diff --git a/demo/partials/cards.html b/demo/partials/cards.html index a9e6895..154f7f6 100644 --- a/demo/partials/cards.html +++ b/demo/partials/cards.html @@ -86,4 +86,25 @@ ` }).mount("#vue-cards-app"); + +

Vue action card

+
+ diff --git a/demo/partials/inputs.html b/demo/partials/inputs.html index 3329df7..03f6f26 100644 --- a/demo/partials/inputs.html +++ b/demo/partials/inputs.html @@ -103,4 +103,57 @@ ` }).mount("#vue-search-field-app"); + +

Vue copy button

+
+ + +

Vue range

+
+ + +

Vue tag input

+
+ diff --git a/demo/partials/lists.html b/demo/partials/lists.html index ef40328..22e5832 100644 --- a/demo/partials/lists.html +++ b/demo/partials/lists.html @@ -1,4 +1,4 @@ -
+

Lists

Default list

@@ -24,4 +24,31 @@
  • Sent
  • Drafts
  • + +

    Vue action list

    +
    +
    diff --git a/src/scss/components/_buttons.scss b/src/scss/components/_buttons.scss index eb7ea68..69da530 100644 --- a/src/scss/components/_buttons.scss +++ b/src/scss/components/_buttons.scss @@ -150,6 +150,17 @@ &.btn-xxs { width: 24px; } } +.btn-icon { + aspect-ratio: 1 / 1; + padding: 0; + border-radius: $radius-md; + + &.btn-icon-sm { + width: 32px; + height: 32px; + } +} + // Variants aligned with Figma Button.png // Primary — filled yellow CTA diff --git a/src/scss/components/_cards.scss b/src/scss/components/_cards.scss index dafe896..403fe34 100644 --- a/src/scss/components/_cards.scss +++ b/src/scss/components/_cards.scss @@ -91,4 +91,37 @@ .card-content { padding: $space-6; } .card-footer { padding: $space-4 $space-6; } } + + // Action card layout + &.action-card { + .action-card-kicker { + display: block; + margin-bottom: $space-2; + font-size: 12px; + font-weight: $font-weight-semibold; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $neutral-500; + } + + .action-card-title { + margin: 0 0 $space-2; + font-size: 20px; + font-weight: $font-weight-bold; + color: $neutral-900; + } + + .action-card-text { + margin: 0 0 $space-4; + color: $neutral-700; + font-size: 15px; + line-height: 1.5; + } + + .action-card-actions { + display: flex; + align-items: center; + gap: $space-3; + } + } } diff --git a/src/scss/components/_forms.scss b/src/scss/components/_forms.scss index a8c3388..ca81f26 100644 --- a/src/scss/components/_forms.scss +++ b/src/scss/components/_forms.scss @@ -198,3 +198,56 @@ grid-template-columns: 1fr; } } + +.range { + display: flex; + flex-direction: column; + gap: $space-2; + width: 100%; + max-width: 400px; + + .range-input { + width: 100%; + height: 6px; + margin: $space-2 0; + appearance: none; + background: $neutral-200; + border-radius: $radius-full; + cursor: pointer; + + &::-webkit-slider-thumb { + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: $neutral-900; + border: 2px solid $neutral-0; + box-shadow: 0 1px 4px rgba($neutral-900, 0.16); + transition: transform $motion-base $motion-ease; + } + + &::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: $neutral-900; + border: 2px solid $neutral-0; + box-shadow: 0 1px 4px rgba($neutral-900, 0.16); + transition: transform $motion-base $motion-ease; + } + + &:hover::-webkit-slider-thumb { transform: scale(1.1); } + &:hover::-moz-range-thumb { transform: scale(1.1); } + + &:focus { + outline: none; + &::-webkit-slider-thumb { box-shadow: 0 0 0 3px rgba($mint-600, 0.32); } + &::-moz-range-thumb { box-shadow: 0 0 0 3px rgba($mint-600, 0.32); } + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} diff --git a/src/scss/components/_lists.scss b/src/scss/components/_lists.scss index 39772ec..2f07442 100644 --- a/src/scss/components/_lists.scss +++ b/src/scss/components/_lists.scss @@ -89,3 +89,51 @@ border-top: $border-width-base $border-style-base $border-color-muted; } } + +.list-actions { + width: 100%; + max-width: 420px; + border: 0; + background: transparent; + padding: 0; + + .list-item { + justify-content: space-between; + align-items: flex-start; + gap: $space-3; + padding: $space-3 0; + border-bottom: $border-width-base $border-style-base rgba($neutral-900, 0.08); + + &:last-child { + border-bottom: 0; + } + + .list-content { + display: flex; + flex-direction: column; + gap: $space-1; + } + + .list-title { + font-size: 15px; + font-weight: $font-weight-semibold; + color: $neutral-900; + } + + .list-subtitle { + font-size: 13px; + color: $neutral-600; + line-height: 1.4; + } + + .list-controls { + display: flex; + align-items: center; + flex-shrink: 0; + } + + &.list-item-muted { + opacity: 0.6; + } + } +} diff --git a/src/scss/components/_tag-input.scss b/src/scss/components/_tag-input.scss new file mode 100644 index 0000000..bf9c703 --- /dev/null +++ b/src/scss/components/_tag-input.scss @@ -0,0 +1,95 @@ +/* ========================= + Tag input +========================= */ + +@use "../kit-deps" as *; + +.tag-input { + display: flex; + flex-direction: column; + gap: $space-2; + width: 100%; + max-width: 600px; + + .label { + display: flex; + align-items: center; + gap: $space-2; + font-size: 14px; + font-weight: $font-weight-semibold; + color: $neutral-900; + + .label-disabled-hint { + font-size: 12px; + font-weight: $font-weight-regular; + color: $neutral-500; + } + } + + .tag-input-wrap { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: $space-2; + min-height: 48px; + padding: $space-2 $space-3; + background-color: $neutral-0; + border: $border-width-base $border-style-base $neutral-300; + border-radius: $radius-lg; + cursor: text; + transition: + border-color $motion-base $motion-ease, + box-shadow $motion-base $motion-ease; + + &:hover { + border-color: $mint-600; + } + } + + .tag-input-field { + flex: 1; + min-width: 80px; + min-height: 28px; + padding: $space-1 $space-2; + font-family: $font-family-base; + font-size: 15px; + font-weight: $font-weight-medium; + color: $neutral-900; + background: transparent; + border: 0; + outline: none; + + &::placeholder { + color: $neutral-600; + font-weight: $font-weight-regular; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + } + + &.tag-input-focused .tag-input-wrap { + border-color: $mint-600; + box-shadow: 0 0 0 3px rgba($mint-600, 0.24); + } + + &.tag-input-disabled .tag-input-wrap { + background-color: $neutral-50; + border-color: $neutral-300; + cursor: not-allowed; + } + + .input-help { + display: flex; + align-items: center; + gap: $space-1; + font-size: 13px; + color: $neutral-600; + + .vmk-icon { + font-size: 16px; + } + } +} diff --git a/src/scss/kit.scss b/src/scss/kit.scss index b639ff5..ece7901 100644 --- a/src/scss/kit.scss +++ b/src/scss/kit.scss @@ -7,6 +7,7 @@ @use "components/badges"; @use "components/alerts"; @use "components/forms"; +@use "components/tag-input"; @use "components/input-group"; @use "components/select"; @use "components/checkbox"; diff --git a/src/vue/components/GnActionCard.js b/src/vue/components/GnActionCard.js new file mode 100644 index 0000000..45477a0 --- /dev/null +++ b/src/vue/components/GnActionCard.js @@ -0,0 +1,34 @@ +/** + * GnActionCard - Card with a kicker, title, text and an actions slot. + * + * @typedef {Object} GnActionCardProps + * @property {string} [kicker=''] - Eyebrow text + * @property {string} title - Card title + * @property {string} [text=''] - Body text + * + * @slots default - Body content (overrides text) + * @slots kicker - Kicker content (overrides kicker) + * @slots title - Title content (overrides title) + * @slots actions - Footer actions + */ + +import { defineComponent, h } from "vue"; + +export default defineComponent({ + name: "GnActionCard", + props: { + kicker: { type: String, default: "" }, + title: { type: String, required: true }, + text: { type: String, default: "" } + }, + setup(props, { slots }) { + return () => h("article", { class: "card action-card" }, [ + h("div", { class: "card-content" }, [ + (props.kicker || slots.kicker) && h("span", { class: "action-card-kicker" }, slots.kicker?.() || props.kicker), + h("h3", { class: "action-card-title" }, slots.title?.() || props.title), + (props.text || slots.default) && h("p", { class: "action-card-text" }, slots.default?.() || props.text), + slots.actions && h("div", { class: "action-card-actions" }, slots.actions()) + ]) + ]); + } +}); diff --git a/src/vue/components/GnActionList.js b/src/vue/components/GnActionList.js new file mode 100644 index 0000000..8d23824 --- /dev/null +++ b/src/vue/components/GnActionList.js @@ -0,0 +1,32 @@ +/** + * GnActionList - List of actionable items with title, subtitle and controls slots. + * + * @typedef {Object} GnActionListProps + * @property {Array} [items=[]] - Items as objects with title/label, subtitle, muted, etc. + * + * @slots title - Override title for an item (scope: { item }) + * @slots subtitle - Override subtitle for an item (scope: { item }) + * @slots controls - Right-side controls for an item (scope: { item }) + */ + +import { defineComponent, h } from "vue"; +import { cx } from "../utils.js"; + +export default defineComponent({ + name: "GnActionList", + inheritAttrs: false, + props: { + items: { type: Array, default: () => [] } + }, + setup(props, { attrs, slots }) { + return () => h("ul", { ...attrs, class: cx("list list-actions", attrs.class) }, props.items.map(item => h("li", { + class: cx("list-item", item.muted && "list-item-muted") + }, [ + h("div", { class: "list-content" }, [ + h("div", { class: "list-title" }, slots.title?.({ item }) || item.title || item.label || ""), + (item.subtitle || slots.subtitle) && h("div", { class: "list-subtitle" }, slots.subtitle?.({ item }) || item.subtitle) + ]), + slots.controls && h("div", { class: "list-controls" }, slots.controls({ item })) + ]))); + } +}); diff --git a/src/vue/components/GnCopyButton.js b/src/vue/components/GnCopyButton.js new file mode 100644 index 0000000..8d0b5c7 --- /dev/null +++ b/src/vue/components/GnCopyButton.js @@ -0,0 +1,63 @@ +/** + * GnCopyButton - Copies text to the clipboard and shows a transient success state. + * + * @typedef {Object} GnCopyButtonProps + * @property {string} text - Text to copy + * @property {string} [icon='ph-copy'] - Default icon name + * @property {string} [successIcon='ph-check'] - Icon shown after copying + * @property {number} [duration=3000] - How long the success state lasts (ms) + * @property {string} [label='Copy'] - Accessible label + * @property {string} [size=''] - "sm" for a smaller button + * + * @emits copy + */ + +import { defineComponent, h, ref } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnCopyButton", + props: { + text: { type: String, required: true }, + icon: { type: String, default: "ph-copy" }, + successIcon: { type: String, default: "ph-check" }, + duration: { type: Number, default: 3000 }, + label: { type: String, default: "Copy" }, + size: { type: String, default: "" } + }, + emits: ["copy"], + setup(props, { emit }) { + const copied = ref(false); + let timer = null; + + const copy = async () => { + try { + await navigator.clipboard.writeText(props.text); + } catch { + const textarea = document.createElement("textarea"); + textarea.value = props.text; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + } + + copied.value = true; + window.clearTimeout(timer); + timer = window.setTimeout(() => { + copied.value = false; + }, props.duration); + + emit("copy", props.text); + }; + + return () => h("button", { + class: cx("btn-icon", { "btn-icon-sm": props.size === "sm" }), + type: "button", + "aria-label": props.label, + onClick: copy + }, [iconNode(copied.value ? props.successIcon : props.icon)]); + } +}); diff --git a/src/vue/components/GnRange.js b/src/vue/components/GnRange.js new file mode 100644 index 0000000..74df483 --- /dev/null +++ b/src/vue/components/GnRange.js @@ -0,0 +1,55 @@ +/** + * GnRange - Accessible range/slider input. + * + * @typedef {Object} GnRangeProps + * @property {number|string} [modelValue=0] - Current value + * @property {string} [label=''] - Field label + * @property {number|string} [min=0] - Minimum value + * @property {number|string} [max=100] - Maximum value + * @property {number|string} [step=1] - Step increment + * + * @emits update:modelValue + */ + +import { defineComponent, h } from "vue"; +import { cx, eventValue } from "../utils.js"; + +export default defineComponent({ + name: "GnRange", + inheritAttrs: false, + props: { + modelValue: { type: [Number, String], default: 0 }, + label: { type: String, default: "" }, + min: { type: [Number, String], default: 0 }, + max: { type: [Number, String], default: 100 }, + step: { type: [Number, String], default: 1 } + }, + emits: ["update:modelValue"], + setup(props, { attrs, emit }) { + return () => h("div", { class: "range" }, [ + props.label && h("label", { class: "label" }, [ + props.label, + h("input", { + ...attrs, + type: "range", + value: props.modelValue, + min: props.min, + max: props.max, + step: props.step, + class: cx("range-input", attrs.class), + onInput: event => emit("update:modelValue", eventValue(event)) + }) + ]), + !props.label && h("input", { + ...attrs, + type: "range", + value: props.modelValue, + min: props.min, + max: props.max, + step: props.step, + class: cx("range-input", attrs.class), + onInput: event => emit("update:modelValue", eventValue(event)) + }) + ]); + } +}); diff --git a/src/vue/components/GnTagInput.js b/src/vue/components/GnTagInput.js new file mode 100644 index 0000000..a2d3f46 --- /dev/null +++ b/src/vue/components/GnTagInput.js @@ -0,0 +1,179 @@ +/** + * GnTagInput - Input that turns typed text into removable chips/tags. + * + * @typedef {Object} GnTagInputProps + * @property {string[]} [modelValue=[]] - Current tag list + * @property {string} [label=''] - Field label + * @property {string} [placeholder='Add item…'] - Input placeholder + * @property {string} [help=''] - Help text + * @property {boolean} [disabled=false] - Disabled state + * @property {string} [separator=','] - Character used to split pasted/typed batch input + * @property {boolean} [unique=true] - Prevent duplicate tags + * @property {number} [maxItems=0] - Maximum number of tags (0 = unlimited) + * + * @emits update:modelValue + * @emits add + * @emits remove + */ + +import { defineComponent, h, ref, computed } from "vue"; +import { cx, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnTagInput", + inheritAttrs: false, + props: { + modelValue: { type: Array, default: () => [] }, + label: { type: String, default: "" }, + placeholder: { type: String, default: "Add item…" }, + help: { type: String, default: "" }, + disabled: { type: Boolean, default: false }, + separator: { type: String, default: "," }, + unique: { type: Boolean, default: true }, + maxItems: { type: Number, default: 0 } + }, + emits: ["update:modelValue", "add", "remove"], + setup(props, { emit, attrs, slots }) { + const inputRef = ref(null); + const focused = ref(false); + const rawValue = ref(""); + + const canAdd = computed(() => { + if (props.disabled) return false; + if (props.maxItems > 0 && props.modelValue.length >= props.maxItems) return false; + return true; + }); + + function normalizeText(text) { + return text.trim().replace(/\s+/g, " "); + } + + function addValue(text) { + const value = normalizeText(text); + if (!value) return; + if (props.unique && props.modelValue.includes(value)) return; + if (props.maxItems > 0 && props.modelValue.length >= props.maxItems) return; + + const next = [...props.modelValue, value]; + emit("update:modelValue", next); + emit("add", value); + } + + function removeValue(index) { + const removed = props.modelValue[index]; + const next = props.modelValue.filter((_, i) => i !== index); + emit("update:modelValue", next); + emit("remove", removed); + } + + function onKeydown(event) { + if (event.key === "Enter") { + event.preventDefault(); + if (rawValue.value) { + const parts = props.separator + ? rawValue.value.split(props.separator).map(s => s.trim()).filter(Boolean) + : [rawValue.value]; + parts.forEach(addValue); + rawValue.value = ""; + } + return; + } + + if (event.key === "Backspace" && !rawValue.value && props.modelValue.length > 0) { + removeValue(props.modelValue.length - 1); + } + } + + function onPaste(event) { + const paste = event.clipboardData.getData("text"); + if (!paste || !props.separator) return; + event.preventDefault(); + const parts = paste.split(props.separator).map(s => s.trim()).filter(Boolean); + const appended = []; + for (const part of parts) { + const v = normalizeText(part); + if (!v) continue; + if (props.unique && props.modelValue.includes(v)) continue; + if (props.maxItems > 0 && props.modelValue.length + appended.length >= props.maxItems) break; + appended.push(v); + } + if (appended.length) { + const next = [...props.modelValue, ...appended]; + emit("update:modelValue", next); + appended.forEach(v => emit("add", v)); + } + } + + function onWrapClick() { + inputRef.value?.focus(); + } + + return () => { + const labelNode = (props.label || slots.label) + ? h("label", { class: "label" }, [ + slots.label?.() || props.label, + props.disabled && h("span", { class: "label-disabled-hint" }, "Disabled") + ]) + : null; + + const chips = props.modelValue.map((item, index) => + h("span", { + class: cx("chip", "chip-secondary"), + key: `${item}-${index}` + }, [ + item, + !props.disabled && h("button", { + class: "chip-remove", + type: "button", + "aria-label": `Remove ${item}`, + onClick: (e) => { + e.stopPropagation(); + removeValue(index); + } + }, [iconNode("ph-x")]) + ]) + ); + + const field = h("input", { + ref: inputRef, + class: "tag-input-field", + type: "text", + placeholder: canAdd.value ? props.placeholder : "", + value: rawValue.value, + disabled: props.disabled, + onInput: (e) => { rawValue.value = e.target.value; }, + onKeydown, + onPaste, + onFocus: () => focused.value = true, + onBlur: () => focused.value = false + }); + + const wrap = h("div", { + class: cx("tag-input-wrap"), + onClick: onWrapClick + }, [ + ...chips, + field + ]); + + const meta = props.help + ? h("div", { class: "input-help" }, [ + iconNode("ph-info"), + " " + props.help + ]) + : null; + + return h("div", { + ...attrs, + class: cx("tag-input", { + "tag-input-focused": focused.value, + "tag-input-disabled": props.disabled + }, attrs.class) + }, [ + labelNode, + wrap, + meta + ]); + }; + } +}); diff --git a/src/vue/index.js b/src/vue/index.js index 9949584..034183e 100644 --- a/src/vue/index.js +++ b/src/vue/index.js @@ -17,6 +17,11 @@ 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 { default as GnActionCard } from "./components/GnActionCard.js"; +export { default as GnActionList } from "./components/GnActionList.js"; +export { default as GnCopyButton } from "./components/GnCopyButton.js"; +export { default as GnRange } from "./components/GnRange.js"; +export { default as GnTagInput } from "./components/GnTagInput.js"; export { default as GnModal } from "./components/GnModal.js"; export { default as GnConfirmDialog } from "./components/GnConfirmDialog.js"; export { default as GnDrawer } from "./components/GnDrawer.js"; diff --git a/src/vue/plugin.js b/src/vue/plugin.js index deaa856..d1b7ea8 100644 --- a/src/vue/plugin.js +++ b/src/vue/plugin.js @@ -16,6 +16,11 @@ import GnSwitch from "./components/GnSwitch.js"; import GnSelect from "./components/GnSelect.js"; import GnTextarea from "./components/GnTextarea.js"; +import GnActionCard from "./components/GnActionCard.js"; +import GnActionList from "./components/GnActionList.js"; +import GnCopyButton from "./components/GnCopyButton.js"; +import GnRange from "./components/GnRange.js"; +import GnTagInput from "./components/GnTagInput.js"; import GnCard from "./components/GnCard.js"; import GnModal from "./components/GnModal.js"; import GnConfirmDialog from "./components/GnConfirmDialog.js"; @@ -73,6 +78,11 @@ GnSwitch, GnSelect, GnTextarea, + GnActionCard, + GnActionList, + GnCopyButton, + GnRange, + GnTagInput, GnCard, GnModal, GnConfirmDialog, diff --git a/tests/demo-screenshot.spec.js b/tests/demo-screenshot.spec.js index 97aff12..a154514 100644 --- a/tests/demo-screenshot.spec.js +++ b/tests/demo-screenshot.spec.js @@ -15,6 +15,7 @@ { name: "tables", label: "Tables" }, { name: "file-upload", label: "File Upload" }, { name: "advanced-select", label: "Advanced Select" }, + { name: "lists", label: "Lists" }, { name: "description-list", label: "Description List" }, { name: "page-header", label: "Page Header" }, { name: "backgrounds", label: "Backgrounds" },