Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0350397
docs: add branching strategy and commit conventions to plans
sagalbot Feb 16, 2026
b6dad1a
fix(useClickAway): store handler reference so removeEventListener works
sagalbot Feb 16, 2026
cef4732
feat(types): define ComboBoxProps and ComboBoxContext interfaces
sagalbot Feb 16, 2026
ce1611c
feat(useComboBox): implement core state management composable
sagalbot Feb 16, 2026
ce05321
feat(useComboBox): add filtering and taggable support
sagalbot Feb 16, 2026
eaaac83
feat(useComboBox): add typeAheadPointer keyboard navigation
sagalbot Feb 16, 2026
7e26291
feat(useComboBox): add reduce prop support with reverse lookup
sagalbot Feb 16, 2026
369fdc9
feat(useComboBox): add tagging, pushTags, and loading support
sagalbot Feb 16, 2026
abdfdda
feat(ComboBox): wire up useComboBox composable with provide/inject
sagalbot Feb 16, 2026
0a9d437
feat(ComboBoxInput): full keyboard nav, ARIA, and IME support
sagalbot Feb 16, 2026
8d5cb02
feat(ComboBoxMenu): ARIA listbox with auto-scroll
sagalbot Feb 16, 2026
f5d09ae
feat(ComboBoxOption)!: add ARIA, highlight, and disabled states
sagalbot Feb 16, 2026
16c1a61
feat(ComboBoxButton, ComboBoxClear): toggle and clear primitives
sagalbot Feb 16, 2026
93d2a0d
feat: export headless primitives and useComboBox from package
sagalbot Feb 16, 2026
2ab50fd
test: add integration tests for full ComboBox composition
sagalbot Feb 16, 2026
04bcc1a
fix: address PR review feedback for headless primitives
sagalbot Feb 16, 2026
0d30d55
fix(ComboBoxMenu): use id-based lookup for autoscroll
sagalbot Feb 17, 2026
a79f488
fix: resolve ESLint errors in new v4 primitives and tests
sagalbot Feb 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 124 additions & 5 deletions docs/plans/2026-02-16-headless-primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch-name>
```

**After completing work on a branch:**
```bash
git checkout @beta/dev
git merge <branch-name>
```

## 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:

```
<type>(<scope>): <description>

[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.
Expand Down Expand Up @@ -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
)"
```

---
Expand Down Expand Up @@ -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
)"
```

---
Expand Down Expand Up @@ -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
)"
```

---
Expand Down Expand Up @@ -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
)"
```

