diff --git a/src/components/Combobox.vue b/src/components/Combobox.vue
index 49ca912..968531f 100644
--- a/src/components/Combobox.vue
+++ b/src/components/Combobox.vue
@@ -1,27 +1,28 @@
-
-
+
@@ -367,19 +322,19 @@ export default defineComponent({
]">
+ :disabled="attrs.disabled">
{{ getOption(value)?.label || value }}
@@ -395,15 +350,15 @@ export default defineComponent({
ref="inputRef"
class="truncate bg-transparent focus:focus-none disabled:a-text-input-disabled"
:style="`width: ${inputSize + 1}ch`"
- :display-value="value => displayValue(value as string | string[])"
+ :display-value="(value: unknown) => displayValue(value as string | string[])"
:placeholder="placeholderString"
- :disabled="$attrs.disabled"
+ :disabled="attrs.disabled"
@change.stop="updateQuery($event.target.value)" />
+ :float="variant === 'subtle' && !attrs.disabled"
+ :class="attrs.disabled && 'text-warsaw'" />
+ :selected-values="isMulti ? (model as string[]) : []">
diff --git a/src/components/Listbox.vue b/src/components/Listbox.vue
index e085640..52631d5 100644
--- a/src/components/Listbox.vue
+++ b/src/components/Listbox.vue
@@ -1,23 +1,26 @@
-
@@ -145,7 +131,7 @@ export default defineComponent({
-->
{{ placeholder }}
@@ -153,8 +139,8 @@ export default defineComponent({
+ :float="variant === 'subtle' && !attrs.disabled"
+ :class="{ 'text-warsaw': attrs.disabled }" />
-import { computed, defineComponent, PropType } from 'vue'
+export type { Option, BaseOption, ExtendedOption, OptionValue } from '../types/selection'
+
+
+
@@ -76,7 +66,7 @@ export default defineComponent({
@slot Named `#extra` slot. Use to add an icon or image preview, or any extra elements on the left-hand side of the option label.
-->
-
+
-
+
diff --git a/src/components/SelectableOptionGroup.vue b/src/components/SelectableOptionGroup.vue
index 616b3a4..0188a48 100644
--- a/src/components/SelectableOptionGroup.vue
+++ b/src/components/SelectableOptionGroup.vue
@@ -1,78 +1,61 @@
+
+
@@ -105,7 +88,7 @@ export default defineComponent({
diff --git a/src/components/Switcher.vue b/src/components/Switcher.vue
index 38b16a5..23b84bf 100644
--- a/src/components/Switcher.vue
+++ b/src/components/Switcher.vue
@@ -1,59 +1,38 @@
+
+
@@ -65,7 +44,7 @@ export default defineComponent({
{{ label }}
-
+
{{ option.label }}
diff --git a/src/components/__tests__/Combobox.test.ts b/src/components/__tests__/Combobox.test.ts
index faf5df2..a5fe729 100644
--- a/src/components/__tests__/Combobox.test.ts
+++ b/src/components/__tests__/Combobox.test.ts
@@ -10,6 +10,23 @@ const defaultOptions = [
{ value: 'e', label: 'Option E' }
]
+// Helper to count visible tags in the combobox trigger
+// Tags have removable buttons with aria-label="Remove"
+const getVisibleTagCount = () => {
+ return screen.queryAllByLabelText('Remove').length
+}
+
+// Helper to check if a tag with specific text is visible
+const hasVisibleTag = (label: string) => {
+ // Tags are rendered as ATag components with removable buttons
+ // The tag text appears alongside the Remove button
+ const removeButtons = screen.queryAllByLabelText('Remove')
+ return removeButtons.some(button => {
+ const tag = button.closest('[class*="a-tag"]') || button.parentElement
+ return tag?.textContent?.includes(label)
+ })
+}
+
describe('Combobox.vue - Multi-select tag display', () => {
describe('maxTags prop', () => {
it('displays all tags when maxTags is undefined', () => {
@@ -20,11 +37,13 @@ describe('Combobox.vue - Multi-select tag display', () => {
}
})
- expect(screen.getByText('Option A')).toBeInTheDocument()
- expect(screen.getByText('Option B')).toBeInTheDocument()
- expect(screen.getByText('Option C')).toBeInTheDocument()
- expect(screen.getByText('Option D')).toBeInTheDocument()
- expect(screen.getByText('Option E')).toBeInTheDocument()
+ // All 5 tags should be visible (each has a Remove button)
+ expect(getVisibleTagCount()).toBe(5)
+ expect(hasVisibleTag('Option A')).toBe(true)
+ expect(hasVisibleTag('Option B')).toBe(true)
+ expect(hasVisibleTag('Option C')).toBe(true)
+ expect(hasVisibleTag('Option D')).toBe(true)
+ expect(hasVisibleTag('Option E')).toBe(true)
})
it('limits visible tags to maxTags count', () => {
@@ -36,11 +55,13 @@ describe('Combobox.vue - Multi-select tag display', () => {
}
})
- expect(screen.getByText('Option A')).toBeInTheDocument()
- expect(screen.getByText('Option B')).toBeInTheDocument()
- expect(screen.queryByText('Option C')).not.toBeInTheDocument()
- expect(screen.queryByText('Option D')).not.toBeInTheDocument()
- expect(screen.queryByText('Option E')).not.toBeInTheDocument()
+ // Only 2 tags should be visible
+ expect(getVisibleTagCount()).toBe(2)
+ expect(hasVisibleTag('Option A')).toBe(true)
+ expect(hasVisibleTag('Option B')).toBe(true)
+ expect(hasVisibleTag('Option C')).toBe(false)
+ expect(hasVisibleTag('Option D')).toBe(false)
+ expect(hasVisibleTag('Option E')).toBe(false)
})
it('shows collapsed indicator when tags exceed maxTags', () => {
@@ -76,9 +97,12 @@ describe('Combobox.vue - Multi-select tag display', () => {
}
})
- expect(screen.queryByText('Option A')).not.toBeInTheDocument()
- expect(screen.queryByText('Option B')).not.toBeInTheDocument()
- expect(screen.queryByText('Option C')).not.toBeInTheDocument()
+ // With maxTags: 0, no tags should be visible (no Remove buttons)
+ expect(getVisibleTagCount()).toBe(0)
+ expect(hasVisibleTag('Option A')).toBe(false)
+ expect(hasVisibleTag('Option B')).toBe(false)
+ expect(hasVisibleTag('Option C')).toBe(false)
+ // The collapsed indicator should be shown
expect(screen.getByText('+3 more')).toBeInTheDocument()
})
})
diff --git a/src/components/internal/OptionsPanel.vue b/src/components/internal/OptionsPanel.vue
index 016c5b4..e48ed9e 100644
--- a/src/components/internal/OptionsPanel.vue
+++ b/src/components/internal/OptionsPanel.vue
@@ -1,25 +1,32 @@
+
+
-
+
@@ -128,7 +120,7 @@ export default defineComponent({
diff --git a/src/index.ts b/src/index.ts
index 5a53173..4de984f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -12,3 +12,5 @@ export * from './components'
export * from './composables'
export * from './colors'
+
+export * from './types'
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..975551f
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,11 @@
+export {
+ type OptionValue,
+ type BaseOption,
+ type ExtendedOption,
+ type Option,
+ type OptionGroup,
+ type SwitcherOption,
+ areOptionsGrouped,
+ isOptionGroup,
+ GROUP_VALUE_PREFIX
+} from './selection'
diff --git a/src/types/selection.ts b/src/types/selection.ts
new file mode 100644
index 0000000..264a3f5
--- /dev/null
+++ b/src/types/selection.ts
@@ -0,0 +1,115 @@
+import { type Color } from '../colors'
+
+/**
+ * Valid value types that can be used as option values.
+ * Constrained to types compatible with HeadlessUI components.
+ */
+export type OptionValue = string | number | boolean | Record | null
+
+/**
+ * Base constraint for all option types.
+ * Every option must have at minimum a value, label, and optional disabled state.
+ *
+ * @typeParam V - The type of the option's value (default: string)
+ */
+export interface BaseOption {
+ value: V
+ label: string
+ disabled?: boolean
+}
+
+/**
+ * Extended option with color support.
+ * This is the default option type for backwards compatibility with existing code.
+ *
+ * @typeParam V - The type of the option's value (default: string)
+ */
+export interface ExtendedOption extends BaseOption {
+ color?: Color
+}
+
+/**
+ * Generic Option type alias.
+ * Defaults to ExtendedOption for backwards compatibility.
+ *
+ * @typeParam V - The type of the option's value (default: string)
+ * @typeParam T - The full option type extending BaseOption (default: ExtendedOption)
+ *
+ * @example
+ * // Default usage (backwards compatible)
+ * const options: Option[] = [{ value: 'a', label: 'Option A' }]
+ *
+ * @example
+ * // With custom value type
+ * const numericOptions: Option[] = [{ value: 1, label: 'One' }]
+ *
+ * @example
+ * // With custom option type
+ * interface MyOption extends BaseOption {
+ * metadata: { priority: number }
+ * }
+ * const customOptions: Option[] = [
+ * { value: 1, label: 'One', metadata: { priority: 1 } }
+ * ]
+ */
+export type Option = ExtendedOption> = T
+
+/**
+ * Generic OptionGroup for grouped options.
+ *
+ * @typeParam V - The type of the option's value (default: string)
+ * @typeParam T - The full option type extending BaseOption (default: ExtendedOption)
+ */
+export interface OptionGroup<
+ V extends OptionValue = string,
+ T extends BaseOption = ExtendedOption
+> {
+ title?: string
+ options: T[]
+}
+
+/**
+ * Switcher-specific option with icon support.
+ *
+ * @typeParam V - The type of the option's value (default: string)
+ */
+export interface SwitcherOption extends BaseOption {
+ icon?: string
+}
+
+/**
+ * Type guard to check if options array contains grouped options.
+ *
+ * @typeParam V - The type of the option's value
+ * @typeParam T - The full option type extending BaseOption
+ */
+export function areOptionsGrouped>(
+ options: T[] | OptionGroup[]
+): options is OptionGroup[] {
+ return !!options.length && 'options' in options[0]
+}
+
+/**
+ * Type guard to check if a single item is an OptionGroup (vs a flat option).
+ * Useful for narrowing types when iterating over mixed option arrays.
+ *
+ * @example
+ * filteredOptions.map(item => {
+ * if (isOptionGroup(item)) {
+ * // item is OptionGroup - access item.options, item.title
+ * } else {
+ * // item is T - access item.value, item.label
+ * }
+ * })
+ */
+export function isOptionGroup>(
+ item: T | OptionGroup
+): item is OptionGroup {
+ return 'options' in item
+}
+
+/**
+ * Prefix used internally for group selection values in SelectableOptionGroup.
+ * This is an internal mechanism and should not be used by consumers.
+ */
+export const GROUP_VALUE_PREFIX = '__group__'