diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e2bfb17..e6816d55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - [x] mdc-radio - [x] mdc-tooltip - [x] mdc-segmented-button +- [x] mdc-snackbar - [x] material-icons # v0.0.1 diff --git a/README.md b/README.md index ceeb162d..5dafd71c 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ fun Sample() { ### Progress -Here's a tracker list of currently completed *material-components-web* modules (23/49): +Here's a tracker list of currently completed *material-components-web* modules (24/49): - [ ] mdc-animation (SASS) - [x] mdc-auto-init (won't wrap) @@ -109,7 +109,7 @@ Here's a tracker list of currently completed *material-components-web* modules ( - [ ] mdc-select - [ ] mdc-shape (SASS) - [ ] mdc-slider -- [ ] mdc-snackbar +- [x] mdc-snackbar - [ ] mdc-switch - [ ] mdc-tab-bar - [ ] mdc-tab-indicator diff --git a/kmdc/kmdc-core/src/jsMain/kotlin/util.kt b/kmdc/kmdc-core/src/jsMain/kotlin/util.kt index da149711..80686eb3 100644 --- a/kmdc/kmdc-core/src/jsMain/kotlin/util.kt +++ b/kmdc/kmdc-core/src/jsMain/kotlin/util.kt @@ -1,7 +1,10 @@ package dev.petuska.kmdc.core import androidx.compose.runtime.Composable +import org.jetbrains.compose.web.attributes.AttrsBuilder import org.w3c.dom.Element +import org.w3c.dom.HTMLElement +import org.w3c.dom.events.Event @JsName("require") public external fun requireJsModule(module: String): dynamic @@ -15,6 +18,14 @@ public annotation class MDCAttrsDsl public typealias Builder = T.() -> Unit public typealias ComposableBuilder = @Composable Builder +public external interface Destroyable { + public fun destroy() +} + +public abstract external class MDCEvent : Event { + public var detail: T +} + public data class Wrapper(val value: T) public fun T.wrap(): Wrapper = Wrapper(this) @@ -29,3 +40,18 @@ public fun Element.mdc(action: Builder? = null): T? = mdc.unsafeCast( public inline fun jsObject(builder: Builder = { }): T = js("({})").unsafeCast().apply(builder) + +@MDCAttrsDsl +public fun AttrsBuilder.initialiseMDC(mdcInit: (HTMLElement) -> T, onDispose: Builder? = null) { + ref { + it.mdc = mdcInit(it) + onDispose { + it.mdc(onDispose) + } + } +} + +@MDCAttrsDsl +public fun AttrsBuilder.initialiseMDC(mdcInit: (HTMLElement) -> T) { + initialiseMDC(mdcInit, Destroyable::destroy) +} diff --git a/kmdc/kmdc-icon-button/src/jsMain/kotlin/MDCIconButton.kt b/kmdc/kmdc-icon-button/src/jsMain/kotlin/MDCIconButton.kt index 571e6f91..fe83dcc1 100644 --- a/kmdc/kmdc-icon-button/src/jsMain/kotlin/MDCIconButton.kt +++ b/kmdc/kmdc-icon-button/src/jsMain/kotlin/MDCIconButton.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import dev.petuska.kmdc.core.Builder import dev.petuska.kmdc.core.ComposableBuilder import dev.petuska.kmdc.core.MDCDsl +import dev.petuska.kmdc.core.initialiseMDC import dev.petuska.kmdc.core.mdc import dev.petuska.kmdc.ripple.MDCRipple import org.jetbrains.compose.web.dom.A @@ -11,23 +12,12 @@ import org.jetbrains.compose.web.dom.AttrBuilderContext import org.jetbrains.compose.web.dom.Button import org.jetbrains.compose.web.dom.ElementScope import org.jetbrains.compose.web.dom.Span -import org.w3c.dom.Element import org.w3c.dom.HTMLAnchorElement import org.w3c.dom.HTMLButtonElement @JsModule("@material/icon-button/dist/mdc.icon-button.css") private external val MDCIconButtonStyle: dynamic -@JsModule("@material/icon-button") -private external object MDCIconButtonModule { - class MDCIconButtonToggle(element: Element) { - companion object { - fun attachTo(element: Element): MDCIconButtonToggle - } - fun destroy() - } -} - public data class MDCIconButtonOpts(var on: Boolean = false) public class MDCIconButtonScope(scope: ElementScope) : ElementScope by scope @@ -49,12 +39,7 @@ public fun MDCIconButton( Button( attrs = { classes(*listOfNotNull("mdc-icon-button", if (options.on) "mdc-icon-button--on" else null).toTypedArray()) - ref { - it.mdc = MDCIconButtonModule.MDCIconButtonToggle.attachTo(it) - onDispose { - it.mdc { destroy() } - } - } + initialiseMDC(MDCIconButtonModule.MDCIconButtonToggle::attachTo) attrs?.invoke(this) }, ) { diff --git a/kmdc/kmdc-icon-button/src/jsMain/kotlin/MDCIconButtonModule.kt b/kmdc/kmdc-icon-button/src/jsMain/kotlin/MDCIconButtonModule.kt new file mode 100644 index 00000000..04885d7d --- /dev/null +++ b/kmdc/kmdc-icon-button/src/jsMain/kotlin/MDCIconButtonModule.kt @@ -0,0 +1,15 @@ +package dev.petuska.kmdc.icon.button + +import dev.petuska.kmdc.core.Destroyable +import org.w3c.dom.Element + +@JsModule("@material/icon-button") +public external object MDCIconButtonModule { + public class MDCIconButtonToggle(element: Element) : Destroyable { + public companion object { + public fun attachTo(element: Element): MDCIconButtonToggle + } + + override fun destroy() + } +} diff --git a/kmdc/kmdc-snackbar/build.gradle.kts b/kmdc/kmdc-snackbar/build.gradle.kts new file mode 100644 index 00000000..d7cb33ce --- /dev/null +++ b/kmdc/kmdc-snackbar/build.gradle.kts @@ -0,0 +1,19 @@ +import util.mdcVersion + +plugins { + id("plugin.library-compose") + id("plugin.publishing-mpp") +} + +kotlin { + sourceSets { + named("jsMain") { + dependencies { + api(project(":kmdc:kmdc-core")) + api(project(":kmdc:kmdc-button")) + api(project(":kmdc:kmdc-icon-button")) + api(npm("@material/snackbar", mdcVersion)) + } + } + } +} diff --git a/kmdc/kmdc-snackbar/src/jsMain/kotlin/MDCSnackbar.kt b/kmdc/kmdc-snackbar/src/jsMain/kotlin/MDCSnackbar.kt new file mode 100644 index 00000000..d001d64a --- /dev/null +++ b/kmdc/kmdc-snackbar/src/jsMain/kotlin/MDCSnackbar.kt @@ -0,0 +1,68 @@ +package dev.petuska.kmdc.snackbar + +import MDCSnackbarModule +import androidx.compose.runtime.Composable +import dev.petuska.kmdc.core.Builder +import dev.petuska.kmdc.core.ComposableBuilder +import dev.petuska.kmdc.core.MDCDsl +import dev.petuska.kmdc.core.initialiseMDC +import dev.petuska.kmdc.core.mdc +import org.jetbrains.compose.web.dom.Aside +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.ElementScope +import org.w3c.dom.HTMLElement + +@JsModule("@material/snackbar/dist/mdc.snackbar.css") +private external val MDCSnackbarCSS: dynamic + +public data class MDCSnackbarOpts( + var type: Type = Type.Default, + var open: Boolean = false, + var dismissible: Boolean = false, +) { + public enum class Type(public vararg val classes: String) { + Default, + Stacked("mdc-snackbar--stacked"), + Leading("mdc-snackbar--leading"), + } +} + +public class MDCSnackbarScope(scope: ElementScope) : ElementScope by scope + +/** + * [JS API](https://github.com/material-components/material-components-web/tree/v13.0.0/packages/mdc-snackbar) + */ +@MDCDsl +@Composable +public fun MDCSnackbar( + opts: Builder? = null, + attrs: AttrBuilderContext? = null, + content: ComposableBuilder? = null, +) { + val options = MDCSnackbarOpts().apply { opts?.invoke(this) } + MDCSnackbarCSS + Aside(attrs = { + classes("mdc-snackbar", *options.type.classes) + attrs?.invoke(this) + initialiseMDC(MDCSnackbarModule.MDCSnackbar.Companion::attachTo) + }) { + DomSideEffect(options.open) { + it.mdc { + if (options.open) { + open() + } else { + close() + } + } + } + Div( + attrs = { + classes("mdc-snackbar__surface") + attr("role", "status") + attr("aria-relevant", "additions") + }, + content = content?.let { { MDCSnackbarScope(this).it() } } + ) + } +} diff --git a/kmdc/kmdc-snackbar/src/jsMain/kotlin/MDCSnackbarActions.kt b/kmdc/kmdc-snackbar/src/jsMain/kotlin/MDCSnackbarActions.kt new file mode 100644 index 00000000..ff6071f9 --- /dev/null +++ b/kmdc/kmdc-snackbar/src/jsMain/kotlin/MDCSnackbarActions.kt @@ -0,0 +1,95 @@ +package dev.petuska.kmdc.snackbar + +import androidx.compose.runtime.Composable +import dev.petuska.kmdc.button.MDCButton +import dev.petuska.kmdc.button.MDCButtonLabel +import dev.petuska.kmdc.button.MDCButtonOpts +import dev.petuska.kmdc.button.MDCButtonScope +import dev.petuska.kmdc.core.Builder +import dev.petuska.kmdc.core.ComposableBuilder +import dev.petuska.kmdc.core.MDCDsl +import dev.petuska.kmdc.icon.button.MDCIconButton +import dev.petuska.kmdc.icon.button.MDCIconButtonOpts +import dev.petuska.kmdc.icon.button.MDCIconButtonScope +import org.jetbrains.compose.web.attributes.ButtonType +import org.jetbrains.compose.web.attributes.type +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.ElementScope +import org.w3c.dom.HTMLButtonElement +import org.w3c.dom.HTMLDivElement + +public class MDCSnackbarActionsScope(scope: ElementScope) : ElementScope by scope + +/** + * [JS API](https://github.com/material-components/material-components-web/tree/v13.0.0/packages/mdc-snackbar) + */ +@MDCDsl +@Composable +public fun MDCSnackbarScope.MDCSnackbarActions( + attrs: AttrBuilderContext? = null, + content: ComposableBuilder? = null, +) { + Div( + attrs = { + classes("mdc-snackbar__actions") + attr("aria-atomic", "true") + attrs?.invoke(this) + }, + content = content?.let { { MDCSnackbarActionsScope(this).it() } } + ) +} + +/** + * [JS API](https://github.com/material-components/material-components-web/tree/v13.0.0/packages/mdc-snackbar) + */ +@MDCDsl +@Composable +public fun MDCSnackbarActionsScope.MDCSnackbarAction( + opts: Builder? = null, + attrs: AttrBuilderContext? = null, + content: ComposableBuilder? = null, +) { + MDCButton( + opts = opts, + attrs = { + classes("mdc-snackbar__action") + type(ButtonType.Button) + attrs?.invoke(this) + }, + content = content, + ) +} + +/** + * [JS API](https://github.com/material-components/material-components-web/tree/v13.0.0/packages/mdc-snackbar) + */ +@MDCDsl +@Composable +public fun MDCSnackbarActionsScope.MDCSnackbarAction( + text: String, + opts: Builder? = null, + attrs: AttrBuilderContext? = null, +) { + MDCSnackbarAction(opts, attrs) { MDCButtonLabel(text) } +} + +/** + * [JS API](https://github.com/material-components/material-components-web/tree/v13.0.0/packages/mdc-snackbar) + */ +@MDCDsl +@Composable +public fun MDCSnackbarActionsScope.MDCSnackbarDismiss( + opts: Builder? = null, + attrs: AttrBuilderContext? = null, + content: ComposableBuilder? = null, +) { + MDCIconButton( + opts = opts, + attrs = { + classes("mdc-snackbar__dismiss") + attrs?.invoke(this) + }, + content = content, + ) +} diff --git a/kmdc/kmdc-snackbar/src/jsMain/kotlin/MDCSnackbarLabel.kt b/kmdc/kmdc-snackbar/src/jsMain/kotlin/MDCSnackbarLabel.kt new file mode 100644 index 00000000..2823dcff --- /dev/null +++ b/kmdc/kmdc-snackbar/src/jsMain/kotlin/MDCSnackbarLabel.kt @@ -0,0 +1,40 @@ +package dev.petuska.kmdc.snackbar + +import androidx.compose.runtime.Composable +import dev.petuska.kmdc.core.MDCDsl +import org.jetbrains.compose.web.dom.AttrBuilderContext +import org.jetbrains.compose.web.dom.ContentBuilder +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.Text +import org.w3c.dom.HTMLDivElement + +/** + * [JS API](https://github.com/material-components/material-components-web/tree/v13.0.0/packages/mdc-snackbar) + */ +@MDCDsl +@Composable +public fun MDCSnackbarScope.MDCSnackbarLabel( + attrs: AttrBuilderContext? = null, + content: ContentBuilder? = null, +) { + Div( + attrs = { + classes("mdc-snackbar__label") + attr("aria-atomic", "false") + attrs?.invoke(this) + }, + content = content + ) +} + +/** + * [JS API](https://github.com/material-components/material-components-web/tree/v13.0.0/packages/mdc-snackbar) + */ +@MDCDsl +@Composable +public fun MDCSnackbarScope.MDCSnackbarLabel( + text: String, + attrs: AttrBuilderContext? = null, +) { + MDCSnackbarLabel(attrs) { Text(text) } +} diff --git a/kmdc/kmdc-snackbar/src/jsMain/kotlin/MDCSnackbarModule.kt b/kmdc/kmdc-snackbar/src/jsMain/kotlin/MDCSnackbarModule.kt new file mode 100644 index 00000000..1af43b7e --- /dev/null +++ b/kmdc/kmdc-snackbar/src/jsMain/kotlin/MDCSnackbarModule.kt @@ -0,0 +1,38 @@ +import dev.petuska.kmdc.core.Destroyable +import dev.petuska.kmdc.core.MDCEvent +import org.w3c.dom.Element + +@JsModule("@material/snackbar") +public external object MDCSnackbarModule { + public class MDCSnackbar(element: Element) : Destroyable { + public companion object { + public fun attachTo(element: Element): MDCSnackbar + } + + public fun initialize( + segmentFactory: ( + el: Element, + foundation: dynamic + ) -> (() -> (ariaEl: Element, labelEl: Element?) -> Unit) = definedExternally + ) + + public fun initialSyncWithDOM() + public override fun destroy() + public fun open() + public fun close(reason: String = definedExternally) + public fun getDefaultFoundation(): dynamic + public var timeoutMs: Number + public var closeOnEscape: Boolean + public val isOpen: Boolean + public var labelText: String + public var actionButtonText: String + } + + public interface MDCSnackbarOpenEventDetail + public interface MDCSnackbarCloseEventDetail { + public val reason: String? + } + + public class MDCSnackbarOpenEvent : MDCEvent + public class MDCSnackbarCloseEvent : MDCEvent +} diff --git a/kmdc/kmdc-snackbar/src/jsMain/kotlin/events.kt b/kmdc/kmdc-snackbar/src/jsMain/kotlin/events.kt new file mode 100644 index 00000000..2e509660 --- /dev/null +++ b/kmdc/kmdc-snackbar/src/jsMain/kotlin/events.kt @@ -0,0 +1,54 @@ +package dev.petuska.kmdc.snackbar + +import MDCSnackbarModule +import dev.petuska.kmdc.core.MDCAttrsDsl +import org.jetbrains.compose.web.attributes.AttrsBuilder +import org.w3c.dom.HTMLElement + +/** + * [JS API](https://github.com/material-components/material-components-web/tree/v13.0.0/packages/mdc-snackbar) + */ +@MDCAttrsDsl +public inline fun AttrsBuilder.onSnackbarOpening( + crossinline listener: (MDCSnackbarModule.MDCSnackbarOpenEvent) -> Unit +) { + addEventListener("MDCSnackbar:opening") { + listener(it.nativeEvent.unsafeCast()) + } +} + +/** + * [JS API](https://github.com/material-components/material-components-web/tree/v13.0.0/packages/mdc-snackbar) + */ +@MDCAttrsDsl +public inline fun AttrsBuilder.onSnackbarOpened( + crossinline listener: (MDCSnackbarModule.MDCSnackbarOpenEvent) -> Unit +) { + addEventListener("MDCSnackbar:opened") { + listener(it.nativeEvent.unsafeCast()) + } +} + +/** + * [JS API](https://github.com/material-components/material-components-web/tree/v13.0.0/packages/mdc-snackbar) + */ +@MDCAttrsDsl +public inline fun AttrsBuilder.onSnackbarClosing( + crossinline listener: (MDCSnackbarModule.MDCSnackbarCloseEvent) -> Unit +) { + addEventListener("MDCSnackbar:closing") { + listener(it.nativeEvent.unsafeCast()) + } +} + +/** + * [JS API](https://github.com/material-components/material-components-web/tree/v13.0.0/packages/mdc-snackbar) + */ +@MDCAttrsDsl +public inline fun AttrsBuilder.onSnackbarClosed( + crossinline listener: (MDCSnackbarModule.MDCSnackbarCloseEvent) -> Unit +) { + addEventListener("MDCSnackbar:closed") { + listener(it.nativeEvent.unsafeCast()) + } +} diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock index 1b003ef0..4a993c0a 100644 --- a/kotlin-js-store/yarn.lock +++ b/kotlin-js-store/yarn.lock @@ -310,6 +310,25 @@ "@material/theme" "^13.0.0" tslib "^2.1.0" +"@material/snackbar@^13.0.0": + version "13.0.0" + resolved "https://registry.yarnpkg.com/@material/snackbar/-/snackbar-13.0.0.tgz#63798b832c6931ecb99350c9ef99ac23a7e47f18" + integrity sha512-z59aYCeMWWEbsUU04QDZN4CxzCCOp3OIc5tzrdqnY3qRq4wwApxncf7RKKKSU2K6WTEWfdHHOc7aNX8kqlDmUg== + dependencies: + "@material/animation" "^13.0.0" + "@material/base" "^13.0.0" + "@material/button" "^13.0.0" + "@material/dom" "^13.0.0" + "@material/elevation" "^13.0.0" + "@material/feature-targeting" "^13.0.0" + "@material/icon-button" "^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/textfield@^13.0.0": version "13.0.0" resolved "https://registry.yarnpkg.com/@material/textfield/-/textfield-13.0.0.tgz#64cd2677ed954a0287b6e5b7b07dbdbf07328ef0" diff --git a/sandbox/kotlin-js-store/yarn.lock b/sandbox/kotlin-js-store/yarn.lock index 9014e4d7..2499af37 100644 --- a/sandbox/kotlin-js-store/yarn.lock +++ b/sandbox/kotlin-js-store/yarn.lock @@ -310,6 +310,25 @@ "@material/theme" "^13.0.0" tslib "^2.1.0" +"@material/snackbar@^13.0.0": + version "13.0.0" + resolved "https://registry.yarnpkg.com/@material/snackbar/-/snackbar-13.0.0.tgz#63798b832c6931ecb99350c9ef99ac23a7e47f18" + integrity sha512-z59aYCeMWWEbsUU04QDZN4CxzCCOp3OIc5tzrdqnY3qRq4wwApxncf7RKKKSU2K6WTEWfdHHOc7aNX8kqlDmUg== + dependencies: + "@material/animation" "^13.0.0" + "@material/base" "^13.0.0" + "@material/button" "^13.0.0" + "@material/dom" "^13.0.0" + "@material/elevation" "^13.0.0" + "@material/feature-targeting" "^13.0.0" + "@material/icon-button" "^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/textfield@^13.0.0": version "13.0.0" resolved "https://registry.yarnpkg.com/@material/textfield/-/textfield-13.0.0.tgz#64cd2677ed954a0287b6e5b7b07dbdbf07328ef0" diff --git a/sandbox/src/jsMain/kotlin/samples/Snackbar.kt b/sandbox/src/jsMain/kotlin/samples/Snackbar.kt new file mode 100644 index 00000000..f4bc5403 --- /dev/null +++ b/sandbox/src/jsMain/kotlin/samples/Snackbar.kt @@ -0,0 +1,60 @@ +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.button.MDCButton +import dev.petuska.kmdc.snackbar.MDCSnackbar +import dev.petuska.kmdc.snackbar.MDCSnackbarAction +import dev.petuska.kmdc.snackbar.MDCSnackbarActions +import dev.petuska.kmdc.snackbar.MDCSnackbarDismiss +import dev.petuska.kmdc.snackbar.MDCSnackbarLabel +import dev.petuska.kmdc.snackbar.MDCSnackbarOpts +import dev.petuska.kmdc.snackbar.onSnackbarClosed +import dev.petuska.kmdc.snackbar.onSnackbarClosing +import dev.petuska.kmdc.snackbar.onSnackbarOpened +import dev.petuska.kmdc.snackbar.onSnackbarOpening +import local.sandbox.engine.Sample +import local.sandbox.engine.Samples +import org.jetbrains.compose.web.attributes.AttrsBuilder +import org.jetbrains.compose.web.dom.Text +import org.w3c.dom.HTMLElement + +@Suppress("unused") +private val SnackbarSamples = Samples("MDCSnackbar") { + MDCSnackbarOpts.Type.values().forEach { type -> + Sample("$type") { name -> + var open by remember { mutableStateOf(false) } + MDCButton("Toggle Snackbar", attrs = { onClick { open = !open } }) + MDCSnackbar( + opts = { + this.open = open + this.type = type + }, + attrs = { + registerEvents(name) + onSnackbarClosed { open = false } + } + ) { + MDCSnackbarLabel("Can't send photo. Retry in 5 seconds.") + MDCSnackbarActions { + MDCSnackbarAction("Retry", attrs = { + onClick { console.log("$name#Retried") } + }) + MDCSnackbarDismiss(attrs = { + classes("material-icons") + title("Dismiss") + }) { Text("close") } + } + } + } + } +} + +private fun AttrsBuilder.registerEvents(name: String) { + onSnackbarOpening { console.log("$name#onSnackbarOpening", it.detail) } + onSnackbarOpened { console.log("$name#onSnackbarOpened", it.detail) } + onSnackbarClosing { console.log("$name#onSnackbarClosing", it.detail) } + onSnackbarClosed { console.log("$name#onSnackbarClosed", it.detail) } +}