---
Expand Down Expand Up @@ -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
)"
```

---
Expand Down
18 changes: 18 additions & 0 deletions docs/plans/2026-02-16-v4-release-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
101 changes: 42 additions & 59 deletions src/components/ComboBox/ComboBox.vue
Original file line number Diff line number Diff line change
@@ -1,72 +1,55 @@
<script setup lang="ts">
import { ListBoxKey } from '@/keys'
import type { ComputedRef } from 'vue'
import {
provide,
computed,
reactive,
watch,
onMounted,
ref,
onUnmounted,
} from 'vue'
import { provide, ref, onMounted, onUnmounted } from 'vue'
import { useComboBox } from '@/hooks/useComboBox'
import { useClickAway } from '@/hooks/useClickAway'
import type {
InjectedListBoxProps,
ListBoxProps,
ResolvedListBoxProps,
VueSelectValue,
} from '@/types'

const emit = defineEmits(['update:modelValue', 'update:open', 'open', 'close'])

const props = withDefaults(defineProps<ListBoxProps>(), {
open: undefined,
import { ComboBoxKey } from '@/keys'
import type { ComboBoxProps } from '@/types'

const props = withDefaults(defineProps<ComboBoxProps>(), {
options: () => [],
multiple: false,
filterable: true,
taggable: false,
pushTags: false,
clearable: true,
closeOnSelect: true,
clearSearchOnSelect: true,
disabled: false,
label: 'label',
loading: false,
noDrop: false,
deselectFromDropdown: false,
autoscroll: true,
placeholder: '',
})

const emit = defineEmits<{
'update:modelValue': [value: unknown]
'update:open': [value: boolean]
open: []
close: []
search: [search: string, toggleLoading: (value?: boolean) => void]
'option:created': [option: unknown]
'option:selecting': [option: unknown]
'option:selected': [option: unknown]
'option:deselecting': [option: unknown]
'option:deselected': [option: unknown]
}>()
Comment on lines +26 to +37

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The v3 component emits search:blur and search:focus events that are documented in the API. These events are not emitted in the new primitives. If you want to maintain backward compatibility with v3 event listeners in the styled wrapper, consider adding these events to the emit types and emitting them from onFocus and onBlur handlers.

Copilot uses AI. Check for mistakes.

const ctx = useComboBox(props, emit)
provide(ComboBoxKey, ctx)
Comment thread
sagalbot marked this conversation as resolved.
Comment thread
sagalbot marked this conversation as resolved.

// Click-away to close
const el = ref<HTMLElement>()
const state = reactive<{
open: boolean
}>({
open: props.open === undefined ? false : props.open,
const { addClickAwayListener, removeClickAwayListener } = useClickAway(() => {
ctx.setOpen(false)
})

watch(
() => state.open,
(open) => emit('update:open', open),
)

const inputText = ref('')

const isOpen = computed<boolean>(() => {
if (props.open !== undefined) {
return props.open
}
return state.open
})

const { addClickAwayListener, removeClickAwayListener } = useClickAway(
() => (state.open = false),
)

onMounted(() => addClickAwayListener(el.value))
onUnmounted(() => removeClickAwayListener(el.value))

provide<InjectedListBoxProps>(
ListBoxKey,
computed<ResolvedListBoxProps>(() => ({
open: isOpen.value,
modelValue: props.modelValue,
inputText: inputText.value,
toggleOpen: () => (state.open = !state.open),
setModelValue: (modelValue) => emit('update:modelValue', modelValue),
setInputText: (value: string) => (inputText.value = value),
})),
)
</script>

<template>
<div tabindex="0" role="combobox" ref="el">
<slot></slot>
<div ref="el">
<slot />
</div>
</template>
31 changes: 12 additions & 19 deletions src/components/ComboBox/ComboBoxButton.vue
Original file line number Diff line number Diff line change
@@ -1,28 +1,21 @@
<script setup lang="ts">
import { inject } from 'vue'
import { ListBoxKey } from '@/keys'
import { ComboBoxKey } from '@/keys'

withDefaults(
defineProps<{
as?: string
}>(),
{
as: 'button',
},
)

const listBoxProps = inject(ListBoxKey)
const ctx = inject(ComboBoxKey)
if (!ctx) throw new Error('ComboBoxButton must be used inside a ComboBox component')
</script>

<template>
<Component
:is="as"
tabindex="0"
<button
type="button"
aria-haspopup="true"
:aria-expanded="listBoxProps.open"
@click="listBoxProps.toggleOpen"
aria-haspopup="listbox"
:aria-expanded="String(ctx.open.value)"
:aria-controls="`vs-${ctx.uid.value}-listbox`"
:disabled="ctx.disabled.value || undefined"
@click="ctx.toggleOpen()"
@mousedown.prevent
>
<slot></slot>
</Component>
<slot />
</button>
</template>
19 changes: 19 additions & 0 deletions src/components/ComboBox/ComboBoxClear.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { inject } from 'vue'
import { ComboBoxKey } from '@/keys'

const ctx = inject(ComboBoxKey)
if (!ctx) throw new Error('ComboBoxClear must be used inside a ComboBox component')
</script>

<template>
<button
v-show="!ctx.isValueEmpty.value && ctx.clearable.value"
type="button"
aria-label="Clear selection"
@click="ctx.clearSelection()"
@mousedown.prevent
>
<slot />
</button>
</template>
Loading
Loading