diff --git a/demo/partials/advanced-select.html b/demo/partials/advanced-select.html index 63b4828..b00976d 100644 --- a/demo/partials/advanced-select.html +++ b/demo/partials/advanced-select.html @@ -30,4 +30,30 @@ } }); + +

Vue combobox

+
+ diff --git a/demo/partials/avatar.html b/demo/partials/avatar.html index c6455c1..f377f70 100644 --- a/demo/partials/avatar.html +++ b/demo/partials/avatar.html @@ -45,11 +45,32 @@ components: { GnAvatar }, template: `
- - + +
` }).mount("#vue-avatar-app"); + +

Vue avatar stack

+
+ diff --git a/demo/partials/chips.html b/demo/partials/chips.html index d5aee5e..525e898 100644 --- a/demo/partials/chips.html +++ b/demo/partials/chips.html @@ -48,4 +48,32 @@ ` }).mount("#vue-chips-app"); + +

Vue chip group

+
+ diff --git a/demo/partials/inputs.html b/demo/partials/inputs.html index fb3ccd7..3329df7 100644 --- a/demo/partials/inputs.html +++ b/demo/partials/inputs.html @@ -86,4 +86,21 @@ ` }).mount("#vue-inputs-app"); + +

Vue search field

+
+ diff --git a/src/scss/components/_avatar.scss b/src/scss/components/_avatar.scss index 0775ba5..02b3899 100644 --- a/src/scss/components/_avatar.scss +++ b/src/scss/components/_avatar.scss @@ -78,6 +78,12 @@ font-size: 32px; } + &.avatar-xs { + width: 24px; + height: 24px; + font-size: 10px; + } + // Variants &.avatar-neutral { background-color: $neutral-100; @@ -99,3 +105,35 @@ color: $lavender-700; } } + +.avatar-stack { + display: inline-flex; + align-items: center; + + .avatar { + margin-right: -$space-2; + box-shadow: 0 0 0 2px $neutral-0; + transition: transform $motion-base $motion-ease; + + &:hover { + transform: translateY(-2px); + z-index: 1; + } + } + + .avatar-stack-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 32px; + padding: 0 $space-2; + margin-left: $space-1; + border-radius: $radius-full; + background-color: $neutral-100; + color: $neutral-700; + font-size: 12px; + font-weight: $font-weight-medium; + box-shadow: 0 0 0 2px $neutral-0; + } +} diff --git a/src/scss/components/_chips.scss b/src/scss/components/_chips.scss index 4653994..49287e3 100644 --- a/src/scss/components/_chips.scss +++ b/src/scss/components/_chips.scss @@ -95,3 +95,10 @@ font-size: 14px; } } + +.chip-group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: $space-2; +} diff --git a/src/vue/components/GnAvatarStack.js b/src/vue/components/GnAvatarStack.js new file mode 100644 index 0000000..e1d6442 --- /dev/null +++ b/src/vue/components/GnAvatarStack.js @@ -0,0 +1,21 @@ +/** + * GnAvatarStack - Overlapping avatar group with optional overflow counter. + */ + +import { defineComponent, h } from "vue"; +import GnAvatar from "./GnAvatar.js"; + +export default defineComponent({ + name: "GnAvatarStack", + props: { + items: { type: Array, default: () => [] }, + count: { type: [Number, String], default: "" } + }, + setup(props, { slots }) { + return () => h("span", { class: "avatar-stack" }, [ + props.items.map(item => h(GnAvatar, { ...item, size: item.size || "sm" })), + slots.default?.(), + props.count && h("span", { class: "avatar-stack-count" }, `+${props.count}`) + ]); + } +}); diff --git a/src/vue/components/GnChipGroup.js b/src/vue/components/GnChipGroup.js new file mode 100644 index 0000000..bef613c --- /dev/null +++ b/src/vue/components/GnChipGroup.js @@ -0,0 +1,14 @@ +/** + * GnChipGroup - Wrapper that lays out chips as a group. + */ + +import { defineComponent, h } from "vue"; +import { cx } from "../utils.js"; + +export default defineComponent({ + name: "GnChipGroup", + inheritAttrs: false, + setup(_, { attrs, slots }) { + return () => h("div", { ...attrs, class: cx("chip-group", attrs.class) }, slots.default?.()); + } +}); diff --git a/src/vue/components/GnCombobox.js b/src/vue/components/GnCombobox.js new file mode 100644 index 0000000..b0c624a --- /dev/null +++ b/src/vue/components/GnCombobox.js @@ -0,0 +1,178 @@ +/** + * GnCombobox - Text input with a filterable dropdown list. + * + * @typedef {Object} GnComboboxProps + * @property {string|number} [modelValue=''] - Current input value + * @property {string} [label=''] - Field label + * @property {string} [icon=''] - Leading icon key + * @property {Array} [options=[]] - Options as strings or { value, label } objects + * @property {string} [placeholder='Search'] - Input placeholder + * @property {string} [notFoundText='Nothing found'] - Empty-state message + * @property {string} [state=''] - error | warning | success + * @property {string} [help=''] - Help or validation message + * + * @emits update:modelValue + * @emits select + */ + +import { computed, defineComponent, h, nextTick, ref } from "vue"; +import { cx, eventValue, iconNode, normalizeInputState } from "../utils.js"; + +let comboboxId = 0; + +export default defineComponent({ + name: "GnCombobox", + inheritAttrs: false, + props: { + modelValue: { type: [String, Number], default: "" }, + label: { type: String, default: "" }, + icon: { type: String, default: "" }, + options: { type: Array, default: () => [] }, + placeholder: { type: String, default: "Search" }, + notFoundText: { type: String, default: "Nothing found" }, + state: { type: String, default: "" }, + help: { type: String, default: "" } + }, + emits: ["update:modelValue", "select"], + setup(props, { attrs, emit }) { + const id = `gn-combobox-${++comboboxId}`; + const listboxId = `${id}-listbox`; + const open = ref(false); + const focused = ref(-1); + const inputRef = ref(null); + const normalized = computed(() => props.options.map(option => typeof option === "object" ? option : { + value: option, + label: option + })); + const query = computed(() => String(props.modelValue ?? "").toLowerCase()); + const filtered = computed(() => normalized.value.filter(option => String(option.label).toLowerCase().includes(query.value))); + const state = computed(() => normalizeInputState(props.state)); + + const select = option => { + if (!option) { + return; + } + + emit("update:modelValue", option.label); + emit("select", option); + open.value = false; + focused.value = -1; + }; + const move = direction => { + if (!filtered.value.length) { + return; + } + + open.value = true; + focused.value = (focused.value + direction + filtered.value.length) % filtered.value.length; + nextTick(() => { + const container = inputRef.value?.closest(".form-group")?.querySelector(".advanced-select"); + container?.querySelector(".option.focus")?.scrollIntoView({ block: "nearest" }); + }); + }; + const onKeydown = event => { + if (event.key === "ArrowDown") { + event.preventDefault(); + move(1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + move(-1); + } else if (event.key === "Enter") { + event.preventDefault(); + select(filtered.value[focused.value]); + } else if (event.key === "Escape") { + open.value = false; + focused.value = -1; + } + }; + + return () => h("div", { class: cx("form-group", state.value) }, [ + props.label && h("label", { class: cx("label", state.value) }, [ + props.label, + iconNode(props.icon), + h("input", { + ...attrs, + ref: inputRef, + id, + type: "text", + value: props.modelValue, + placeholder: props.placeholder, + autocomplete: "off", + role: "combobox", + "aria-autocomplete": "list", + "aria-expanded": open.value ? "true" : "false", + "aria-controls": listboxId, + "aria-activedescendant": focused.value >= 0 ? `${id}-option-${focused.value}` : undefined, + class: cx("input", attrs.class), + onFocus: () => { + open.value = true; + }, + onBlur: () => { + setTimeout(() => { + open.value = false; + }, 120); + }, + onInput: event => { + focused.value = -1; + open.value = true; + emit("update:modelValue", eventValue(event)); + }, + onKeydown + }) + ]), + !props.label && h("input", { + ...attrs, + ref: inputRef, + id, + type: "text", + value: props.modelValue, + placeholder: props.placeholder, + autocomplete: "off", + role: "combobox", + "aria-autocomplete": "list", + "aria-expanded": open.value ? "true" : "false", + "aria-controls": listboxId, + "aria-activedescendant": focused.value >= 0 ? `${id}-option-${focused.value}` : undefined, + class: cx("input", attrs.class), + onFocus: () => { + open.value = true; + }, + onBlur: () => { + setTimeout(() => { + open.value = false; + }, 120); + }, + onInput: event => { + focused.value = -1; + open.value = true; + emit("update:modelValue", eventValue(event)); + }, + onKeydown + }), + h("div", { class: "advanced-select-container" }, [ + h("div", { class: cx("advanced-select", { "a-show": open.value }) }, [ + h("div", { class: "popup-options-container" }, [ + h("div", { class: cx("not-found", { show: !filtered.value.length }) }, props.notFoundText), + h("div", { + id: listboxId, + class: cx("options", { show: filtered.value.length }), + role: "listbox" + }, filtered.value.map((option, index) => h("div", { + id: `${id}-option-${index}`, + class: cx("option", { focus: index === focused.value }), + role: "option", + "aria-selected": option.label === props.modelValue ? "true" : "false", + "data-value": option.value, + "data-display-value": option.label, + onMousedown: event => { + event.preventDefault(); + select(option); + } + }, option.label))) + ]) + ]) + ]), + props.help && h("div", { class: cx("input-help", state.value && `is-${state.value}`) }, props.help) + ]); + } +}); diff --git a/src/vue/components/GnSearchField.js b/src/vue/components/GnSearchField.js new file mode 100644 index 0000000..22488b0 --- /dev/null +++ b/src/vue/components/GnSearchField.js @@ -0,0 +1,53 @@ +/** + * GnSearchField - Compact search input with optional clear button. + * + * @typedef {Object} GnSearchFieldProps + * @property {string} [modelValue=''] - Bound search value + * @property {string} [placeholder='Search'] - Input placeholder + * @property {boolean} [compact=true] - Use compact input-group styling + * @property {boolean} [clearable=true] - Show clear button when there is a value + * + * @emits update:modelValue + * @emits clear + */ + +import { defineComponent, h } from "vue"; +import { cx, eventValue, iconNode } from "../utils.js"; + +export default defineComponent({ + name: "GnSearchField", + inheritAttrs: false, + props: { + modelValue: { type: String, default: "" }, + placeholder: { type: String, default: "Search" }, + compact: { type: Boolean, default: true }, + clearable: { type: Boolean, default: true } + }, + emits: ["update:modelValue", "clear"], + setup(props, { attrs, emit }) { + const clear = () => { + emit("update:modelValue", ""); + emit("clear"); + }; + + return () => h("div", { + class: cx("input-group search-field", { "input-group-compact": props.compact }) + }, [ + h("span", { class: "input-group-addon" }, [iconNode("ph-magnifying-glass")]), + h("input", { + ...attrs, + type: "search", + value: props.modelValue, + placeholder: props.placeholder, + class: cx("input-group-input", attrs.class), + onInput: event => emit("update:modelValue", eventValue(event)) + }), + props.clearable && props.modelValue && h("button", { + class: "input-group-action", + type: "button", + "aria-label": "Clear search", + onClick: clear + }, [iconNode("ph-x")]) + ]); + } +}); diff --git a/src/vue/index.js b/src/vue/index.js index e48491d..9949584 100644 --- a/src/vue/index.js +++ b/src/vue/index.js @@ -25,7 +25,11 @@ export { default as GnTab } from "./components/GnTab.js"; export { default as GnTabPanel } from "./components/GnTabPanel.js"; export { default as GnAvatar } from "./components/GnAvatar.js"; +export { default as GnAvatarStack } from "./components/GnAvatarStack.js"; export { default as GnChip } from "./components/GnChip.js"; +export { default as GnChipGroup } from "./components/GnChipGroup.js"; +export { default as GnCombobox } from "./components/GnCombobox.js"; +export { default as GnSearchField } from "./components/GnSearchField.js"; export { default as GnProgress } from "./components/GnProgress.js"; export { default as GnTable } from "./components/GnTable.js"; export { default as GnTableRow } from "./components/GnTableRow.js"; diff --git a/src/vue/plugin.js b/src/vue/plugin.js index 612a357..deaa856 100644 --- a/src/vue/plugin.js +++ b/src/vue/plugin.js @@ -25,7 +25,11 @@ import GnTab from "./components/GnTab.js"; import GnTabPanel from "./components/GnTabPanel.js"; import GnAvatar from "./components/GnAvatar.js"; +import GnAvatarStack from "./components/GnAvatarStack.js"; import GnChip from "./components/GnChip.js"; +import GnChipGroup from "./components/GnChipGroup.js"; +import GnCombobox from "./components/GnCombobox.js"; +import GnSearchField from "./components/GnSearchField.js"; import GnProgress from "./components/GnProgress.js"; import GnTable from "./components/GnTable.js"; import GnTableRow from "./components/GnTableRow.js"; @@ -78,7 +82,11 @@ GnTab, GnTabPanel, GnAvatar, + GnAvatarStack, GnChip, + GnChipGroup, + GnCombobox, + GnSearchField, GnProgress, GnTable, GnTableRow,