diff --git a/kmdc/kmdc-menu-surface/src/jsMain/kotlin/MDCMenuSurface.kt b/kmdc/kmdc-menu-surface/src/jsMain/kotlin/MDCMenuSurface.kt index 0cc32fe1..600006cf 100644 --- a/kmdc/kmdc-menu-surface/src/jsMain/kotlin/MDCMenuSurface.kt +++ b/kmdc/kmdc-menu-surface/src/jsMain/kotlin/MDCMenuSurface.kt @@ -14,6 +14,7 @@ private external val MDCMenuSurfaceStyle: dynamic public data class MDCMenuSurfaceOpts( public var fixed: Boolean = false, + public var fullwidth: Boolean = false ) public open class MDCMenuSurfaceAttrsScope : AttrsBuilder() @@ -34,6 +35,7 @@ public fun MDCMenuSurface( attrs = { classes("mdc-menu-surface") if (options.fixed) classes("mdc-menu-surface--fixed") + if (options.fullwidth) classes("mdc-menu-surface--fullwidth") initialiseMDC(MDCMenuSurfaceModule.MDCMenuSurface::attachTo) attrs?.invoke(this.unsafeCast()) }, diff --git a/kmdc/kmdc-select/build.gradle.kts b/kmdc/kmdc-select/build.gradle.kts new file mode 100644 index 00000000..d041cf7a --- /dev/null +++ b/kmdc/kmdc-select/build.gradle.kts @@ -0,0 +1,23 @@ +import util.mdcVersion + +plugins { + id("plugin.library-compose") + id("plugin.publishing-mpp") +} + +description = "Compose Multiplatform Kotlin/JS wrappers for @material/select" + +kotlin { + sourceSets { + commonMain + jsMain { + dependencies { + api(project(":kmdc:kmdc-core")) + api(project(":kmdc:kmdc-list")) + api(project(":kmdc:kmdc-menu-surface")) + api(npm("@material/select", mdcVersion)) + api(compose.web.svg) + } + } + } +} diff --git a/kmdc/kmdc-select/src/jsMain/kotlin/MDCSelect.kt b/kmdc/kmdc-select/src/jsMain/kotlin/MDCSelect.kt new file mode 100644 index 00000000..f0245629 --- /dev/null +++ b/kmdc/kmdc-select/src/jsMain/kotlin/MDCSelect.kt @@ -0,0 +1,155 @@ +package dev.petuska.kmdc.select + +import androidx.compose.runtime.Composable +import dev.petuska.kmdc.core.Builder +import dev.petuska.kmdc.core.MDCDsl +import dev.petuska.kmdc.core.aria +import dev.petuska.kmdc.core.initialiseMDC +import dev.petuska.kmdc.core.mdc +import dev.petuska.kmdc.core.rememberUniqueDomElementId +import dev.petuska.kmdc.core.role +import dev.petuska.kmdc.list.MDCList +import dev.petuska.kmdc.list.MDCListItem +import dev.petuska.kmdc.list.MDCListItemGraphic +import dev.petuska.kmdc.list.MDCListItemText +import dev.petuska.kmdc.menu.surface.MDCMenuSurface +import org.jetbrains.compose.web.attributes.AttrsBuilder +import org.jetbrains.compose.web.attributes.name +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.ElementScope +import org.jetbrains.compose.web.dom.HiddenInput +import org.jetbrains.compose.web.dom.Text +import org.w3c.dom.HTMLDivElement +import org.w3c.dom.HTMLSpanElement + +@JsModule("@material/select/dist/mdc.select.css") +private external val MDCSelectCSS: dynamic + +public data class MDCSelectOpts( + var type: Type = Type.Filled, + var value: T? = null, + var itemValue: T.() -> String = { toString() }, + var itemDisabled: T.() -> Boolean = { false }, + var label: String? = null, + var required: Boolean = false, + var disabled: Boolean = false, + var hiddenInputName: String? = null, + var helperText: String? = null, + var helperTextType: HelperTextType = HelperTextType.Default, + var leadingIcon: String? = null, + var leadingIconClickable: Boolean = false, + var leadingIconClasses: List = listOf("material-icons") +) { + public enum class Type(public val klass: String) { + Outlined("mdc-select--outlined"), + Filled("mdc-select--filled") + } + + public enum class HelperTextType(public vararg val classes: String) { + Default, + Validation("mdc-select-helper-text--validation-msg"), + PersistentValidation("mdc-select-helper-text--validation-msg", "mdc-select-helper-text--validation-msg-persistent") + } +} + +public class MDCSelectAttrsScope private constructor() : AttrsBuilder() + +/** + * [JS API](https://github.com/material-components/material-components-web/tree/v13.0.0/packages/mdc-select) + */ +@MDCDsl +@Composable +public fun MDCSelect( + items: List, + opts: Builder>? = null, + attrs: Builder>? = null, + renderItem: @Composable ElementScope.(T) -> Unit = { Text(it.toString()) } +) { + MDCSelectCSS + val labelId = rememberUniqueDomElementId() + val selectedTextId = rememberUniqueDomElementId() + val helperTextId = rememberUniqueDomElementId() + val options = MDCSelectOpts().apply { opts?.invoke(this) } + val hasLeadingIcon = options.leadingIcon != null + + fun T.itemValue() = with(options) { itemValue() } + fun T.itemDisabled() = with(options) { itemDisabled() } + + Div( + attrs = { + with(options) { + classes("mdc-select", type.klass) + if (label == null) classes("mdc-select--no-label") + if (required) classes("mdc-select--required") + if (disabled) classes("mdc-select--disabled") + if (required && value?.itemValue().isNullOrBlank()) classes("mdc-select--invalid") + if (hasLeadingIcon) classes("mdc-select--with-leading-icon") + if (helperText != null) { + aria("controls", helperTextId) + aria("describedby", helperTextId) + } + } + initialiseMDC( + mdcInit = { MDCSelectModule.MDCSelect.attachTo(it) }, + postInit = { _, mdc -> mdc.items = items } + ) + attrs?.invoke(this.unsafeCast>()) + } + ) { + + DomSideEffect(options.required) { + it.mdc> { required = options.required } + } + DomSideEffect(options.disabled) { + it.mdc> { disabled = options.disabled } + } + DomSideEffect(options.value) { + it.mdc> { value = options.value?.itemValue() } + } + + options.hiddenInputName?.let { + HiddenInput { name(it) } + } + + MDCSelectAnchor(labelId, options, selectedTextId, items, renderItem) + + MDCMenuSurface( + opts = { fullwidth = true }, + attrs = { + classes("mdc-select__menu", "mdc-menu") + } + ) { + MDCList( + opts = { singleSelection = true }, + attrs = { + options.label?.let { aria("label", it) } + } + ) { + items.forEach { item -> + MDCListItem( + opts = { + this.selected = item == options.value + this.disabled = item.itemDisabled() + }, + attrs = { + attr("data-value", item.itemValue()) + aria("selected", (item == options.value).toString()) + if (item.itemDisabled()) aria("disabled", "true") + role("option") + } + ) { + if (hasLeadingIcon) { + MDCListItemGraphic() + } + MDCListItemText { + renderItem(item) + } + } + } + } + } + } + options.helperText?.let { helperText -> + MDCSelectHelperText(helperTextId, helperText, options.helperTextType) + } +} diff --git a/kmdc/kmdc-select/src/jsMain/kotlin/MDCSelectAnchor.kt b/kmdc/kmdc-select/src/jsMain/kotlin/MDCSelectAnchor.kt new file mode 100644 index 00000000..4102320e --- /dev/null +++ b/kmdc/kmdc-select/src/jsMain/kotlin/MDCSelectAnchor.kt @@ -0,0 +1,78 @@ +package dev.petuska.kmdc.select + +import androidx.compose.runtime.Composable +import dev.petuska.kmdc.core.aria +import dev.petuska.kmdc.core.role +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.ElementScope +import org.jetbrains.compose.web.dom.I +import org.jetbrains.compose.web.dom.Span +import org.jetbrains.compose.web.dom.Text +import org.w3c.dom.HTMLSpanElement + +@Composable +internal fun MDCSelectAnchor( + labelId: String, + options: MDCSelectOpts, + selectedTextId: String, + items: List, + renderItem: @Composable ElementScope.(T) -> Unit +) { + fun T.itemValue() = with(options) { itemValue() } + + Div( + attrs = { + classes("mdc-select__anchor") + role("button") + aria("haspopup", "listbox") + aria("expanded", "false") + aria("labelledby", labelId) + } + ) { + + MDCSelectLabel(options, labelId) + + options.leadingIcon?.let { icon -> + MDCSelectLeadingIcon(options, icon) + } + + Span( + attrs = { + classes("mdc-select__selected-text-container") + } + ) { + Span( + attrs = { + classes("mdc-select__selected-text") + id(selectedTextId) + } + ) { + options.value?.itemValue()?.let { selectedValue -> + items.firstOrNull { it.itemValue() == selectedValue }?.let { + renderItem(it) + } + } + } + } + + MDCSelectDownArrowIcon() + + Span(attrs = { classes("mdc-line-ripple") }) + } +} + +@Composable +private fun MDCSelectLeadingIcon(options: MDCSelectOpts, icon: String) { + I( + attrs = { + classes("mdc-select__icon") + if (options.leadingIconClickable) { + tabIndex(0) + role("button") + } + classes(*options.leadingIconClasses.toTypedArray()) + } + ) { + Text(icon) + } +} diff --git a/kmdc/kmdc-select/src/jsMain/kotlin/MDCSelectDropdownIcon.kt b/kmdc/kmdc-select/src/jsMain/kotlin/MDCSelectDropdownIcon.kt new file mode 100644 index 00000000..e4396d2a --- /dev/null +++ b/kmdc/kmdc-select/src/jsMain/kotlin/MDCSelectDropdownIcon.kt @@ -0,0 +1,43 @@ +package dev.petuska.kmdc.select + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.ExperimentalComposeWebSvgApi +import org.jetbrains.compose.web.dom.Span +import org.jetbrains.compose.web.svg.Polygon +import org.jetbrains.compose.web.svg.Svg + +/** + * [JS API](https://github.com/material-components/material-components-web/tree/v13.0.0/packages/mdc-select) + */ +@OptIn(ExperimentalComposeWebSvgApi::class) +@Composable +internal fun MDCSelectDownArrowIcon() { + Span( + attrs = { classes("mdc-select__dropdown-icon") } + ) { + Svg( + viewBox = "7 10 10 5", + attrs = { + attr("focusable", "false") + classes("mdc-select__dropdown-icon-graphic") + } + ) { + Polygon( + 7, 10, 12, 15, 17, 10, + attrs = { + classes("mdc-select__dropdown-icon-inactive") + attr("stroke", "none") + attr("fill-rule", "evenodd") + } + ) + Polygon( + 7, 15, 12, 10, 17, 15, + attrs = { + classes("mdc-select__dropdown-icon-active") + attr("stroke", "none") + attr("fill-rule", "evenodd") + } + ) + } + } +} diff --git a/kmdc/kmdc-select/src/jsMain/kotlin/MDCSelectHelperText.kt b/kmdc/kmdc-select/src/jsMain/kotlin/MDCSelectHelperText.kt new file mode 100644 index 00000000..c7b27f10 --- /dev/null +++ b/kmdc/kmdc-select/src/jsMain/kotlin/MDCSelectHelperText.kt @@ -0,0 +1,20 @@ +package dev.petuska.kmdc.select + +import androidx.compose.runtime.Composable +import dev.petuska.kmdc.core.initialiseMDC +import org.jetbrains.compose.web.dom.P +import org.jetbrains.compose.web.dom.Text + +@Composable +internal fun MDCSelectHelperText(id: String, text: String, type: MDCSelectOpts.HelperTextType) { + P( + attrs = { + id(id) + classes("mdc-select-helper-text") + classes(*type.classes) + initialiseMDC(MDCSelectModule.MDCSelectHelperText.Companion::attachTo) + } + ) { + Text(text) + } +} diff --git a/kmdc/kmdc-select/src/jsMain/kotlin/MDCSelectLabel.kt b/kmdc/kmdc-select/src/jsMain/kotlin/MDCSelectLabel.kt new file mode 100644 index 00000000..d99ea210 --- /dev/null +++ b/kmdc/kmdc-select/src/jsMain/kotlin/MDCSelectLabel.kt @@ -0,0 +1,51 @@ +package dev.petuska.kmdc.select + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.dom.Span +import org.jetbrains.compose.web.dom.Text + +/** + * [JS API](https://github.com/material-components/material-components-web/tree/v13.0.0/packages/mdc-select) + */ +@Composable +internal fun MDCSelectLabel( + options: MDCSelectOpts, + labelId: String +) { + when (options.type) { + MDCSelectOpts.Type.Filled -> { + Span(attrs = { classes("mdc-select__ripple") }) + options.label?.let { + MDCSelectFloatingLabel(labelId, it) + } + } + MDCSelectOpts.Type.Outlined -> { + Span( + attrs = { classes("mdc-notched-outline") } + ) { + Span(attrs = { classes("mdc-notched-outline__leading") }) + options.label?.let { + Span(attrs = { classes("mdc-notched-outline__notch") }) { + MDCSelectFloatingLabel(labelId, it) + } + } + Span(attrs = { classes("mdc-notched-outline__trailing") }) + } + } + } +} + +/** + * [JS API](https://github.com/material-components/material-components-web/tree/v13.0.0/packages/mdc-select) + */ +@Composable +private fun MDCSelectFloatingLabel(id: String, label: String) { + Span( + attrs = { + classes("mdc-floating-label", "mdc-floating-label--float-above") + id(id) + } + ) { + Text(label) + } +} diff --git a/kmdc/kmdc-select/src/jsMain/kotlin/MDCSelectModule.kt b/kmdc/kmdc-select/src/jsMain/kotlin/MDCSelectModule.kt new file mode 100644 index 00000000..f012d682 --- /dev/null +++ b/kmdc/kmdc-select/src/jsMain/kotlin/MDCSelectModule.kt @@ -0,0 +1,42 @@ +package dev.petuska.kmdc.select + +import dev.petuska.kmdc.core.MDCBaseModule +import dev.petuska.kmdc.core.MDCEvent +import org.w3c.dom.Element + +@JsModule("@material/select") +public external object MDCSelectModule { + public class MDCSelect(element: Element) : MDCBaseModule.MDCComponent { + public companion object { + public fun attachTo(element: Element): MDCSelect + } + + public var value: String? + + public fun setValue(value: String?, skipNotify: Boolean) + + public var disabled: Boolean + + public var required: Boolean + + public var items: List + } + + public class MDCSelectHelperText(element: Element) : MDCBaseModule.MDCComponent { + public companion object { + public fun attachTo(element: Element): MDCSelectHelperText + } + } + + @Suppress("ClassName") + public object strings { + public val CHANGE_EVENT: String + } + + public class MDCSelectChangeEventDetail { + public val value: String + public val index: Int + } + + public class MDCSelectChangeEvent : MDCEvent +} diff --git a/kmdc/kmdc-select/src/jsMain/kotlin/events.kt b/kmdc/kmdc-select/src/jsMain/kotlin/events.kt new file mode 100644 index 00000000..e32ffd3b --- /dev/null +++ b/kmdc/kmdc-select/src/jsMain/kotlin/events.kt @@ -0,0 +1,20 @@ +package dev.petuska.kmdc.select + +import dev.petuska.kmdc.core.MDCAttrsDsl +import dev.petuska.kmdc.core.mdc +import org.w3c.dom.Element + +/** + * [JS API](https://github.com/material-components/material-components-web/tree/v13.0.0/packages/mdc-select) + */ +@MDCAttrsDsl +public fun MDCSelectAttrsScope.onChange(listener: (value: T) -> Unit) { + addEventListener(MDCSelectModule.strings.CHANGE_EVENT) { + val event = it.nativeEvent.unsafeCast() + (event.currentTarget as? Element) + ?.mdc> { + listener(items[event.detail.index]) + } + ?: console.warn("MDCSelect component - current target not found for change event") + } +} diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock index a774e0af..24cc4202 100644 --- a/kotlin-js-store/yarn.lock +++ b/kotlin-js-store/yarn.lock @@ -330,6 +330,29 @@ "@material/typography" "^13.0.0" tslib "^2.1.0" +"@material/select@^13.0.0": + version "13.0.0" + resolved "https://registry.yarnpkg.com/@material/select/-/select-13.0.0.tgz#0bd1933c983ec86440bd5a5540e25aeba3918868" + integrity sha512-wVprsSMicU/l+LAqXdOU+qdzzdHupLXpWWQo2Rsk8G6AxL1Zna+/+ETnRlDdr2wHHK/KNDXSBdmuCcoEIRshcA== + dependencies: + "@material/animation" "^13.0.0" + "@material/base" "^13.0.0" + "@material/density" "^13.0.0" + "@material/dom" "^13.0.0" + "@material/feature-targeting" "^13.0.0" + "@material/floating-label" "^13.0.0" + "@material/line-ripple" "^13.0.0" + "@material/list" "^13.0.0" + "@material/menu" "^13.0.0" + "@material/menu-surface" "^13.0.0" + "@material/notched-outline" "^13.0.0" + "@material/ripple" "^13.0.0" + "@material/rtl" "^13.0.0" + "@material/shape" "^13.0.0" + "@material/theme" "^13.0.0" + "@material/typography" "^13.0.0" + tslib "^2.1.0" + "@material/shape@^13.0.0": version "13.0.0" resolved "https://registry.yarnpkg.com/@material/shape/-/shape-13.0.0.tgz#42751ceb4c709c87026f14340aed1831106fdfda" diff --git a/sandbox/kotlin-js-store/yarn.lock b/sandbox/kotlin-js-store/yarn.lock index 28c321aa..2893eb05 100644 --- a/sandbox/kotlin-js-store/yarn.lock +++ b/sandbox/kotlin-js-store/yarn.lock @@ -330,6 +330,29 @@ "@material/typography" "^13.0.0" tslib "^2.1.0" +"@material/select@^13.0.0": + version "13.0.0" + resolved "https://registry.yarnpkg.com/@material/select/-/select-13.0.0.tgz#0bd1933c983ec86440bd5a5540e25aeba3918868" + integrity sha512-wVprsSMicU/l+LAqXdOU+qdzzdHupLXpWWQo2Rsk8G6AxL1Zna+/+ETnRlDdr2wHHK/KNDXSBdmuCcoEIRshcA== + dependencies: + "@material/animation" "^13.0.0" + "@material/base" "^13.0.0" + "@material/density" "^13.0.0" + "@material/dom" "^13.0.0" + "@material/feature-targeting" "^13.0.0" + "@material/floating-label" "^13.0.0" + "@material/line-ripple" "^13.0.0" + "@material/list" "^13.0.0" + "@material/menu" "^13.0.0" + "@material/menu-surface" "^13.0.0" + "@material/notched-outline" "^13.0.0" + "@material/ripple" "^13.0.0" + "@material/rtl" "^13.0.0" + "@material/shape" "^13.0.0" + "@material/theme" "^13.0.0" + "@material/typography" "^13.0.0" + tslib "^2.1.0" + "@material/shape@^13.0.0": version "13.0.0" resolved "https://registry.yarnpkg.com/@material/shape/-/shape-13.0.0.tgz#42751ceb4c709c87026f14340aed1831106fdfda" diff --git a/sandbox/src/jsMain/kotlin/samples/Select.kt b/sandbox/src/jsMain/kotlin/samples/Select.kt new file mode 100644 index 00000000..8be3156e --- /dev/null +++ b/sandbox/src/jsMain/kotlin/samples/Select.kt @@ -0,0 +1,139 @@ +package local.sandbox.samples + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import dev.petuska.kmdc.checkbox.MDCCheckbox +import dev.petuska.kmdc.form.field.MDCFormField +import dev.petuska.kmdc.select.MDCSelect +import dev.petuska.kmdc.select.MDCSelectOpts +import dev.petuska.kmdc.select.onChange +import dev.petuska.kmdcx.icons.MDCIconOpts +import local.sandbox.engine.Sample +import local.sandbox.engine.Samples +import org.jetbrains.compose.web.css.px +import org.jetbrains.compose.web.css.width +import org.jetbrains.compose.web.dom.Br +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.Span +import org.jetbrains.compose.web.dom.Text + +@Suppress("unused") +private val SelectSamples = Samples("MDCSelect") { + var selectedValue by remember { mutableStateOf(fruits[1]) } + var isRequired by remember { mutableStateOf(false) } + var isDisabled by remember { mutableStateOf(false) } + + Sample("Filled") { + Div { + MDCSelect( + fruits + "Potato", + opts = { + type = MDCSelectOpts.Type.Filled + value = selectedValue + required = isRequired + disabled = isDisabled + itemDisabled = { this == "Potato" } + hiddenInputName = "filledSelect" + label = "Fruit" + }, + attrs = { + onChange { selectedValue = it } + } + ) + } + Div { + MDCFormField { + MDCCheckbox(isRequired, opts = { label = "Required" }, attrs = { onChange { isRequired = it.value } }) + } + } + Div { + MDCFormField { + MDCCheckbox(isDisabled, opts = { label = "Disabled" }, attrs = { onChange { isDisabled = it.value } }) + } + } + } + + Sample("Outlined") { + MDCSelect( + fruits + "Potato", + opts = { + type = MDCSelectOpts.Type.Outlined + value = selectedValue + label = "Fruit" + itemDisabled = { this == "Potato" } + }, + attrs = { + onChange { selectedValue = it } + } + ) + } + + Sample("Helper text") { + var helperTextType by remember { mutableStateOf(MDCSelectOpts.HelperTextType.Default) } + Div { + MDCSelect( + MDCSelectOpts.HelperTextType.values().toList(), + opts = { + type = MDCSelectOpts.Type.Filled + value = helperTextType + }, + attrs = { + onChange { helperTextType = it } + } + ) { + Text(it.name) + } + } + Br() + Div { + MDCSelect( + fruits, + opts = { + type = MDCSelectOpts.Type.Outlined + value = selectedValue + label = "Fruit" + required = true + helperText = "Please pick up your favorite fruit" + this.helperTextType = helperTextType + }, + attrs = { + onChange { selectedValue = it } + } + ) + } + } + + Sample("With leading icon") { + MDCSelect( + fruits, + opts = { + type = MDCSelectOpts.Type.Filled + value = selectedValue + leadingIcon = MDCIconOpts.MDCIconType.FoodBank.iconType + leadingIconClickable = true + }, + attrs = { + onChange { selectedValue = it } + } + ) + + Span(attrs = { style { width(10.px) } }) + + MDCSelect( + fruits, + opts = { + type = MDCSelectOpts.Type.Outlined + value = selectedValue + leadingIcon = MDCIconOpts.MDCIconType.FoodBank.iconType + leadingIconClickable = false + }, + attrs = { + onChange { selectedValue = it } + } + ) + } +} + +private val fruits = listOf("", "Apple", "Orange", "Banana")