diff --git a/demo/index.html b/demo/index.html index b442c24..408b731 100644 --- a/demo/index.html +++ b/demo/index.html @@ -19,6 +19,7 @@ @@include('./partials/palette.html') @@include('./partials/utilities.html') @@include('./partials/buttons.html') + @@include('./partials/choices.html') @@include('./partials/inputs.html') @@include('./partials/badges.html') @@include('./partials/alerts.html') diff --git a/demo/partials/choices.html b/demo/partials/choices.html new file mode 100644 index 0000000..088e718 --- /dev/null +++ b/demo/partials/choices.html @@ -0,0 +1,67 @@ + + Choice controls + + Checkbox + + + + + Checked + + + + + Unchecked + + + + + Disabled checked + + + + + Disabled + + + + Radio + + + + + + Option A + + + + + Option B + + + + + Option C disabled + + + + + Switch + + + + + Enabled + + + + + Disabled state + + + + + Disabled on + + + diff --git a/docs/component-coverage.md b/docs/component-coverage.md index c6c10f7..30d32ae 100644 --- a/docs/component-coverage.md +++ b/docs/component-coverage.md @@ -18,10 +18,10 @@ | Input | done | GnInput | partial | | Badge | done | GnBadge | partial | | Alert | done | GnAlert | partial | -| Checkbox | — | — | — | -| Radio | — | — | — | +| Checkbox | done | GnCheckbox | partial | +| Radio | done | GnRadio, GnRadioGroup | partial | | Select | — | — | — | -| Switch | — | — | — | +| Switch | done | GnSwitch | partial | | Card | — | — | — | | Modal | — | — | — | | Drawer | — | — | — | diff --git a/examples/vue/src/main.js b/examples/vue/src/main.js index fb651d6..fa69035 100644 --- a/examples/vue/src/main.js +++ b/examples/vue/src/main.js @@ -7,7 +7,9 @@ setup() { const count = ref(0); const loading = ref(false); - return { count, loading }; + const checked = ref(true); + const radio = ref("a"); + return { count, loading, checked, radio }; }, template: ` @@ -29,6 +31,16 @@ Vue adapter alert component. + + Agree to terms + Loading mode + + + + ` }; diff --git a/src/scss/components/_checkbox.scss b/src/scss/components/_checkbox.scss new file mode 100644 index 0000000..54834c5 --- /dev/null +++ b/src/scss/components/_checkbox.scss @@ -0,0 +1,91 @@ +/* ========================= + Checkbox +========================= */ + +@use "../kit-deps" as *; + +.checkbox { + display: inline-flex; + align-items: center; + gap: $space-2; + cursor: pointer; + font-size: 15px; + color: $neutral-900; + + input[type="checkbox"] { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; + padding: 0; + margin: 0; + } + + .checkbox-control { + width: 20px; + height: 20px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid $neutral-400; + border-radius: $radius-sm; + background-color: $neutral-0; + transition: + border-color $motion-base $motion-ease, + background-color $motion-base $motion-ease; + + &::after { + content: ""; + width: 5px; + height: 9px; + border: solid $neutral-0; + border-width: 0 2px 2px 0; + transform: rotate(45deg) translateY(-1px) scale(0); + opacity: 0; + transition: opacity $motion-base $motion-ease, transform $motion-base $motion-ease; + } + } + + &:hover .checkbox-control { + border-color: $mint-600; + } + + input[type="checkbox"]:focus-visible + .checkbox-control { + @include focus_ring; + } + + input[type="checkbox"]:checked + .checkbox-control { + background-color: $mint-600; + border-color: $mint-600; + + &::after { + transform: rotate(45deg) translateY(-1px) scale(1); + opacity: 1; + } + } + + input[type="checkbox"]:disabled + .checkbox-control { + background-color: $neutral-100; + border-color: $neutral-300; + cursor: not-allowed; + } + + input[type="checkbox"]:checked:disabled + .checkbox-control { + background-color: $neutral-400; + border-color: $neutral-400; + } + + input[type="checkbox"]:disabled ~ .checkbox-label { + color: $neutral-500; + cursor: not-allowed; + } + + .checkbox-label { + font-weight: $font-weight-medium; + line-height: 1.25; + } +} diff --git a/src/scss/components/_radio.scss b/src/scss/components/_radio.scss new file mode 100644 index 0000000..032e9e5 --- /dev/null +++ b/src/scss/components/_radio.scss @@ -0,0 +1,103 @@ +/* ========================= + Radio +========================= */ + +@use "../kit-deps" as *; + +.radio { + display: inline-flex; + align-items: center; + gap: $space-2; + cursor: pointer; + font-size: 15px; + color: $neutral-900; + + input[type="radio"] { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; + padding: 0; + margin: 0; + } + + .radio-control { + width: 20px; + height: 20px; + flex-shrink: 0; + border: 1px solid $neutral-400; + border-radius: 50%; + background-color: $neutral-0; + position: relative; + transition: + border-color $motion-base $motion-ease, + background-color $motion-base $motion-ease; + + &::after { + content: ""; + position: absolute; + inset: 0; + margin: auto; + width: 10px; + height: 10px; + border-radius: 50%; + background-color: $neutral-0; + transform: scale(0); + transition: transform $motion-base $motion-ease; + } + } + + &:hover .radio-control { + border-color: $mint-600; + } + + input[type="radio"]:focus-visible + .radio-control { + @include focus_ring; + } + + input[type="radio"]:checked + .radio-control { + background-color: $mint-600; + border-color: $mint-600; + + &::after { + transform: scale(1); + } + } + + input[type="radio"]:disabled + .radio-control { + background-color: $neutral-100; + border-color: $neutral-300; + cursor: not-allowed; + } + + input[type="radio"]:checked:disabled + .radio-control { + background-color: $neutral-400; + border-color: $neutral-400; + } + + input[type="radio"]:disabled ~ .radio-label { + color: $neutral-500; + cursor: not-allowed; + } + + .radio-label { + font-weight: $font-weight-medium; + line-height: 1.25; + } +} + +.radio-group { + display: flex; + flex-direction: column; + gap: $space-2; + + > .label { + font-size: 14px; + font-weight: $font-weight-semibold; + color: $neutral-900; + margin-bottom: $space-1; + } +} diff --git a/src/scss/components/_switch.scss b/src/scss/components/_switch.scss new file mode 100644 index 0000000..d4aeacb --- /dev/null +++ b/src/scss/components/_switch.scss @@ -0,0 +1,84 @@ +/* ========================= + Switch +========================= */ + +@use "../kit-deps" as *; + +.switch { + display: inline-flex; + align-items: center; + gap: $space-2; + cursor: pointer; + font-size: 15px; + color: $neutral-900; + + input[type="checkbox"] { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; + padding: 0; + margin: 0; + } + + .switch-control { + width: 44px; + height: 24px; + flex-shrink: 0; + border-radius: 9999px; + background-color: $neutral-300; + position: relative; + transition: background-color $motion-base $motion-ease; + + &::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background-color: $neutral-0; + box-shadow: 0 1px 3px rgba($neutral-900, 0.16); + transition: transform $motion-base $motion-ease; + } + } + + &:hover .switch-control { + background-color: $neutral-400; + } + + input[type="checkbox"]:focus-visible + .switch-control { + @include focus_ring; + } + + input[type="checkbox"]:checked + .switch-control { + background-color: $mint-600; + + &::after { + transform: translateX(20px); + } + } + + input[type="checkbox"]:disabled + .switch-control { + background-color: $neutral-200; + cursor: not-allowed; + } + + input[type="checkbox"]:checked:disabled + .switch-control { + background-color: $neutral-400; + } + + input[type="checkbox"]:disabled ~ .switch-label { + color: $neutral-500; + cursor: not-allowed; + } + + .switch-label { + font-weight: $font-weight-medium; + line-height: 1.25; + } +} diff --git a/src/scss/kit.scss b/src/scss/kit.scss index 453f00d..98f307f 100644 --- a/src/scss/kit.scss +++ b/src/scss/kit.scss @@ -6,6 +6,9 @@ @use "components/badges"; @use "components/alerts"; @use "components/forms"; +@use "components/checkbox"; +@use "components/radio"; +@use "components/switch"; @use "utils"; * { diff --git a/src/vue/components/GnCheckbox.js b/src/vue/components/GnCheckbox.js new file mode 100644 index 0000000..e02bf3e --- /dev/null +++ b/src/vue/components/GnCheckbox.js @@ -0,0 +1,26 @@ +import { defineComponent, h } from "vue"; +import { cx, eventValue } from "../utils.js"; + +export default defineComponent({ + name: "GnCheckbox", + inheritAttrs: false, + props: { + modelValue: { type: Boolean, default: false }, + label: { type: String, default: "" }, + disabled: { type: Boolean, default: false } + }, + emits: ["update:modelValue"], + setup(props, { attrs, emit, slots }) { + return () => h("label", { class: cx("checkbox", attrs.class) }, [ + h("input", { + ...attrs, + type: "checkbox", + checked: props.modelValue, + disabled: props.disabled, + onChange: event => emit("update:modelValue", eventValue(event)) + }), + h("span", { class: "checkbox-control", "aria-hidden": "true" }), + h("span", { class: "checkbox-label" }, slots.default?.() || props.label) + ]); + } +}); diff --git a/src/vue/components/GnRadio.js b/src/vue/components/GnRadio.js new file mode 100644 index 0000000..74af983 --- /dev/null +++ b/src/vue/components/GnRadio.js @@ -0,0 +1,30 @@ +import { defineComponent, h } from "vue"; +import { cx } from "../utils.js"; + +export default defineComponent({ + name: "GnRadio", + inheritAttrs: false, + props: { + modelValue: { type: [String, Number, Boolean], default: "" }, + value: { type: [String, Number, Boolean], required: true }, + label: { type: String, default: "" }, + name: { type: String, default: "" }, + disabled: { type: Boolean, default: false } + }, + emits: ["update:modelValue"], + setup(props, { attrs, emit, slots }) { + return () => h("label", { class: cx("radio", attrs.class) }, [ + h("input", { + ...attrs, + type: "radio", + name: props.name, + value: props.value, + checked: props.modelValue === props.value, + disabled: props.disabled, + onChange: () => emit("update:modelValue", props.value) + }), + h("span", { class: "radio-control", "aria-hidden": "true" }), + h("span", { class: "radio-label" }, slots.default?.() || props.label) + ]); + } +}); diff --git a/src/vue/components/GnRadioGroup.js b/src/vue/components/GnRadioGroup.js new file mode 100644 index 0000000..1c6077e --- /dev/null +++ b/src/vue/components/GnRadioGroup.js @@ -0,0 +1,30 @@ +import { defineComponent, h } from "vue"; +import GnRadio from "./GnRadio.js"; + +export default defineComponent({ + name: "GnRadioGroup", + props: { + modelValue: { type: [String, Number, Boolean], default: "" }, + name: { type: String, default: "gn-radio-group" }, + label: { type: String, default: "" }, + options: { type: Array, default: () => [] } + }, + emits: ["update:modelValue"], + setup(props, { emit, slots }) { + return () => h("div", { + class: "form-group radio-group", + role: "radiogroup", + "aria-label": props.label || undefined + }, [ + props.label && h("div", { class: "label" }, props.label), + slots.default?.() || props.options.map(option => h(GnRadio, { + modelValue: props.modelValue, + "onUpdate:modelValue": value => emit("update:modelValue", value), + name: props.name, + value: option.value, + label: option.label, + disabled: option.disabled + })) + ]); + } +}); diff --git a/src/vue/components/GnSwitch.js b/src/vue/components/GnSwitch.js new file mode 100644 index 0000000..4f1cf94 --- /dev/null +++ b/src/vue/components/GnSwitch.js @@ -0,0 +1,26 @@ +import { defineComponent, h } from "vue"; +import { cx, eventValue } from "../utils.js"; + +export default defineComponent({ + name: "GnSwitch", + inheritAttrs: false, + props: { + modelValue: { type: Boolean, default: false }, + label: { type: String, default: "" }, + disabled: { type: Boolean, default: false } + }, + emits: ["update:modelValue"], + setup(props, { attrs, emit, slots }) { + return () => h("label", { class: cx("switch", attrs.class) }, [ + h("input", { + ...attrs, + type: "checkbox", + checked: props.modelValue, + disabled: props.disabled, + onChange: event => emit("update:modelValue", eventValue(event)) + }), + h("span", { class: "switch-control", "aria-hidden": "true" }), + h("span", { class: "switch-label" }, slots.default?.() || props.label) + ]); + } +}); diff --git a/src/vue/index.js b/src/vue/index.js index b96cf61..3131069 100644 --- a/src/vue/index.js +++ b/src/vue/index.js @@ -8,4 +8,8 @@ export { default as GnInput } from "./components/GnInput.js"; export { default as GnBadge } from "./components/GnBadge.js"; export { default as GnAlert } from "./components/GnAlert.js"; +export { default as GnCheckbox } from "./components/GnCheckbox.js"; +export { default as GnRadio } from "./components/GnRadio.js"; +export { default as GnRadioGroup } from "./components/GnRadioGroup.js"; +export { default as GnSwitch } from "./components/GnSwitch.js"; export { components, default as VMKUiVue } from "./plugin.js"; diff --git a/src/vue/plugin.js b/src/vue/plugin.js index c2fc8fc..064900d 100644 --- a/src/vue/plugin.js +++ b/src/vue/plugin.js @@ -8,13 +8,21 @@ import GnInput from "./components/GnInput.js"; import GnBadge from "./components/GnBadge.js"; import GnAlert from "./components/GnAlert.js"; +import GnCheckbox from "./components/GnCheckbox.js"; +import GnRadio from "./components/GnRadio.js"; +import GnRadioGroup from "./components/GnRadioGroup.js"; +import GnSwitch from "./components/GnSwitch.js"; export const components = { GnButton, VmkIcon, GnInput, GnBadge, - GnAlert + GnAlert, + GnCheckbox, + GnRadio, + GnRadioGroup, + GnSwitch }; export default {