diff --git a/docs/plans/2026-02-16-headless-primitives.md b/docs/plans/2026-02-16-headless-primitives.md index 762aa4367..53c4e48d3 100644 --- a/docs/plans/2026-02-16-headless-primitives.md +++ b/docs/plans/2026-02-16-headless-primitives.md @@ -16,6 +16,89 @@ --- +## Branching Strategy + +All headless primitives work happens on **feature branches** off `@beta/dev`. Each logical unit of work gets its own branch, is completed with passing tests, and merged back into `@beta/dev`. + +**Branch naming:** +``` +@beta/dev ← integration branch (canonical) + └── feat/combobox-types ← Task 2: types + keys + └── fix/use-click-away ← Task 1: bugfix + └── feat/use-combobox-core ← Tasks 3-7: composable + └── feat/combobox-components ← Tasks 8-12: primitive components + └── feat/combobox-exports ← Task 13: package exports + └── test/combobox-integration ← Task 14: integration tests +``` + +**Merge strategy:** Each feature branch is merged into `@beta/dev` via fast-forward or merge commit. No squash -- we want the semantic commit history preserved for `semantic-release`. + +**Before creating a branch:** +```bash +git checkout @beta/dev +git pull origin @beta/dev +git checkout -b +``` + +**After completing work on a branch:** +```bash +git checkout @beta/dev +git merge +``` + +## Commit Conventions + +This project uses [Conventional Commits](https://www.conventionalcommits.org/) with `cz-conventional-changelog` and `semantic-release`. Every commit message MUST follow this format: + +``` +(): + +[optional body] + +[optional footer(s)] +``` + +**Types:** +- `feat` — new feature (triggers MINOR version bump) +- `fix` — bug fix (triggers PATCH version bump) +- `test` — adding or updating tests (no release) +- `refactor` — code change that neither fixes a bug nor adds a feature (no release) +- `chore` — build, CI, dependency changes (no release) +- `docs` — documentation only (no release) + +**Scopes for this plan:** +- `useClickAway` — the click-away hook +- `types` — TypeScript type definitions +- `useComboBox` — the core composable +- `ComboBox` — the root provider component +- `ComboBoxInput` — the search input primitive +- `ComboBoxMenu` — the dropdown menu primitive +- `ComboBoxOption` — the option primitive +- `ComboBoxButton` — the toggle button primitive +- `ComboBoxClear` — the clear button primitive + +**Breaking changes:** Any commit that introduces a breaking change MUST include a `BREAKING CHANGE:` footer in the commit body. This triggers a MAJOR version bump via `semantic-release`. + +``` +feat(ComboBox)!: replace ListBoxKey injection with ComboBoxKey + +BREAKING CHANGE: The provide/inject key has been renamed from +ListBoxKey to ComboBoxKey. Any code injecting ListBoxKey must +update to use ComboBoxKey instead. +``` + +**Known breaking changes to track:** +- `ListBoxKey` renamed to `ComboBoxKey` (injection key) +- `ListBoxProps` / `ResolvedListBoxProps` types replaced by `ComboBoxProps` / `ComboBoxContext` +- `ComboBoxOption` now requires an `index` prop +- `StyledComboBox` API will change once primitives are finalized +- The `value` prop was already renamed to `modelValue` in a prior beta (documented in #1597) +- SCSS was already removed in a prior beta (documented in #1597) + +These breaking changes are acceptable within the `beta` prerelease channel. They will be collected into the v4 upgrade guide (Plan 3, Phase C). + +--- + ## Task 1: Fix useClickAway Bug + Add Tests The current `useClickAway.ts` has a critical bug: `removeClickAwayListener` creates a new arrow function each call, so `document.removeEventListener` never actually removes the listener. The handler reference must be stored. @@ -123,7 +206,14 @@ Expected: All 4 tests PASS ```bash git add src/hooks/useClickAway.ts tests/unit/hooks/useClickAway.spec.ts -git commit -m "fix(useClickAway): store handler reference so removeEventListener works" +git commit -m "$(cat <<'EOF' +fix(useClickAway): store handler reference so removeEventListener works + +The previous implementation created a new arrow function in +removeClickAwayListener, so document.removeEventListener never +matched the original handler. Now the handler is stored and reused. +EOF +)" ``` --- @@ -304,7 +394,14 @@ Expected: May have errors in existing ComboBox files that reference old types. T ```bash git add src/types.ts src/keys.ts -git commit -m "feat(types): define ComboBoxProps and ComboBoxContext interfaces" +git commit -m "$(cat <<'EOF' +feat(types): define ComboBoxProps and ComboBoxContext interfaces + +BREAKING CHANGE: ListBoxProps and ResolvedListBoxProps types are +replaced by ComboBoxProps and ComboBoxContext. The ListBoxKey +injection key is replaced by ComboBoxKey. +EOF +)" ``` --- @@ -1009,7 +1106,14 @@ onUnmounted(() => removeClickAwayListener(el.value)) ```bash git add src/components/ComboBox/ComboBox.vue tests/unit/ComboBox/ComboBox.spec.ts -git commit -m "feat(ComboBox): wire up useComboBox composable with provide/inject" +git commit -m "$(cat <<'EOF' +feat(ComboBox): wire up useComboBox composable with provide/inject + +BREAKING CHANGE: ComboBox now provides ComboBoxContext (via ComboBoxKey) +instead of the previous ResolvedListBoxProps (via ListBoxKey). Child +components must inject ComboBoxKey to access the expanded context. +EOF +)" ``` --- @@ -1131,7 +1235,14 @@ function onBlur() { ```bash git add src/components/ComboBox/ComboBoxInput.vue tests/unit/ComboBox/ComboBoxInput.spec.ts -git commit -m "feat(ComboBoxInput): full keyboard nav, ARIA, and IME support" +git commit -m "$(cat <<'EOF' +feat(ComboBoxInput): full keyboard nav, ARIA, and IME support + +BREAKING CHANGE: ComboBoxInput now renders a fully controlled input +with ARIA attributes and keyboard handlers. The previous uncontrolled +input with no bindings is replaced. +EOF +)" ``` --- @@ -1269,7 +1380,15 @@ function onClick() { ```bash git add src/components/ComboBox/ComboBoxOption.vue tests/unit/ComboBox/ComboBoxOption.spec.ts -git commit -m "feat(ComboBoxOption): ARIA option with selection, highlight, and disabled states" +git commit -m "$(cat <<'EOF' +feat(ComboBoxOption)!: add ARIA, highlight, and disabled states + +BREAKING CHANGE: ComboBoxOption now requires an `index` prop for +ARIA and typeahead pointer tracking. The `value` prop type is +narrowed to OptionValue. Slot bindings now include isHighlighted +and isDisabled alongside isSelected. +EOF +)" ``` --- diff --git a/docs/plans/2026-02-16-v4-release-design.md b/docs/plans/2026-02-16-v4-release-design.md index 141132ead..e3902394d 100644 --- a/docs/plans/2026-02-16-v4-release-design.md +++ b/docs/plans/2026-02-16-v4-release-design.md @@ -168,6 +168,22 @@ Plan 3: Documentation │ backward compat) - **Plan 3 Phase C** (upgrade guide, headless docs) waits for Plans 1 + 2 - **Plan 4** (release) waits for everything +## Branching & Commit Strategy + +**Integration branch:** `@beta/dev` is the canonical integration branch for all v4 work. + +**Feature branches:** Each workstream/task gets an isolated feature branch off `@beta/dev`: +- `feat/combobox-*` -- headless primitives work +- `fix/*` -- bugfixes +- `docs/*` -- documentation work +- `chore/*` -- build/CI/dependency changes + +**Commit convention:** [Conventional Commits](https://www.conventionalcommits.org/) via `cz-conventional-changelog`. This project uses `semantic-release` to auto-publish from the `beta` release branch. + +**Breaking changes:** Any commit with a breaking change MUST include a `BREAKING CHANGE:` footer. All breaking changes are tracked in the implementation plans and will be collected into the v4 upgrade guide. + +**Release flow:** `@beta/dev` -> `beta` (prerelease channel) -> `master` (stable v4.0.0) + ## Key Decisions Made 1. **Ship both headless primitives and styled wrapper** in v4.0 @@ -176,6 +192,8 @@ Plan 3: Documentation │ backward compat) 4. **Parallel workstreams, ship when ready** -- no artificial timeline pressure 5. **v3 docs archived under `/v3/` path** via content migration into Nuxt 6. **Each plan gets its own detailed implementation plan** in a separate planning session +7. **Feature branches** for isolated work, merged back into `@beta/dev` +8. **Semantic commits** with `BREAKING CHANGE:` footers for all breaking changes ## Current Project State (as of 2026-02-16) diff --git a/src/components/ComboBox/ComboBox.vue b/src/components/ComboBox/ComboBox.vue index 0f4d34cd0..5bd23175a 100644 --- a/src/components/ComboBox/ComboBox.vue +++ b/src/components/ComboBox/ComboBox.vue @@ -1,72 +1,55 @@ diff --git a/src/components/ComboBox/ComboBoxButton.vue b/src/components/ComboBox/ComboBoxButton.vue index 277d18441..c04c85ab3 100644 --- a/src/components/ComboBox/ComboBoxButton.vue +++ b/src/components/ComboBox/ComboBoxButton.vue @@ -1,28 +1,21 @@ diff --git a/src/components/ComboBox/ComboBoxClear.vue b/src/components/ComboBox/ComboBoxClear.vue new file mode 100644 index 000000000..fb399d8f4 --- /dev/null +++ b/src/components/ComboBox/ComboBoxClear.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/components/ComboBox/ComboBoxInput.vue b/src/components/ComboBox/ComboBoxInput.vue index a0e71b9a1..da033d6ee 100644 --- a/src/components/ComboBox/ComboBoxInput.vue +++ b/src/components/ComboBox/ComboBoxInput.vue @@ -1,13 +1,85 @@ diff --git a/src/components/ComboBox/ComboBoxMenu.vue b/src/components/ComboBox/ComboBoxMenu.vue index 160dfea1b..2521dd838 100644 --- a/src/components/ComboBox/ComboBoxMenu.vue +++ b/src/components/ComboBox/ComboBoxMenu.vue @@ -1,21 +1,38 @@ diff --git a/src/components/ComboBox/ComboBoxOption.vue b/src/components/ComboBox/ComboBoxOption.vue index efe1c125c..36ca4f2ed 100644 --- a/src/components/ComboBox/ComboBoxOption.vue +++ b/src/components/ComboBox/ComboBoxOption.vue @@ -1,26 +1,39 @@ diff --git a/src/components/ComboBox/StyledComboBox.vue b/src/components/ComboBox/StyledComboBox.vue index 4670c799e..d2f5f822c 100644 --- a/src/components/ComboBox/StyledComboBox.vue +++ b/src/components/ComboBox/StyledComboBox.vue @@ -3,7 +3,6 @@ import { ref, watch } from 'vue' import ComboBox from '@/components/ComboBox/ComboBox.vue' import ComboBoxInput from '@/components/ComboBox/ComboBoxInput.vue' import ComboBoxMenu from '@/components/ComboBox/ComboBoxMenu.vue' -import ComboBoxOption from '@/components/ComboBox/ComboBoxOption.vue' import ComboBoxButton from '@/components/ComboBox/ComboBoxButton.vue' import type { VueSelectOption } from '@/types' diff --git a/src/hooks/useClickAway.ts b/src/hooks/useClickAway.ts index cbce1544b..a1a92ee1d 100644 --- a/src/hooks/useClickAway.ts +++ b/src/hooks/useClickAway.ts @@ -1,21 +1,24 @@ -export function useClickAway(callback: () => void): { - addClickAwayListener: (el: HTMLElement | undefined) => void - removeClickAwayListener: (el: HTMLElement | undefined) => void -} { - function clickedOutside(el: HTMLElement | undefined, event: MouseEvent) { - if (el && !(el == event.target || el?.contains(event.target))) { - callback() +export function useClickAway(callback: () => void) { + let handler: ((event: Event) => void) | null = null + + function addClickAwayListener(el: HTMLElement | undefined) { + if (!el) return + + handler = (event: Event) => { + if (el && !(el === event.target || el.contains(event.target as Node))) { + callback() + } } + + document.addEventListener('click', handler) } - return { - addClickAwayListener: (el) => - document.addEventListener('click', (event: MouseEvent) => - clickedOutside(el, event) - ), - removeClickAwayListener: (el) => - document.removeEventListener('click', (event: MouseEvent) => - clickedOutside(el, event) - ), + function removeClickAwayListener() { + if (handler) { + document.removeEventListener('click', handler) + handler = null + } } + + return { addClickAwayListener, removeClickAwayListener } } diff --git a/src/hooks/useComboBox.ts b/src/hooks/useComboBox.ts index e69de29bb..b297131dd 100644 --- a/src/hooks/useComboBox.ts +++ b/src/hooks/useComboBox.ts @@ -0,0 +1,339 @@ +import { ref, computed, watch } from 'vue' +import type { ComboBoxProps, ComboBoxContext, OptionValue } from '@/types' + +let uidCounter = 0 + +export function useComboBox( + props: ComboBoxProps, + emit: (event: string, ...args: unknown[]) => void +): ComboBoxContext { + // --- State --- + + const open = ref(props.open ?? false) + const search = ref('') + const typeAheadPointer = ref(-1) + const isLoading = ref(props.loading ?? false) + const pushedTags = ref([]) + + // Generate a stable uid once per instance using a counter + const stableUid = String(++uidCounter) + + // Sync controlled open prop + watch(() => props.open, (val) => { + if (val !== undefined) open.value = val + }) + + // Sync loading prop + watch(() => props.loading, (val) => { + if (val !== undefined) isLoading.value = val + }) + + // --- Computed --- + + const selectedValue = computed(() => { + const v = props.modelValue + const values = Array.isArray(v) ? v : v != null ? [v] : [] + + if (!props.reduce) return values as OptionValue[] + + // When reduce is in use, modelValue contains reduced values. + // Map them back to full option objects. + return values.map((val) => findOptionFromReducedValue(val) ?? val as OptionValue) + }) + + const isValueEmpty = computed(() => selectedValue.value.length === 0) + const isSearching = computed(() => search.value.length > 0) + const disabled = computed(() => props.disabled ?? false) + const multiple = computed(() => props.multiple ?? false) + const filterable = computed(() => props.filterable ?? true) + const noDrop = computed(() => props.noDrop ?? false) + const clearable = computed(() => props.clearable ?? true) + const taggable = computed(() => props.taggable ?? false) + const placeholder = computed(() => props.placeholder ?? '') + const uid = computed(() => props.uid ?? stableUid) + const deselectFromDropdown = computed(() => props.deselectFromDropdown ?? false) + const autoscroll = computed(() => props.autoscroll ?? true) + + // --- Option helpers --- + + function getOptionLabel(option: OptionValue): string { + if (props.getOptionLabel) return props.getOptionLabel(option) + if (typeof option === 'object' && option !== null) { + const key = props.label ?? 'label' + return String((option as Record)[key] ?? '') + } + return String(option) + } + + function getOptionKey(option: OptionValue): string { + if (props.getOptionKey) return props.getOptionKey(option) + if (typeof option === 'object' && option !== null && 'id' in option) { + return String((option as Record).id) + } + return JSON.stringify(option) + } + + function isOptionSelected(option: OptionValue): boolean { + return selectedValue.value.some( + (selected) => getOptionKey(selected) === getOptionKey(option) + ) + } + + function isOptionSelectable(option: OptionValue): boolean { + return props.selectable ? props.selectable(option) : true + } + + function isOptionHighlighted(index: number): boolean { + return typeAheadPointer.value === index + } + + // --- Tagging helpers --- + + /** + * Checks if the option is a new tag candidate — i.e., its key doesn't exist + * in the original options list (props.options) or in pushedTags. + */ + function isNewTagCandidate(option: OptionValue): boolean { + const opts = props.options ?? [] + const optKey = getOptionKey(option) + return !opts.some((o) => getOptionKey(o) === optKey) && + !pushedTags.value.some((o) => getOptionKey(o) === optKey) + } + + // --- Filtering helpers --- + + function defaultFilterBy(option: OptionValue, label: string, search: string): boolean { + return label.toLocaleLowerCase().includes(search.toLocaleLowerCase()) + } + + function maybeAddTaggableOption(options: OptionValue[]): OptionValue[] { + if (!taggable.value || !search.value) return options + + // Create the tag option + const tagOption = props.createOption + ? props.createOption(search.value) + : search.value + + // Don't add if an option with the same key already exists + const tagKey = getOptionKey(tagOption) + const alreadyExists = options.some((o) => getOptionKey(o) === tagKey) + + if (alreadyExists) return options + return [tagOption, ...options] + } + + // --- Filtering computeds --- + + const optionList = computed(() => [ + ...(props.options ?? []), + ...pushedTags.value, + ]) + + const filteredOptions = computed(() => { + const opts = optionList.value + + // Custom filter function takes priority, but still add taggable option + if (props.filter) { + return maybeAddTaggableOption(props.filter(opts, search.value)) + } + + // If not filterable or no search, return all + if (!filterable.value || !search.value) { + return maybeAddTaggableOption(opts) + } + + // Default filtering logic + const filterFn = props.filterBy ?? defaultFilterBy + const filtered = opts.filter((option) => { + const label = getOptionLabel(option) + return filterFn(option, label, search.value) + }) + + return maybeAddTaggableOption(filtered) + }) + + // --- Reduce helpers --- + + function findOptionFromReducedValue(reducedValue: unknown): OptionValue | undefined { + const opts = optionList.value + return opts.find((option) => { + const reduced = props.reduce ? props.reduce(option) : option + return JSON.stringify(reduced) === JSON.stringify(reducedValue) + }) + } + + // --- Open state --- + + function setOpen(value: boolean) { + if (open.value === value) return + open.value = value + emit('update:open', value) + emit(value ? 'open' : 'close') + } + + function toggleOpen() { + setOpen(!open.value) + } + + // --- Search --- + + function setSearch(value: string) { + search.value = value + typeAheadPointer.value = -1 + emit('search', value, toggleLoading) + } + + // --- Selection --- + + function select(option: OptionValue) { + // Check if this is a new tag (not in the original options or pushedTags) + if (taggable.value && isNewTagCandidate(option)) { + emit('option:created', option) + if (props.pushTags) { + pushedTags.value = [...pushedTags.value, option] + } + } + + emit('option:selecting', option) + const emitValue = props.reduce ? props.reduce(option) : option + if (props.multiple) { + if (!isOptionSelected(option)) { + const current = Array.isArray(props.modelValue) + ? [...props.modelValue] + : [] + current.push(emitValue) + emit('update:modelValue', current) + } + } else { + emit('update:modelValue', emitValue) + } + emit('option:selected', option) + + if (props.clearSearchOnSelect !== false) { + search.value = '' + } + if (props.closeOnSelect !== false) { + setOpen(false) + } + } + + function deselect(option: OptionValue) { + emit('option:deselecting', option) + const current = Array.isArray(props.modelValue) + ? [...props.modelValue] + : [] + const reducedKey = props.reduce + ? JSON.stringify(props.reduce(option)) + : getOptionKey(option) + const filtered = current.filter((v) => { + const vKey = props.reduce ? JSON.stringify(v) : getOptionKey(v as OptionValue) + return vKey !== reducedKey + }) + emit('update:modelValue', filtered) + emit('option:deselected', option) + } + + function clearSelection() { + emit('update:modelValue', props.multiple ? [] : null) + } + + // --- TypeAhead navigation --- + + function typeAheadDown() { + const opts = filteredOptions.value + if (opts.length === 0) return + + let next = typeAheadPointer.value + 1 + if (next >= opts.length) next = 0 + + let checked = 0 + while (!isOptionSelectable(opts[next]) && checked < opts.length) { + next = (next + 1) % opts.length + checked++ + } + + // If we checked all options and none are selectable, reset to -1 + if (checked >= opts.length) { + typeAheadPointer.value = -1 + return + } + + typeAheadPointer.value = next + } + + function typeAheadUp() { + const opts = filteredOptions.value + if (opts.length === 0) return + + let prev = typeAheadPointer.value - 1 + if (prev < 0) prev = opts.length - 1 + + let checked = 0 + while (!isOptionSelectable(opts[prev]) && checked < opts.length) { + prev = prev - 1 + if (prev < 0) prev = opts.length - 1 + checked++ + } + + if (checked >= opts.length) { + typeAheadPointer.value = -1 + return + } + + typeAheadPointer.value = prev + } + + function typeAheadSelect() { + const opts = filteredOptions.value + if (typeAheadPointer.value >= 0 && typeAheadPointer.value < opts.length) { + const option = opts[typeAheadPointer.value] + if (isOptionSelectable(option)) { + select(option) + } + } + } + + function toggleLoading(value?: boolean) { + isLoading.value = value ?? !isLoading.value + } + + return { + // State + open, + search, + selectedValue, + filteredOptions, + typeAheadPointer, + isLoading, + disabled, + multiple, + filterable, + noDrop, + clearable, + taggable, + placeholder, + isValueEmpty, + isSearching, + uid, + deselectFromDropdown, + autoscroll, + + // Methods + select, + deselect, + clearSelection, + toggleOpen, + setOpen, + setSearch, + typeAheadUp, + typeAheadDown, + typeAheadSelect, + toggleLoading, + getOptionLabel, + getOptionKey, + isOptionSelected, + isOptionSelectable, + isOptionHighlighted, + optionList, + } +} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index e9f2f8fb8..000000000 --- a/src/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import VueSelect from './components/Select.vue' - -export default VueSelect diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 000000000..8695322e4 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,19 @@ +// src/index.ts +import Select from './components/Select.vue' + +// Headless primitives +export { default as ComboBox } from './components/ComboBox/ComboBox.vue' +export { default as ComboBoxInput } from './components/ComboBox/ComboBoxInput.vue' +export { default as ComboBoxMenu } from './components/ComboBox/ComboBoxMenu.vue' +export { default as ComboBoxOption } from './components/ComboBox/ComboBoxOption.vue' +export { default as ComboBoxButton } from './components/ComboBox/ComboBoxButton.vue' +export { default as ComboBoxClear } from './components/ComboBox/ComboBoxClear.vue' + +// Composable +export { useComboBox } from './hooks/useComboBox' + +// Types +export type { ComboBoxProps, ComboBoxContext, OptionValue, ModelValue } from './types' + +// Default export: the batteries-included component +export default Select diff --git a/src/keys.ts b/src/keys.ts index 5ca5bedbf..e5a5ca543 100644 --- a/src/keys.ts +++ b/src/keys.ts @@ -1,8 +1,16 @@ import type { InjectionKey } from 'vue' -import type { ResolvedListBoxProps } from '@/types' +import type { ComboBoxContext, ResolvedListBoxProps } from '@/types' +export const ComboBoxKey: InjectionKey = Symbol('ComboBoxContext') + +/** + * @deprecated Use ComboBoxKey instead. Will be removed in a future release. + */ export const ListBoxKey: InjectionKey = Symbol( 'ListBoxInjectionKey', ) +/** + * @deprecated Will be removed in a future release. + */ export const ListBoxOptionInjectionKey = Symbol() as InjectionKey diff --git a/src/types.ts b/src/types.ts index 3324f2b4e..d0dbd5759 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,12 +1,107 @@ -import { ComputedRef, PropType } from 'vue' +import type { ComputedRef, Ref } from 'vue' -export type VueSelectValue = PropType -export type VueSelectOption = PropType +/** + * A single option can be a string, number, or object. + */ +export type OptionValue = string | number | Record +/** + * The modelValue can be a single option, an array (multi-select), or null. + */ +export type ModelValue = OptionValue | OptionValue[] | null + +/** + * Props accepted by the ComboBox root component. + */ +export interface ComboBoxProps { + modelValue?: ModelValue + options?: OptionValue[] + multiple?: boolean + filterable?: boolean + taggable?: boolean + pushTags?: boolean + clearable?: boolean + closeOnSelect?: boolean + clearSearchOnSelect?: boolean + disabled?: boolean + open?: boolean + label?: string + reduce?: (option: OptionValue) => unknown + selectable?: (option: OptionValue) => boolean + getOptionLabel?: (option: OptionValue) => string + getOptionKey?: (option: OptionValue) => string + filter?: (options: OptionValue[], search: string) => OptionValue[] + filterBy?: (option: OptionValue, label: string, search: string) => boolean + createOption?: (search: string) => OptionValue + loading?: boolean + uid?: string + placeholder?: string + noDrop?: boolean + deselectFromDropdown?: boolean + autoscroll?: boolean +} + +/** + * The resolved context provided to child components via inject. + */ +export interface ComboBoxContext { + // State (readonly) + open: Ref + search: Ref + selectedValue: ComputedRef + filteredOptions: ComputedRef + typeAheadPointer: Ref + isLoading: Ref + disabled: ComputedRef + multiple: ComputedRef + filterable: ComputedRef + noDrop: ComputedRef + clearable: ComputedRef + taggable: ComputedRef + placeholder: ComputedRef + isValueEmpty: ComputedRef + isSearching: ComputedRef + uid: ComputedRef + deselectFromDropdown: ComputedRef + autoscroll: ComputedRef + + // Methods + select: (option: OptionValue) => void + deselect: (option: OptionValue) => void + clearSelection: () => void + toggleOpen: () => void + setOpen: (value: boolean) => void + setSearch: (value: string) => void + typeAheadUp: () => void + typeAheadDown: () => void + typeAheadSelect: () => void + toggleLoading: (value?: boolean) => void + getOptionLabel: (option: OptionValue) => string + getOptionKey: (option: OptionValue) => string + isOptionSelected: (option: OptionValue) => boolean + isOptionSelectable: (option: OptionValue) => boolean + isOptionHighlighted: (index: number) => boolean + optionList: ComputedRef +} + +/** + * @deprecated Use ComboBoxProps instead. Will be removed in a future release. + */ +export type VueSelectValue = unknown +/** + * @deprecated Use ComboBoxProps instead. Will be removed in a future release. + */ +export type VueSelectOption = unknown +/** + * @deprecated Use ComboBoxProps instead. Will be removed in a future release. + */ export interface ListBoxProps { modelValue: VueSelectValue open?: boolean | undefined } +/** + * @deprecated Use ComboBoxContext instead. Will be removed in a future release. + */ export interface ResolvedListBoxProps extends Omit { open: boolean inputText: string diff --git a/tests/unit/ComboBox/ComboBox.spec.ts b/tests/unit/ComboBox/ComboBox.spec.ts new file mode 100644 index 000000000..3bcd3c46d --- /dev/null +++ b/tests/unit/ComboBox/ComboBox.spec.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import { inject, defineComponent, h } from 'vue' +import ComboBox from '@/components/ComboBox/ComboBox.vue' +import { ComboBoxKey } from '@/keys' + +// Helper component to inspect injected context +const ContextReader = defineComponent({ + setup() { + const ctx = inject(ComboBoxKey)! + return { ctx } + }, + render() { + return h('div', `open:${this.ctx.open.value}`) + }, +}) + +function mountComboBox(props = {}) { + return mount(ComboBox, { + props: { options: ['one', 'two', 'three'], ...props }, + slots: { default: () => h(ContextReader) }, + }) +} + +describe('ComboBox', () => { + it('provides ComboBoxContext to children', () => { + const wrapper = mountComboBox() + const reader = wrapper.findComponent(ContextReader) + expect(reader.vm.ctx).toBeDefined() + expect(reader.vm.ctx.open.value).toBe(false) + }) + + it('does not have role=combobox on wrapper (role is on input)', () => { + const wrapper = mountComboBox() + expect(wrapper.attributes('role')).toBeUndefined() + }) + + it('does not have aria-expanded on wrapper (managed by input)', () => { + const wrapper = mountComboBox() + expect(wrapper.attributes('aria-expanded')).toBeUndefined() + }) + + it('emits update:modelValue when selection changes', () => { + const wrapper = mountComboBox() + const reader = wrapper.findComponent(ContextReader) + reader.vm.ctx.select('one') + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['one']) + }) + + it('renders slot content', () => { + const wrapper = mountComboBox() + expect(wrapper.text()).toContain('open:false') + }) + + it('passes all props through to useComboBox', () => { + const wrapper = mountComboBox({ multiple: true, placeholder: 'Pick...' }) + const reader = wrapper.findComponent(ContextReader) + expect(reader.vm.ctx.multiple.value).toBe(true) + expect(reader.vm.ctx.placeholder.value).toBe('Pick...') + }) +}) diff --git a/tests/unit/ComboBox/ComboBoxButton.spec.ts b/tests/unit/ComboBox/ComboBoxButton.spec.ts new file mode 100644 index 000000000..599bf3026 --- /dev/null +++ b/tests/unit/ComboBox/ComboBoxButton.spec.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { ref, computed } from 'vue' +import ComboBoxButton from '@/components/ComboBox/ComboBoxButton.vue' +import { ComboBoxKey } from '@/keys' +import type { ComboBoxContext, OptionValue } from '@/types' + +function createMockContext(overrides = {}): ComboBoxContext { + return { + open: ref(false), + search: ref(''), + selectedValue: computed(() => []), + filteredOptions: computed(() => []), + typeAheadPointer: ref(-1), + isLoading: ref(false), + disabled: computed(() => false), + multiple: computed(() => false), + filterable: computed(() => true), + noDrop: computed(() => false), + clearable: computed(() => true), + taggable: computed(() => false), + placeholder: computed(() => ''), + isValueEmpty: computed(() => true), + isSearching: computed(() => false), + uid: computed(() => 'test'), + deselectFromDropdown: computed(() => false), + autoscroll: computed(() => true), + select: vi.fn(), + deselect: vi.fn(), + clearSelection: vi.fn(), + toggleOpen: vi.fn(), + setOpen: vi.fn(), + setSearch: vi.fn(), + typeAheadUp: vi.fn(), + typeAheadDown: vi.fn(), + typeAheadSelect: vi.fn(), + toggleLoading: vi.fn(), + getOptionLabel: (opt: OptionValue) => String(opt), + getOptionKey: (opt: OptionValue) => JSON.stringify(opt), + isOptionSelected: () => false, + isOptionSelectable: () => true, + isOptionHighlighted: () => false, + optionList: computed(() => []), + ...overrides, + } +} + +function mountButton(ctxOverrides = {}) { + const ctx = createMockContext(ctxOverrides) + const wrapper = mount(ComboBoxButton, { + global: { + provide: { [ComboBoxKey as symbol]: ctx }, + }, + slots: { default: 'Toggle' }, + }) + return { wrapper, ctx } +} + +describe('ComboBoxButton', () => { + it('has aria-haspopup="listbox"', () => { + const { wrapper } = mountButton() + expect(wrapper.attributes('aria-haspopup')).toBe('listbox') + }) + + it('has aria-expanded reflecting open state', () => { + const { wrapper } = mountButton({ open: ref(true) }) + expect(wrapper.attributes('aria-expanded')).toBe('true') + }) + + it('has aria-controls pointing to listbox', () => { + const { wrapper } = mountButton() + expect(wrapper.attributes('aria-controls')).toBe('vs-test-listbox') + }) + + it('click calls toggleOpen', async () => { + const { wrapper, ctx } = mountButton() + await wrapper.trigger('click') + expect(ctx.toggleOpen).toHaveBeenCalled() + }) + + it('is disabled when context is disabled', () => { + const { wrapper } = mountButton({ disabled: computed(() => true) }) + expect(wrapper.attributes('disabled')).toBeDefined() + }) + + it('renders slot content', () => { + const { wrapper } = mountButton() + expect(wrapper.text()).toBe('Toggle') + }) +}) diff --git a/tests/unit/ComboBox/ComboBoxClear.spec.ts b/tests/unit/ComboBox/ComboBoxClear.spec.ts new file mode 100644 index 000000000..eebed7bdc --- /dev/null +++ b/tests/unit/ComboBox/ComboBoxClear.spec.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { ref, computed } from 'vue' +import ComboBoxClear from '@/components/ComboBox/ComboBoxClear.vue' +import { ComboBoxKey } from '@/keys' +import type { ComboBoxContext, OptionValue } from '@/types' + +function createMockContext(overrides = {}): ComboBoxContext { + return { + open: ref(false), + search: ref(''), + selectedValue: computed(() => []), + filteredOptions: computed(() => []), + typeAheadPointer: ref(-1), + isLoading: ref(false), + disabled: computed(() => false), + multiple: computed(() => false), + filterable: computed(() => true), + noDrop: computed(() => false), + clearable: computed(() => true), + taggable: computed(() => false), + placeholder: computed(() => ''), + isValueEmpty: computed(() => true), + isSearching: computed(() => false), + uid: computed(() => 'test'), + deselectFromDropdown: computed(() => false), + autoscroll: computed(() => true), + select: vi.fn(), + deselect: vi.fn(), + clearSelection: vi.fn(), + toggleOpen: vi.fn(), + setOpen: vi.fn(), + setSearch: vi.fn(), + typeAheadUp: vi.fn(), + typeAheadDown: vi.fn(), + typeAheadSelect: vi.fn(), + toggleLoading: vi.fn(), + getOptionLabel: (opt: OptionValue) => String(opt), + getOptionKey: (opt: OptionValue) => JSON.stringify(opt), + isOptionSelected: () => false, + isOptionSelectable: () => true, + isOptionHighlighted: () => false, + optionList: computed(() => []), + ...overrides, + } +} + +function mountClear(ctxOverrides = {}) { + const ctx = createMockContext(ctxOverrides) + const wrapper = mount(ComboBoxClear, { + global: { + provide: { [ComboBoxKey as symbol]: ctx }, + }, + slots: { default: 'x' }, + }) + return { wrapper, ctx } +} + +describe('ComboBoxClear', () => { + it('is not visible when value is empty', () => { + const { wrapper } = mountClear({ isValueEmpty: computed(() => true) }) + expect(wrapper.isVisible()).toBe(false) + }) + + it('is not visible when clearable is false', () => { + const { wrapper } = mountClear({ + isValueEmpty: computed(() => false), + clearable: computed(() => false), + }) + expect(wrapper.isVisible()).toBe(false) + }) + + it('is visible when value is not empty and clearable', () => { + const { wrapper } = mountClear({ + isValueEmpty: computed(() => false), + clearable: computed(() => true), + }) + expect(wrapper.isVisible()).toBe(true) + }) + + it('click calls clearSelection', async () => { + const { wrapper, ctx } = mountClear({ + isValueEmpty: computed(() => false), + }) + await wrapper.trigger('click') + expect(ctx.clearSelection).toHaveBeenCalled() + }) + + it('renders slot content', () => { + const { wrapper } = mountClear({ isValueEmpty: computed(() => false) }) + expect(wrapper.text()).toBe('x') + }) + + it('has aria-label', () => { + const { wrapper } = mountClear({ isValueEmpty: computed(() => false) }) + expect(wrapper.attributes('aria-label')).toBe('Clear selection') + }) +}) diff --git a/tests/unit/ComboBox/ComboBoxInput.spec.ts b/tests/unit/ComboBox/ComboBoxInput.spec.ts new file mode 100644 index 000000000..9222e335f --- /dev/null +++ b/tests/unit/ComboBox/ComboBoxInput.spec.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { ref, computed } from 'vue' +import ComboBoxInput from '@/components/ComboBox/ComboBoxInput.vue' +import { ComboBoxKey } from '@/keys' +import type { ComboBoxContext, OptionValue } from '@/types' + +function createMockContext(overrides = {}): ComboBoxContext { + return { + open: ref(false), + search: ref(''), + selectedValue: computed(() => []), + filteredOptions: computed(() => []), + typeAheadPointer: ref(-1), + isLoading: ref(false), + disabled: computed(() => false), + multiple: computed(() => false), + filterable: computed(() => true), + noDrop: computed(() => false), + clearable: computed(() => true), + taggable: computed(() => false), + placeholder: computed(() => ''), + isValueEmpty: computed(() => true), + isSearching: computed(() => false), + uid: computed(() => 'test'), + deselectFromDropdown: computed(() => false), + autoscroll: computed(() => true), + select: vi.fn(), + deselect: vi.fn(), + clearSelection: vi.fn(), + toggleOpen: vi.fn(), + setOpen: vi.fn(), + setSearch: vi.fn(), + typeAheadUp: vi.fn(), + typeAheadDown: vi.fn(), + typeAheadSelect: vi.fn(), + toggleLoading: vi.fn(), + getOptionLabel: (opt: OptionValue) => String(opt), + getOptionKey: (opt: OptionValue) => JSON.stringify(opt), + isOptionSelected: () => false, + isOptionSelectable: () => true, + isOptionHighlighted: () => false, + optionList: computed(() => []), + ...overrides, + } +} + +function mountInput(ctxOverrides = {}) { + const ctx = createMockContext(ctxOverrides) + const wrapper = mount(ComboBoxInput, { + global: { + provide: { [ComboBoxKey as symbol]: ctx }, + }, + }) + return { wrapper, ctx } +} + +describe('ComboBoxInput', () => { + it('renders an input with type="search"', () => { + const { wrapper } = mountInput() + const input = wrapper.find('input') + expect(input.exists()).toBe(true) + expect(input.attributes('type')).toBe('search') + }) + + it('has correct ARIA attributes', () => { + const { wrapper } = mountInput() + const input = wrapper.find('input') + expect(input.attributes('role')).toBe('combobox') + expect(input.attributes('aria-autocomplete')).toBe('list') + expect(input.attributes('aria-expanded')).toBe('false') + expect(input.attributes('aria-controls')).toBe('vs-test-listbox') + expect(input.attributes('autocomplete')).toBe('off') + }) + + it('sets aria-activedescendant when pointer is active', () => { + const { wrapper } = mountInput({ typeAheadPointer: ref(2) }) + const input = wrapper.find('input') + expect(input.attributes('aria-activedescendant')).toBe('vs-test-option-2') + }) + + it('does not set aria-activedescendant when pointer is -1', () => { + const { wrapper } = mountInput() + const input = wrapper.find('input') + expect(input.attributes('aria-activedescendant')).toBeUndefined() + }) + + it('calls setSearch on input', async () => { + const { wrapper, ctx } = mountInput() + const input = wrapper.find('input') + await input.setValue('hello') + expect(ctx.setSearch).toHaveBeenCalledWith('hello') + }) + + it('ArrowDown calls typeAheadDown and opens dropdown', async () => { + const { wrapper, ctx } = mountInput() + await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' }) + expect(ctx.typeAheadDown).toHaveBeenCalled() + expect(ctx.setOpen).toHaveBeenCalledWith(true) + }) + + it('ArrowUp calls typeAheadUp', async () => { + const { wrapper, ctx } = mountInput() + await wrapper.find('input').trigger('keydown', { key: 'ArrowUp' }) + expect(ctx.typeAheadUp).toHaveBeenCalled() + }) + + it('Enter calls typeAheadSelect when open', async () => { + const { wrapper, ctx } = mountInput({ open: ref(true) }) + await wrapper.find('input').trigger('keydown', { key: 'Enter' }) + expect(ctx.typeAheadSelect).toHaveBeenCalled() + }) + + it('Escape clears search when searching', async () => { + const search = ref('hello') + const { wrapper, ctx } = mountInput({ + search, + isSearching: computed(() => search.value.length > 0), + }) + await wrapper.find('input').trigger('keydown', { key: 'Escape' }) + expect(ctx.setSearch).toHaveBeenCalledWith('') + }) + + it('Escape closes dropdown when not searching', async () => { + const { wrapper, ctx } = mountInput({ open: ref(true) }) + await wrapper.find('input').trigger('keydown', { key: 'Escape' }) + expect(ctx.setOpen).toHaveBeenCalledWith(false) + }) + + it('Backspace deselects last value when search empty and multiple', async () => { + const lastOption = 'two' + const { wrapper, ctx } = mountInput({ + multiple: computed(() => true), + isValueEmpty: computed(() => false), + selectedValue: computed(() => ['one', lastOption]), + }) + await wrapper.find('input').trigger('keydown', { key: 'Backspace' }) + expect(ctx.deselect).toHaveBeenCalledWith(lastOption) + }) + + it('focus opens the dropdown', async () => { + const { wrapper, ctx } = mountInput() + await wrapper.find('input').trigger('focus') + expect(ctx.setOpen).toHaveBeenCalledWith(true) + }) + + it('is disabled when context is disabled', () => { + const { wrapper } = mountInput({ disabled: computed(() => true) }) + expect(wrapper.find('input').attributes('disabled')).toBeDefined() + }) +}) diff --git a/tests/unit/ComboBox/ComboBoxMenu.spec.ts b/tests/unit/ComboBox/ComboBoxMenu.spec.ts new file mode 100644 index 000000000..9c3ed90db --- /dev/null +++ b/tests/unit/ComboBox/ComboBoxMenu.spec.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { ref, computed } from 'vue' +import ComboBoxMenu from '@/components/ComboBox/ComboBoxMenu.vue' +import { ComboBoxKey } from '@/keys' +import type { ComboBoxContext, OptionValue } from '@/types' + +function createMockContext(overrides = {}): ComboBoxContext { + return { + open: ref(false), + search: ref(''), + selectedValue: computed(() => []), + filteredOptions: computed(() => []), + typeAheadPointer: ref(-1), + isLoading: ref(false), + disabled: computed(() => false), + multiple: computed(() => false), + filterable: computed(() => true), + noDrop: computed(() => false), + clearable: computed(() => true), + taggable: computed(() => false), + placeholder: computed(() => ''), + isValueEmpty: computed(() => true), + isSearching: computed(() => false), + uid: computed(() => 'test'), + deselectFromDropdown: computed(() => false), + autoscroll: computed(() => true), + select: vi.fn(), + deselect: vi.fn(), + clearSelection: vi.fn(), + toggleOpen: vi.fn(), + setOpen: vi.fn(), + setSearch: vi.fn(), + typeAheadUp: vi.fn(), + typeAheadDown: vi.fn(), + typeAheadSelect: vi.fn(), + toggleLoading: vi.fn(), + getOptionLabel: (opt: OptionValue) => String(opt), + getOptionKey: (opt: OptionValue) => JSON.stringify(opt), + isOptionSelected: () => false, + isOptionSelectable: () => true, + isOptionHighlighted: () => false, + optionList: computed(() => []), + ...overrides, + } +} + +function mountMenu(ctxOverrides = {}, slotContent?: string) { + const ctx = createMockContext(ctxOverrides) + const wrapper = mount(ComboBoxMenu, { + global: { + provide: { [ComboBoxKey as symbol]: ctx }, + }, + slots: { default: slotContent ?? 'Menu content' }, + }) + return { wrapper, ctx } +} + +describe('ComboBoxMenu', () => { + it('has role="listbox"', () => { + const { wrapper } = mountMenu({ open: ref(true) }) + expect(wrapper.attributes('role')).toBe('listbox') + }) + + it('has correct id', () => { + const { wrapper } = mountMenu({ open: ref(true) }) + expect(wrapper.attributes('id')).toBe('vs-test-listbox') + }) + + it('has tabindex="-1"', () => { + const { wrapper } = mountMenu({ open: ref(true) }) + expect(wrapper.attributes('tabindex')).toBe('-1') + }) + + it('is visible when open is true', () => { + const { wrapper } = mountMenu({ open: ref(true) }) + expect(wrapper.isVisible()).toBe(true) + }) + + it('is hidden when open is false', () => { + const { wrapper } = mountMenu({ open: ref(false) }) + expect(wrapper.isVisible()).toBe(false) + }) + + it('is hidden when noDrop is true even if open', () => { + const { wrapper } = mountMenu({ open: ref(true), noDrop: computed(() => true) }) + expect(wrapper.isVisible()).toBe(false) + }) + + it('renders slot content', () => { + const { wrapper } = mountMenu({ open: ref(true) }, 'Test options') + expect(wrapper.text()).toContain('Test options') + }) +}) diff --git a/tests/unit/ComboBox/ComboBoxOption.spec.ts b/tests/unit/ComboBox/ComboBoxOption.spec.ts new file mode 100644 index 000000000..7dfda2a9b --- /dev/null +++ b/tests/unit/ComboBox/ComboBoxOption.spec.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { ref, computed } from 'vue' +import ComboBoxOption from '@/components/ComboBox/ComboBoxOption.vue' +import { ComboBoxKey } from '@/keys' +import type { ComboBoxContext, OptionValue } from '@/types' + +function createMockContext(overrides = {}): ComboBoxContext { + return { + open: ref(false), + search: ref(''), + selectedValue: computed(() => []), + filteredOptions: computed(() => []), + typeAheadPointer: ref(-1), + isLoading: ref(false), + disabled: computed(() => false), + multiple: computed(() => false), + filterable: computed(() => true), + noDrop: computed(() => false), + clearable: computed(() => true), + taggable: computed(() => false), + placeholder: computed(() => ''), + isValueEmpty: computed(() => true), + isSearching: computed(() => false), + uid: computed(() => 'test'), + deselectFromDropdown: computed(() => false), + autoscroll: computed(() => true), + select: vi.fn(), + deselect: vi.fn(), + clearSelection: vi.fn(), + toggleOpen: vi.fn(), + setOpen: vi.fn(), + setSearch: vi.fn(), + typeAheadUp: vi.fn(), + typeAheadDown: vi.fn(), + typeAheadSelect: vi.fn(), + toggleLoading: vi.fn(), + getOptionLabel: (opt: OptionValue) => String(opt), + getOptionKey: (opt: OptionValue) => JSON.stringify(opt), + isOptionSelected: () => false, + isOptionSelectable: () => true, + isOptionHighlighted: () => false, + optionList: computed(() => []), + ...overrides, + } +} + +function mountOption(props = {}, ctxOverrides = {}) { + const ctx = createMockContext(ctxOverrides) + const wrapper = mount(ComboBoxOption, { + props: { value: 'test', index: 0, ...props }, + global: { + provide: { [ComboBoxKey as symbol]: ctx }, + }, + slots: { + default: (slotProps: { isSelected: boolean; isHighlighted: boolean; isDisabled: boolean }) => + `selected:${slotProps.isSelected},highlighted:${slotProps.isHighlighted},disabled:${slotProps.isDisabled}`, + }, + }) + return { wrapper, ctx } +} + +describe('ComboBoxOption', () => { + it('has role="option"', () => { + const { wrapper } = mountOption() + expect(wrapper.attributes('role')).toBe('option') + }) + + it('has correct id', () => { + const { wrapper } = mountOption({ index: 3 }) + expect(wrapper.attributes('id')).toBe('vs-test-option-3') + }) + + it('sets aria-selected when selected', () => { + const { wrapper } = mountOption({}, { isOptionSelected: () => true }) + expect(wrapper.attributes('aria-selected')).toBe('true') + }) + + it('sets aria-selected="false" when not selected', () => { + const { wrapper } = mountOption() + expect(wrapper.attributes('aria-selected')).toBe('false') + }) + + it('sets aria-disabled when not selectable', () => { + const { wrapper } = mountOption({}, { isOptionSelectable: () => false }) + expect(wrapper.attributes('aria-disabled')).toBe('true') + }) + + it('click calls select when not selected', async () => { + const { wrapper, ctx } = mountOption() + await wrapper.trigger('click') + expect(ctx.select).toHaveBeenCalledWith('test') + }) + + it('click calls deselect when already selected and deselectFromDropdown is true', async () => { + const { wrapper, ctx } = mountOption({}, { + isOptionSelected: () => true, + deselectFromDropdown: computed(() => true), + }) + await wrapper.trigger('click') + expect(ctx.deselect).toHaveBeenCalledWith('test') + }) + + it('click does nothing when already selected and deselectFromDropdown is false', async () => { + const { wrapper, ctx } = mountOption({}, { + isOptionSelected: () => true, + deselectFromDropdown: computed(() => false), + }) + await wrapper.trigger('click') + expect(ctx.select).not.toHaveBeenCalled() + expect(ctx.deselect).not.toHaveBeenCalled() + }) + + it('click does nothing when disabled', async () => { + const { wrapper, ctx } = mountOption({}, { isOptionSelectable: () => false }) + await wrapper.trigger('click') + expect(ctx.select).not.toHaveBeenCalled() + expect(ctx.deselect).not.toHaveBeenCalled() + }) + + it('mouseover updates typeAheadPointer', async () => { + const pointer = ref(-1) + const { wrapper } = mountOption({ index: 2 }, { typeAheadPointer: pointer }) + await wrapper.trigger('mouseover') + expect(pointer.value).toBe(2) + }) + + it('exposes isSelected, isHighlighted, isDisabled via slot', () => { + const { wrapper } = mountOption({}, { + isOptionSelected: () => true, + isOptionHighlighted: (i: number) => i === 0, + }) + expect(wrapper.text()).toContain('selected:true') + expect(wrapper.text()).toContain('highlighted:true') + expect(wrapper.text()).toContain('disabled:false') + }) +}) diff --git a/tests/unit/ComboBox/integration.spec.ts b/tests/unit/ComboBox/integration.spec.ts new file mode 100644 index 000000000..bc2377d1f --- /dev/null +++ b/tests/unit/ComboBox/integration.spec.ts @@ -0,0 +1,208 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent, h, inject, nextTick } from 'vue' +import { + ComboBox, + ComboBoxInput, + ComboBoxMenu, + ComboBoxOption, + ComboBoxButton, + ComboBoxClear, +} from '@/index' +import { ComboBoxKey } from '@/keys' +import type { OptionValue } from '@/types' + +/** + * A helper component that injects ComboBoxContext and renders + * filteredOptions as ComboBoxOption children inside ComboBoxMenu. + * This mirrors real-world usage where the consumer reads from the + * context to know which options to display after filtering. + */ +const FilteredOptionList = defineComponent({ + setup() { + const ctx = inject(ComboBoxKey)! + return { ctx } + }, + render() { + return this.ctx.filteredOptions.value.map((opt: OptionValue, i: number) => + h( + ComboBoxOption, + { value: opt, index: i, key: this.ctx.getOptionKey(opt) }, + { + default: ({ + isSelected, + isHighlighted, + }: { + isSelected: boolean + isHighlighted: boolean + }) => + h( + 'span', + { + class: { + selected: isSelected, + highlighted: isHighlighted, + }, + }, + String(opt) + ), + } + ) + ) + }, +}) + +const TestSelect = defineComponent({ + props: { + options: { type: Array, default: () => [] }, + modelValue: { type: [String, Number, Object, Array], default: undefined }, + multiple: { type: Boolean, default: false }, + taggable: { type: Boolean, default: false }, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => + h( + ComboBox, + { + ...props, + 'onUpdate:modelValue': (v: unknown) => emit('update:modelValue', v), + }, + { + default: () => [ + h(ComboBoxInput), + h(ComboBoxButton, null, { default: () => 'Toggle' }), + h(ComboBoxClear, null, { default: () => 'Clear' }), + h(ComboBoxMenu, null, { + default: () => h(FilteredOptionList), + }), + ], + } + ) + }, +}) + +/** Wait for all pending Vue reactivity and DOM updates. */ +async function waitForUpdate() { + await nextTick() + await nextTick() +} + +/** + * Checks whether the listbox menu is showing. We rely on the inline + * style.display set by v-show because jsdom's getComputedStyle does + * not propagate inline styles, which causes vue-test-utils' isVisible() + * to give false positives in deeply-nested component trees. + */ +function isListboxVisible(wrapper: ReturnType): boolean { + const el = wrapper.find('[role="listbox"]').element as HTMLElement + return el.style.display !== 'none' +} + +describe('ComboBox integration', () => { + it('full selection flow: open, navigate, select', async () => { + const wrapper = mount(TestSelect, { + props: { options: ['one', 'two', 'three'] }, + }) + + // Focus input to open + await wrapper.find('input').trigger('focus') + await waitForUpdate() + expect(isListboxVisible(wrapper)).toBe(true) + + // Arrow down twice to highlight 'two' + await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' }) + await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' }) + await waitForUpdate() + expect(wrapper.find('.highlighted').text()).toBe('two') + + // Enter to select + await wrapper.find('input').trigger('keydown', { key: 'Enter' }) + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['two']) + }) + + it('multi-select flow', async () => { + const wrapper = mount(TestSelect, { + props: { options: ['one', 'two', 'three'], multiple: true, modelValue: [] }, + }) + + await wrapper.find('input').trigger('focus') + await waitForUpdate() + + // Click 'one' + await wrapper.findAll('[role="option"]')[0].trigger('click') + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['one']]) + }) + + it('filtering flow', async () => { + const wrapper = mount(TestSelect, { + props: { options: ['one', 'two', 'three'] }, + }) + + await wrapper.find('input').trigger('focus') + await waitForUpdate() + + await wrapper.find('input').setValue('tw') + await waitForUpdate() + + const options = wrapper.findAll('[role="option"]') + expect(options).toHaveLength(1) + expect(options[0].text()).toBe('two') + }) + + it('escape closes dropdown', async () => { + const wrapper = mount(TestSelect, { + props: { options: ['one', 'two', 'three'] }, + }) + + await wrapper.find('input').trigger('focus') + await waitForUpdate() + expect(isListboxVisible(wrapper)).toBe(true) + + await wrapper.find('input').trigger('keydown', { key: 'Escape' }) + await waitForUpdate() + expect(isListboxVisible(wrapper)).toBe(false) + }) + + it('toggle button opens and closes dropdown', async () => { + const wrapper = mount(TestSelect, { + props: { options: ['one', 'two', 'three'] }, + }) + + await wrapper.find('button').trigger('click') + await waitForUpdate() + expect(isListboxVisible(wrapper)).toBe(true) + + await wrapper.find('button').trigger('click') + await waitForUpdate() + expect(isListboxVisible(wrapper)).toBe(false) + }) + + it('clicking an option selects it', async () => { + const wrapper = mount(TestSelect, { + props: { options: ['one', 'two', 'three'] }, + }) + + await wrapper.find('input').trigger('focus') + await waitForUpdate() + await wrapper.findAll('[role="option"]')[1].trigger('click') + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['two']) + }) + + it('ARIA attributes are properly connected', () => { + const wrapper = mount(TestSelect, { + props: { options: ['one', 'two'] }, + }) + + const combobox = wrapper.find('[role="combobox"]') + const listbox = wrapper.find('[role="listbox"]') + const options = wrapper.findAll('[role="option"]') + + expect(combobox.exists()).toBe(true) + expect(listbox.exists()).toBe(true) + expect(options).toHaveLength(2) + + // The combobox (input) controls the listbox + expect(combobox.attributes('aria-controls')).toBe(listbox.attributes('id')) + }) +}) diff --git a/tests/unit/hooks/useClickAway.spec.ts b/tests/unit/hooks/useClickAway.spec.ts new file mode 100644 index 000000000..ee888995d --- /dev/null +++ b/tests/unit/hooks/useClickAway.spec.ts @@ -0,0 +1,50 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { useClickAway } from '@/hooks/useClickAway' + +describe('useClickAway', () => { + let el: HTMLDivElement + + beforeEach(() => { + el = document.createElement('div') + document.body.appendChild(el) + }) + + afterEach(() => { + document.body.removeChild(el) + }) + + it('calls callback when clicking outside the element', async () => { + const callback = vi.fn() + const { addClickAwayListener } = useClickAway(callback) + addClickAwayListener(el) + + document.body.click() + expect(callback).toHaveBeenCalledOnce() + }) + + it('does not call callback when clicking inside the element', () => { + const callback = vi.fn() + const { addClickAwayListener } = useClickAway(callback) + addClickAwayListener(el) + + el.click() + expect(callback).not.toHaveBeenCalled() + }) + + it('stops listening after removeClickAwayListener is called', () => { + const callback = vi.fn() + const { addClickAwayListener, removeClickAwayListener } = useClickAway(callback) + addClickAwayListener(el) + removeClickAwayListener(el) + + document.body.click() + expect(callback).not.toHaveBeenCalled() + }) + + it('does not throw when removing listener before adding', () => { + const callback = vi.fn() + const { removeClickAwayListener } = useClickAway(callback) + expect(() => removeClickAwayListener(el)).not.toThrow() + }) +}) diff --git a/tests/unit/hooks/useComboBox.spec.ts b/tests/unit/hooks/useComboBox.spec.ts new file mode 100644 index 000000000..5f4159fb1 --- /dev/null +++ b/tests/unit/hooks/useComboBox.spec.ts @@ -0,0 +1,427 @@ +import { describe, it, expect, vi } from 'vitest' +import { useComboBox } from '@/hooks/useComboBox' +import type { OptionValue } from '@/types' + +function createComboBox(overrides = {}) { + const emit = vi.fn() + const props = { + options: ['one', 'two', 'three'], + ...overrides, + } + return { ...useComboBox(props, emit), emit } +} + +describe('useComboBox', () => { + describe('open state', () => { + it('starts closed by default', () => { + const { open } = createComboBox() + expect(open.value).toBe(false) + }) + + it('toggleOpen flips the state', () => { + const { open, toggleOpen } = createComboBox() + toggleOpen() + expect(open.value).toBe(true) + toggleOpen() + expect(open.value).toBe(false) + }) + + it('setOpen sets the state directly', () => { + const { open, setOpen } = createComboBox() + setOpen(true) + expect(open.value).toBe(true) + setOpen(false) + expect(open.value).toBe(false) + }) + + it('emits open/close events', () => { + const { toggleOpen, emit } = createComboBox() + toggleOpen() + expect(emit).toHaveBeenCalledWith('open') + toggleOpen() + expect(emit).toHaveBeenCalledWith('close') + }) + }) + + describe('search state', () => { + it('starts with empty search', () => { + const { search } = createComboBox() + expect(search.value).toBe('') + }) + + it('setSearch updates search text', () => { + const { search, setSearch } = createComboBox() + setSearch('hello') + expect(search.value).toBe('hello') + }) + + it('isSearching reflects search state', () => { + const { isSearching, setSearch } = createComboBox() + expect(isSearching.value).toBe(false) + setSearch('hello') + expect(isSearching.value).toBe(true) + }) + }) + + describe('selection - single', () => { + it('starts with empty selection when no modelValue', () => { + const { selectedValue, isValueEmpty } = createComboBox() + expect(selectedValue.value).toEqual([]) + expect(isValueEmpty.value).toBe(true) + }) + + it('select sets the value and emits update:modelValue', () => { + const { select, emit } = createComboBox() + select('one') + expect(emit).toHaveBeenCalledWith('update:modelValue', 'one') + }) + + it('select emits option:selecting and option:selected events', () => { + const { select, emit } = createComboBox() + select('one') + expect(emit).toHaveBeenCalledWith('option:selecting', 'one') + expect(emit).toHaveBeenCalledWith('option:selected', 'one') + }) + + it('clearSelection resets to null and emits', () => { + const { clearSelection, emit } = createComboBox({ modelValue: 'one' }) + clearSelection() + expect(emit).toHaveBeenCalledWith('update:modelValue', null) + }) + }) + + describe('selection - multiple', () => { + it('select appends to existing values', () => { + const { select, emit } = createComboBox({ + multiple: true, + modelValue: ['one'], + }) + select('two') + expect(emit).toHaveBeenCalledWith('update:modelValue', ['one', 'two']) + }) + + it('deselect removes from existing values', () => { + const { deselect, emit } = createComboBox({ + multiple: true, + modelValue: ['one', 'two'], + }) + deselect('one') + expect(emit).toHaveBeenCalledWith('option:deselecting', 'one') + expect(emit).toHaveBeenCalledWith('update:modelValue', ['two']) + expect(emit).toHaveBeenCalledWith('option:deselected', 'one') + }) + + it('clearSelection resets to empty array', () => { + const { clearSelection, emit } = createComboBox({ + multiple: true, + modelValue: ['one', 'two'], + }) + clearSelection() + expect(emit).toHaveBeenCalledWith('update:modelValue', []) + }) + }) + + describe('option helpers', () => { + it('getOptionLabel returns the option for strings', () => { + const { getOptionLabel } = createComboBox() + expect(getOptionLabel('hello')).toBe('hello') + }) + + it('getOptionLabel returns option[label] for objects', () => { + const { getOptionLabel } = createComboBox({ label: 'name' }) + expect(getOptionLabel({ name: 'Canada' })).toBe('Canada') + }) + + it('getOptionKey returns JSON.stringify by default', () => { + const { getOptionKey } = createComboBox() + expect(getOptionKey('hello')).toBe(JSON.stringify('hello')) + }) + + it('getOptionKey returns option.id if present', () => { + const { getOptionKey } = createComboBox() + expect(getOptionKey({ id: 42, label: 'x' })).toBe('42') + }) + + it('isOptionSelected returns true for selected options', () => { + const { isOptionSelected } = createComboBox({ modelValue: 'one' }) + expect(isOptionSelected('one')).toBe(true) + expect(isOptionSelected('two')).toBe(false) + }) + + it('isOptionSelectable defaults to true', () => { + const { isOptionSelectable } = createComboBox() + expect(isOptionSelectable('one')).toBe(true) + }) + + it('isOptionSelectable respects selectable prop', () => { + const { isOptionSelectable } = createComboBox({ + selectable: (opt: string) => opt !== 'two', + }) + expect(isOptionSelectable('one')).toBe(true) + expect(isOptionSelectable('two')).toBe(false) + }) + }) + + describe('filtering', () => { + it('filteredOptions returns all options when search is empty', () => { + const { filteredOptions } = createComboBox({ options: ['one', 'two', 'three'] }) + expect(filteredOptions.value).toEqual(['one', 'two', 'three']) + }) + + it('filteredOptions filters by search text (case-insensitive)', () => { + const { filteredOptions, setSearch } = createComboBox({ + options: ['One', 'Two', 'Three'], + }) + setSearch('tw') + expect(filteredOptions.value).toEqual(['Two']) + }) + + it('does not filter when filterable is false', () => { + const { filteredOptions, setSearch } = createComboBox({ + options: ['one', 'two', 'three'], + filterable: false, + }) + setSearch('tw') + expect(filteredOptions.value).toEqual(['one', 'two', 'three']) + }) + + it('uses custom filter function when provided', () => { + const { filteredOptions, setSearch } = createComboBox({ + options: ['one', 'two', 'three'], + filter: (opts: string[], search: string) => opts.filter((o) => o === search), + }) + setSearch('two') + expect(filteredOptions.value).toEqual(['two']) + }) + + it('uses custom filterBy when provided', () => { + const { filteredOptions, setSearch } = createComboBox({ + options: [{ label: 'one', code: '1' }, { label: 'two', code: '2' }], + label: 'label', + filterBy: (option: OptionValue, _label: string, search: string) => (option as Record).code === search, + }) + setSearch('2') + expect(filteredOptions.value).toEqual([{ label: 'two', code: '2' }]) + }) + + it('filteredOptions includes taggable option when taggable and search has no match', () => { + const { filteredOptions, setSearch } = createComboBox({ + options: ['one', 'two'], + taggable: true, + }) + setSearch('new-tag') + expect(filteredOptions.value).toContain('new-tag') + }) + + it('filteredOptions does not include duplicate tag when option already exists', () => { + const { filteredOptions, setSearch } = createComboBox({ + options: ['one', 'two'], + taggable: true, + }) + setSearch('one') + const count = filteredOptions.value.filter((o) => o === 'one').length + expect(count).toBe(1) + }) + }) + + describe('typeAheadPointer', () => { + it('starts at -1', () => { + const { typeAheadPointer } = createComboBox() + expect(typeAheadPointer.value).toBe(-1) + }) + + it('typeAheadDown moves to the next selectable option', () => { + const { typeAheadPointer, typeAheadDown } = createComboBox({ + options: ['one', 'two', 'three'], + }) + typeAheadDown() + expect(typeAheadPointer.value).toBe(0) + typeAheadDown() + expect(typeAheadPointer.value).toBe(1) + }) + + it('typeAheadDown wraps to beginning', () => { + const { typeAheadPointer, typeAheadDown } = createComboBox({ + options: ['one', 'two'], + }) + typeAheadDown() // 0 + typeAheadDown() // 1 + typeAheadDown() // wraps to 0 + expect(typeAheadPointer.value).toBe(0) + }) + + it('typeAheadDown skips non-selectable options', () => { + const { typeAheadPointer, typeAheadDown } = createComboBox({ + options: ['one', 'two', 'three'], + selectable: (opt: string) => opt !== 'two', + }) + typeAheadDown() // 0 (one) + typeAheadDown() // skips 1 (two), lands on 2 (three) + expect(typeAheadPointer.value).toBe(2) + }) + + it('typeAheadUp moves to the previous selectable option', () => { + const { typeAheadPointer, typeAheadDown, typeAheadUp } = createComboBox({ + options: ['one', 'two', 'three'], + }) + typeAheadDown() // 0 + typeAheadDown() // 1 + typeAheadUp() // 0 + expect(typeAheadPointer.value).toBe(0) + }) + + it('typeAheadUp wraps to end', () => { + const { typeAheadPointer, typeAheadUp } = createComboBox({ + options: ['one', 'two', 'three'], + }) + typeAheadUp() // wraps to 2 + expect(typeAheadPointer.value).toBe(2) + }) + + it('typeAheadSelect selects the highlighted option', () => { + const { typeAheadDown, typeAheadSelect, emit } = createComboBox({ + options: ['one', 'two'], + }) + typeAheadDown() // highlight 'one' + typeAheadSelect() + expect(emit).toHaveBeenCalledWith('update:modelValue', 'one') + }) + + it('typeAheadSelect does nothing when pointer is -1', () => { + const { typeAheadSelect, emit } = createComboBox({ + options: ['one', 'two'], + }) + typeAheadSelect() + expect(emit).not.toHaveBeenCalledWith('update:modelValue', expect.anything()) + }) + + it('isOptionHighlighted returns true for the highlighted index', () => { + const { typeAheadDown, isOptionHighlighted } = createComboBox({ + options: ['one', 'two'], + }) + typeAheadDown() + expect(isOptionHighlighted(0)).toBe(true) + expect(isOptionHighlighted(1)).toBe(false) + }) + }) + + describe('reduce', () => { + it('emits the reduced value on select', () => { + const { select, emit } = createComboBox({ + options: [{ label: 'Canada', code: 'CA' }], + reduce: (opt: OptionValue) => (opt as Record).code, + }) + select({ label: 'Canada', code: 'CA' }) + expect(emit).toHaveBeenCalledWith('update:modelValue', 'CA') + }) + + it('selectedValue still contains the full option objects', () => { + const { selectedValue } = createComboBox({ + options: [{ label: 'Canada', code: 'CA' }, { label: 'USA', code: 'US' }], + reduce: (opt: OptionValue) => (opt as Record).code, + modelValue: 'CA', + }) + expect(selectedValue.value).toEqual([{ label: 'Canada', code: 'CA' }]) + }) + + it('handles reduced multi-select values', () => { + const { selectedValue } = createComboBox({ + options: [{ label: 'Canada', code: 'CA' }, { label: 'USA', code: 'US' }], + reduce: (opt: OptionValue) => (opt as Record).code, + multiple: true, + modelValue: ['CA', 'US'], + }) + expect(selectedValue.value).toEqual([ + { label: 'Canada', code: 'CA' }, + { label: 'USA', code: 'US' }, + ]) + }) + + it('isOptionSelected works with reduced values', () => { + const { isOptionSelected } = createComboBox({ + options: [{ label: 'Canada', code: 'CA' }], + reduce: (opt: OptionValue) => (opt as Record).code, + modelValue: 'CA', + }) + expect(isOptionSelected({ label: 'Canada', code: 'CA' })).toBe(true) + }) + + it('deselect works with reduced values in multi mode', () => { + const { deselect, emit } = createComboBox({ + options: [{ label: 'Canada', code: 'CA' }, { label: 'USA', code: 'US' }], + reduce: (opt: OptionValue) => (opt as Record).code, + multiple: true, + modelValue: ['CA', 'US'], + }) + deselect({ label: 'Canada', code: 'CA' }) + expect(emit).toHaveBeenCalledWith('update:modelValue', ['US']) + }) + + it('clearSelection emits null (single) or empty array (multi) with reduce', () => { + const { clearSelection, emit } = createComboBox({ + options: [{ label: 'Canada', code: 'CA' }], + reduce: (opt: OptionValue) => (opt as Record).code, + modelValue: 'CA', + }) + clearSelection() + expect(emit).toHaveBeenCalledWith('update:modelValue', null) + }) + }) + + describe('tagging', () => { + it('select creates a new option from search text when taggable', () => { + const { select, setSearch, emit } = createComboBox({ + options: ['one', 'two'], + taggable: true, + }) + setSearch('new-tag') + select('new-tag') + expect(emit).toHaveBeenCalledWith('option:created', 'new-tag') + expect(emit).toHaveBeenCalledWith('update:modelValue', 'new-tag') + }) + + it('pushTags adds created tag to optionList', () => { + const { select, setSearch, optionList } = createComboBox({ + options: ['one', 'two'], + taggable: true, + pushTags: true, + }) + setSearch('new-tag') + select('new-tag') + expect(optionList.value).toContain('new-tag') + }) + + it('uses custom createOption when provided', () => { + const { select, setSearch, emit } = createComboBox({ + options: [], + taggable: true, + label: 'label', + createOption: (search: string) => ({ label: search, custom: true }), + }) + setSearch('new') + select({ label: 'new', custom: true }) + expect(emit).toHaveBeenCalledWith('option:created', { label: 'new', custom: true }) + }) + }) + + describe('loading', () => { + it('isLoading reflects the loading prop', () => { + const { isLoading } = createComboBox({ loading: true }) + expect(isLoading.value).toBe(true) + }) + + it('toggleLoading flips the internal loading state', () => { + const { isLoading, toggleLoading } = createComboBox() + expect(isLoading.value).toBe(false) + toggleLoading(true) + expect(isLoading.value).toBe(true) + }) + + it('emits search event when search changes', () => { + const { setSearch, emit } = createComboBox() + setSearch('hello') + expect(emit).toHaveBeenCalledWith('search', 'hello', expect.any(Function)) + }) + }) + +}) diff --git a/vite.config.ts b/vite.config.ts index 4b2a72853..b213717a6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -18,7 +18,7 @@ export default defineConfig({ build: { target: 'es2015', lib: { - entry: resolve(__dirname, 'src/index.js'), + entry: resolve(__dirname, 'src/index.ts'), name: 'vue-select', fileName: (format) => `vue-select.${format}.js`, },