diff --git a/demo/index.html b/demo/index.html index 408b731..a943ac6 100644 --- a/demo/index.html +++ b/demo/index.html @@ -20,7 +20,9 @@ @@include('./partials/utilities.html') @@include('./partials/buttons.html') @@include('./partials/choices.html') + @@include('./partials/select-textarea.html') @@include('./partials/inputs.html') + @@include('./partials/cards.html') @@include('./partials/badges.html') @@include('./partials/alerts.html') diff --git a/demo/partials/cards.html b/demo/partials/cards.html new file mode 100644 index 0000000..7a6fb4f --- /dev/null +++ b/demo/partials/cards.html @@ -0,0 +1,68 @@ +
+

Cards

+ +
+
+
Default card
+
+ A simple card with title, content, and footer. +
+
+ +
+
+ +
+
Filled card
+
+ Uses a subtle background fill instead of white. +
+
+ +
+
+ +
+
Outlined card
+
+ Transparent background with a stronger border. +
+
+ +
+
+
+ +
+
+
+ Primary accent card with yellow tint. +
+
+ +
+
+ Secondary accent card with mint tint. +
+
+ +
+
+ Elevated card with a soft shadow. +
+
+
+ +

Sizes

+
+
+
Small card
+
Compact padding and radius.
+
+ +
+
Large card
+
Generous padding and larger radius.
+
+
+
diff --git a/demo/partials/select-textarea.html b/demo/partials/select-textarea.html new file mode 100644 index 0000000..9d6f7ca --- /dev/null +++ b/demo/partials/select-textarea.html @@ -0,0 +1,38 @@ +
+

Select & Textarea

+ +

Select

+
+
+ +
+
+ +

Selection is valid.

+
+
+ +

Textarea

