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,