+
+
+ +
+
+
diff --git a/docs/component-coverage.md b/docs/component-coverage.md index 30d32ae..ee0574e 100644 --- a/docs/component-coverage.md +++ b/docs/component-coverage.md @@ -20,9 +20,10 @@ | Alert | done | GnAlert | partial | | Checkbox | done | GnCheckbox | partial | | Radio | done | GnRadio, GnRadioGroup | partial | -| Select | — | — | — | +| Select | done | GnSelect | partial | | Switch | done | GnSwitch | partial | -| Card | — | — | — | +| Textarea | done | GnTextarea | partial | +| Card | done | GnCard | partial | | Modal | — | — | — | | Drawer | — | — | — | | Toast | — | — | — | diff --git a/examples/vue/src/main.js b/examples/vue/src/main.js index fa69035..87322cc 100644 --- a/examples/vue/src/main.js +++ b/examples/vue/src/main.js @@ -41,6 +41,15 @@ { value: 'b', label: 'Option B' } ]" /> +
+ + Done + + +
` }; diff --git a/src/scss/components/_cards.scss b/src/scss/components/_cards.scss new file mode 100644 index 0000000..26c5fc3 --- /dev/null +++ b/src/scss/components/_cards.scss @@ -0,0 +1,90 @@ +/* ========================= + Cards +========================= */ + +@use "../kit-deps" as *; + +.card { + display: flex; + flex-direction: column; + width: 100%; + background-color: $neutral-0; + border: 1px solid $neutral-200; + border-radius: $radius-lg; + overflow: hidden; + transition: + box-shadow $motion-base $motion-ease, + border-color $motion-base $motion-ease; + + &:hover { + border-color: $neutral-300; + } + + .card-title { + padding: $space-4 $space-5; + font-size: 18px; + font-weight: $font-weight-bold; + color: $neutral-900; + border-bottom: 1px solid $neutral-100; + } + + .card-content { + flex: 1; + padding: $space-5; + color: $neutral-800; + font-size: 15px; + line-height: 1.5; + } + + .card-footer { + display: flex; + align-items: center; + gap: $space-3; + padding: $space-3 $space-5; + border-top: 1px solid $neutral-100; + background-color: $neutral-50; + } + + // Variants + &.card-filled { + background-color: $neutral-50; + border-color: $neutral-100; + } + + &.card-outlined { + background-color: transparent; + border-color: $neutral-400; + } + + &.card-primary { + border-color: $yellow-300; + background-color: $yellow-50; + } + + &.card-secondary { + border-color: $mint-300; + background-color: $mint-50; + } + + &.card-elevated { + box-shadow: 0 4px 24px rgba($neutral-900, 0.08); + border-color: transparent; + } + + // Sizes + &.card-sm { + border-radius: $radius-md; + + .card-title { padding: $space-3 $space-4; font-size: 16px; } + .card-content { padding: $space-4; } + .card-footer { padding: $space-2 $space-4; } + } + + &.card-lg { + border-radius: $radius-xl; + + .card-title { padding: $space-5 $space-6; font-size: 20px; } + .card-content { padding: $space-6; } + .card-footer { padding: $space-4 $space-6; } + } +} diff --git a/src/scss/components/_forms.scss b/src/scss/components/_forms.scss index 5950600..657e4f7 100644 --- a/src/scss/components/_forms.scss +++ b/src/scss/components/_forms.scss @@ -138,6 +138,14 @@ } } +.form-group { + textarea.input { + min-height: 120px; + resize: vertical; + line-height: 1.4; + } +} + .form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/src/scss/components/_select.scss b/src/scss/components/_select.scss new file mode 100644 index 0000000..f41f4c8 --- /dev/null +++ b/src/scss/components/_select.scss @@ -0,0 +1,42 @@ +/* ========================= + Select +========================= */ + +@use "../kit-deps" as *; + +.select-wrap { + position: relative; + width: 100%; + + &::after { + content: ""; + position: absolute; + right: $space-4; + top: 50%; + transform: translateY(-50%); + width: 10px; + height: 10px; + border: solid $neutral-700; + border-width: 0 2px 2px 0; + transform: translateY(-70%) rotate(45deg); + pointer-events: none; + } + + .select { + width: 100%; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + padding-right: $space-8; + cursor: pointer; + + &::-ms-expand { + display: none; + } + + option { + color: $neutral-900; + background-color: $neutral-0; + } + } +} diff --git a/src/scss/kit.scss b/src/scss/kit.scss index 98f307f..48f9c0f 100644 --- a/src/scss/kit.scss +++ b/src/scss/kit.scss @@ -6,9 +6,11 @@ @use "components/badges"; @use "components/alerts"; @use "components/forms"; +@use "components/select"; @use "components/checkbox"; @use "components/radio"; @use "components/switch"; +@use "components/cards"; @use "utils"; * { diff --git a/src/vue/components/GnCard.js b/src/vue/components/GnCard.js new file mode 100644 index 0000000..e3e7433 --- /dev/null +++ b/src/vue/components/GnCard.js @@ -0,0 +1,30 @@ +import { defineComponent, h } from "vue"; +import { cx } from "../utils.js"; + +export default defineComponent({ + name: "GnCard", + inheritAttrs: false, + props: { + title: { type: String, default: "" }, + variant: { type: String, default: "" }, + size: { type: String, default: "" } + }, + setup(props, { attrs, slots }) { + return () => h("article", { + ...attrs, + class: cx( + "card", + props.variant && `card-${props.variant}`, + { + "card-sm": props.size === "sm", + "card-lg": props.size === "lg" + }, + attrs.class + ) + }, [ + (props.title || slots.title) && h("header", { class: "card-title" }, slots.title?.() || props.title), + h("div", { class: "card-content" }, slots.default?.()), + slots.footer && h("footer", { class: "card-footer" }, slots.footer()) + ]); + } +}); diff --git a/src/vue/components/GnSelect.js b/src/vue/components/GnSelect.js new file mode 100644 index 0000000..fce11de --- /dev/null +++ b/src/vue/components/GnSelect.js @@ -0,0 +1,64 @@ +/** + * GnSelect - Dropdown select with label, icon, state, and help text. + * + * @typedef {Object} GnSelectProps + * @property {string|number} [modelValue=''] - Bound value + * @property {string} [label=''] - Label text + * @property {string} [icon=''] - Phosphor icon name with ph- prefix or VMK key + * @property {string} [state=''] - error | warning | success + * @property {string} [help=''] - Help or validation message + * @property {Array} [options=[]] - Array of strings or { value, label } objects + * + * @slots default - Override option nodes + * @emits update:modelValue + */ + +import { defineComponent, h } from "vue"; +import { cx, eventValue, iconNode, normalizeInputState } from "../utils.js"; + +export default defineComponent({ + name: "GnSelect", + inheritAttrs: false, + props: { + modelValue: { type: [String, Number], default: "" }, + label: { type: String, default: "" }, + icon: { type: String, default: "" }, + state: { type: String, default: "" }, + help: { type: String, default: "" }, + options: { type: Array, default: () => [] } + }, + emits: ["update:modelValue"], + setup(props, { attrs, emit, slots }) { + const optionNodes = () => props.options.map(option => { + const value = typeof option === "object" ? option.value : option; + const label = typeof option === "object" ? option.label : option; + return h("option", { value }, label); + }); + + const state = normalizeInputState(props.state); + + return () => h("div", { class: cx("form-group", state) }, [ + props.label && h("label", { class: "label" }, [ + props.label, + iconNode(props.icon), + h("div", { class: "select-wrap" }, [ + h("select", { + ...attrs, + value: props.modelValue, + class: cx("input select", attrs.class), + onChange: event => emit("update:modelValue", eventValue(event)) + }, slots.default?.() || optionNodes()) + ]) + ]), + !props.label && h("div", { class: "select-wrap" }, [ + h("select", { + ...attrs, + value: props.modelValue, + class: cx("input select", attrs.class), + onChange: event => emit("update:modelValue", eventValue(event)) + }, slots.default?.() || optionNodes()) + ]), + props.help && h("p", { class: cx("input-help", state && `is-${state}`) }, props.help) + ]); + } +}); diff --git a/src/vue/components/GnTextarea.js b/src/vue/components/GnTextarea.js new file mode 100644 index 0000000..9b9df38 --- /dev/null +++ b/src/vue/components/GnTextarea.js @@ -0,0 +1,38 @@ +import { defineComponent, h } from "vue"; +import { cx, eventValue, iconNode, normalizeInputState } from "../utils.js"; + +export default defineComponent({ + name: "GnTextarea", + inheritAttrs: false, + props: { + modelValue: { type: String, default: "" }, + label: { type: String, default: "" }, + icon: { type: String, default: "" }, + state: { type: String, default: "" }, + help: { type: String, default: "" } + }, + emits: ["update:modelValue"], + setup(props, { attrs, emit }) { + const state = normalizeInputState(props.state); + + return () => h("div", { class: cx("form-group", state) }, [ + props.label && h("label", { class: "label" }, [ + props.label, + iconNode(props.icon), + h("textarea", { + ...attrs, + value: props.modelValue, + class: cx("input", { "input-icon-left": props.icon }, attrs.class), + onInput: event => emit("update:modelValue", eventValue(event)) + }) + ]), + !props.label && h("textarea", { + ...attrs, + value: props.modelValue, + class: cx("input", attrs.class), + onInput: event => emit("update:modelValue", eventValue(event)) + }), + props.help && h("p", { class: cx("input-help", state && `is-${state}`) }, props.help) + ]); + } +}); diff --git a/src/vue/index.js b/src/vue/index.js index 3131069..7b96712 100644 --- a/src/vue/index.js +++ b/src/vue/index.js @@ -12,4 +12,7 @@ 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 { default as GnSelect } from "./components/GnSelect.js"; +export { default as GnTextarea } from "./components/GnTextarea.js"; +export { default as GnCard } from "./components/GnCard.js"; export { components, default as VMKUiVue } from "./plugin.js"; diff --git a/src/vue/plugin.js b/src/vue/plugin.js index 064900d..571f84a 100644 --- a/src/vue/plugin.js +++ b/src/vue/plugin.js @@ -12,6 +12,9 @@ import GnRadio from "./components/GnRadio.js"; import GnRadioGroup from "./components/GnRadioGroup.js"; import GnSwitch from "./components/GnSwitch.js"; +import GnSelect from "./components/GnSelect.js"; +import GnTextarea from "./components/GnTextarea.js"; +import GnCard from "./components/GnCard.js"; export const components = { GnButton, @@ -22,7 +25,10 @@ GnCheckbox, GnRadio, GnRadioGroup, - GnSwitch + GnSwitch, + GnSelect, + GnTextarea, + GnCard }; export default {