fix(primitives): eslint/tsconfig migration, asChild refactor, type fixes

- Migrate to eslint flat config + composite tsconfig.
- Complete the asChild→as="template" refactor (remove asChild prop + :as-child
  bindings across components, matching Primitive's slot model).
- Fix test type errors and source type-safety (useGraceArea hull/point math,
  FocusScope/util ref typing).

Note: ~53 vue-tsc errors remain (HTML attr/event passthrough typing on
transparent wrapper components + a couple of duplicate-export naming
collisions) — not gated by CI (build/lint/test green); pending a
component-attribute-typing design decision.
This commit is contained in:
2026-06-07 16:29:56 +07:00
parent c7644ade69
commit 626fbc70d8
408 changed files with 27367 additions and 154 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+447
View File
@@ -0,0 +1,447 @@
# AGENTS.md — UI Primitives
Руководство по работе с UI-пакетом примитивов. Описывает правила создания **новых компонентов** и **доработки существующих**. Применимо только к этому пакету (`vue/primitives/`).
> Прочитай этот файл **до** того, как трогать код в `src/`.
---
## 0. Tooling и команды
- **Менеджер пакетов:** `pnpm` (workspaces + catalogs).
- **Линтер и форматтер:** `eslint` (flat config, пресеты `@robonen/eslint`). Prettier/Biome не использовать.
- **Сборка:** `tsdown`.
- **Тесты:** `vitest` (jsdom + browser mode на `@vitest/browser-playwright` + Chromium).
```bash
pnpm exec vitest run # jsdom-сьют
pnpm exec vitest run src/<name> # один компонент
pnpm run test:browser # browser mode (Playwright)
pnpm exec tsdown # сборка ESM + CJS + .d.ts
pnpm lint:check / pnpm lint:fix # eslint
```
---
## 1. Структура пакета
```
src/
<component>/ # kebab-case
<Component>Root.vue # PascalCase, корневой провайдер контекста
<Component><Part>.vue # части (Trigger / Content / Item / Indicator…)
context.ts # фабрика + типы контекста
index.ts # барель
__test__/
<Component>.test.ts # jsdom-тесты
<Component>.browser.test.ts # опционально, если нужен реальный браузер
utils/ # общие хелперы (roving-focus, getRawChildren …)
primitive/ # polymorphic <Primitive :as="…">
index.ts # реэкспорт всех компонентов
```
**Правила структуры:**
- Каждый примитив = отдельная папка. Никаких «всё в одном файле».
- Имя папки — `kebab-case`, файлы — `PascalCase.vue`.
- Корневой компонент **обязан** называться `<Name>Root.vue` и быть провайдером контекста.
- Никаких циклических зависимостей между примитивами. Общий код — в `src/utils/`.
- `index.ts` примитива экспортирует **все** компоненты + контекст-хук + типы.
- После создания примитива добавь `export * from './<name>';` в `src/index.ts`.
---
## 2. Нейминг и data-атрибуты
В коде, комментариях, доках, тестах и коммитах **не должно быть имён сторонних UI-библиотек** и внутренних кодовых названий бренда/форка. Описывай компоненты через их роль (`dialog`, `radio-group`, `spinbutton`) и паттерн (`roving-focus`, `dismissable-layer`).
| Артефакт | Формат |
|---|---|
| Имя контекста | `'<component>'` — аргумент в `useContextFactory('<component>')` |
| ID-префикс | `'<component>-<part>'` — аргумент в `useId(undefined, '<component>-<part>')` |
| Data-атрибуты состояния | `data-state`, `data-disabled`, `data-orientation`, `data-side`, `data-align` |
`data-state` — каноническое отражение состояния:
- toggle/checkbox: `"checked" | "unchecked" | "indeterminate"`
- collapsible/dialog/popover: `"open" | "closed"`
- progress: `"complete" | "loading" | "indeterminate"`
Любая интерактивная часть с `disabled` получает `data-disabled=""` (без значения), чтобы CSS-селектор `[data-disabled]` работал одинаково.
---
## 3. Архитектурные паттерны
### 3.1. Контекст — только через фабрику
**Ручные `Symbol`, `InjectionKey`, `provide()`/`inject()` в коде примитивов запрещены.** Контекст создаётся **только** через `useContextFactory` из тулкита. Фабрика:
- единообразно генерирует ключ (`Symbol(name)` внутри неё),
- типизирует `provide` и `inject` одним generic-аргументом,
- бросает консистентную ошибку, если `inject` делается без предка-провайдера,
- поддерживает `appProvide(app)` для глобальных провайдеров (config, dir, и т.п.).
```ts
// context.ts
import type { ComputedRef, Ref } from 'vue';
import { useContextFactory } from '<toolkit>';
export interface FooContext {
open: Ref<boolean>;
disabled: ComputedRef<boolean>;
onToggle: () => void;
}
export const {
inject: useFooContext,
provide: provideFooContext,
} = useContextFactory<FooContext>('foo');
```
```ts
// FooRoot.vue
provideFooContext({ open, disabled, onToggle });
// FooTrigger.vue
const ctx = useFooContext(); // кидает, если нет <FooRoot>
const ctx = useFooContext(fallback); // опционально: дефолт без ошибки
```
**Правила:**
- Никаких ручных `Symbol(...)` / `InjectionKey<...>` в папке примитива.
- Имя фабрики описательное (`'foo'`, `'foo-item'`, `'dialog'`), без префиксов брендов.
- Контекст хранит **`Ref` / `ComputedRef`** для реактивных полей и **функции** для действий. Не передавай голые значения — потеряешь реактивность у потребителей.
- Для per-item данных (например, `RadioGroupIndicator` смотрит на свой `RadioGroupItem`) создавай **item sub-context** отдельной фабрикой (`useContextFactory<ItemCtx>('foo-item')`). Не ходи по DOM ради `data-*`.
- Контекст-хук и типы экспортируются из `index.ts` примитива.
### 3.2. v-model: продвинутая работа с `defineModel`
`defineModel` — первичный инструмент для любой двусторонней связи со state. Используем его продвинутые возможности.
#### 3.2.1. Контролируемый + неконтролируемый режим через get/set
Вместо ручного `ref + computed`-обёртки используй **`defineModel({ get, set })`** — встроенный способ навесить преобразование/нормализацию прямо на модель:
```ts
interface Props {
defaultOpen?: boolean;
}
const { defaultOpen = false } = defineProps<Props>();
const local = ref<boolean>(defaultOpen);
const open = defineModel<boolean>('open', {
// когда родитель не связал v-model — читаем локальный стейт
get: (external) => external ?? local.value,
// пишем и наружу (emit), и в локальный стейт
set: (value) => {
local.value = value;
return value;
},
});
```
- Если `v-model` не привязан снаружи — `defineModel` эмитит `update:open` в пустоту, а локальный ref обеспечивает работу компонента. Это и есть «uncontrolled» режим.
- Если привязан — родитель writable source of truth, локальный ref просто зеркалит его через `get`.
- Никаких отдельных `computed({ get, set })` поверх `defineModel` — всё делается в одном месте.
#### 3.2.2. Модификаторы v-model
Поддерживай пользовательские модификаторы, если это осмысленно для примитива (`trim`, `lazy`, `number`, кастомные):
```ts
const [model, modifiers] = defineModel<string>({
set: (value) => (modifiers.trim ? value.trim() : value),
});
```
Любой кастомный модификатор документируется в README примитива.
#### 3.2.3. Нормализация union-значений
Для union-типов (`string | string[]`, `number | null`, …) ставь преобразование в `set`, а наружу эмить нормализованный вид. Если Vue не может вывести тип модели или теряет записи — **fallback: explicit `modelValue` prop + `emit('update:modelValue', …)` + `watch(() => props.modelValue, …)`**:
```ts
interface Props { modelValue?: string | string[] }
const props = withDefaults(defineProps<Props>(), { modelValue: undefined });
const emit = defineEmits<{ 'update:modelValue': [v: string[] | string | undefined] }>();
const local = shallowRef<string[]>(normalize(props.modelValue));
watch(() => props.modelValue, (v) => { local.value = normalize(v); });
function commit(next: string[]) {
local.value = next;
emit('update:modelValue', serialize(next));
}
```
Это обход, а не правило — **сначала попробуй `defineModel({ get, set })`**.
### 3.3. Продвинутая реактивность
Используй реактивность по полной — она бесплатная и устраняет ручной оркестр watch-ей.
| Задача | Инструмент |
|---|---|
| Ссылка на элемент шаблона | `useTemplateRef('name')` (Vue 3.5+), не `ref<HTMLElement>()` |
| Развернуть `MaybeRef<T>` | `toValue(source)` — работает и с функциями, и с refs |
| Реактивное значение из пропса | `toRef(() => props.foo)` (getter-форма, не строковая) |
| Большой список/мап, не нужна глубокая реактивность | `shallowRef` / `shallowReactive` |
| Гонки с внешним state | `watchSyncEffect` / `watch(..., { flush: 'sync' })` (осторожно) |
| «Один раз при создании» | `computed` с кэшированием, **не** вызов функции в template |
| Escape-hatch от трекинга | `markRaw`, `readonly`, `customRef` (если реально нужен дебаунс/throttle) |
| Очистка эффектов у регистров | `effectScope` + `onScopeDispose`, не `onBeforeUnmount` в циклах |
| DOM-измерения при ресайзе/скролле | `useResizeObserver`, `useEventListener`, `useMutationObserver` |
Типовые практики:
- **Props через getter.** `watch(() => props.value, …)` и `toRef(() => props.value)` — не деструктурируй пропсы в локальные переменные (сломаешь реактивность).
- **Computed для derived state.** Любой `data-state` / `aria-*` / классовая строка — `computed`, не вычисление в `<template>`.
- **Effect scope для регистров.** Если корень хранит массив дочерних элементов (`items: Ref<HTMLElement[]>`), регистрация/снятие идёт через пару `register(el) + onScopeDispose(() => unregister(el))` в child — scope снимет подписку автоматически при размонтировании ветки.
- **`watch` с `immediate: true`** — когда sync-реакция нужна сразу; иначе первая синхронизация делается отдельно.
- **`watchEffect` vs `watch`.** `watchEffect` — для реактивных деревьев без явного источника; `watch` — когда важна старая-новая пара и источник известен.
- **`shallowRef` для ссылок на массивы/объекты**, которые меняются целиком (replace), — меньше лишних триггеров.
### 3.4. Roving focus
Для коллекций фокусируемых элементов (Toolbar, RadioGroup, ToggleGroup, NavigationMenu …) используй `src/utils/roving-focus.ts`:
- `rovingKeyToAction(event, { orientation, dir, loop })``{ delta, absolute? } | null`
- `resolveNextIndex(current, delta, count, loop)` — wrap-or-clamp
Items сами регистрируются в Root через item sub-context (`useContextFactory`). Root хранит `Ref<HTMLElement[]>` и `activeIndex`. Фильтрация disabled:
```ts
const enabled = items.value.filter(x => !x.hasAttribute('data-disabled'));
```
`tabindex`: активный/выбранный = `0`, остальные = `-1`.
### 3.5. ID и ARIA
- ID генерируй через `useId(undefined, '<component>-<part>')` из тулкита. Один ID — один reactive ref.
- Роли — по WAI-ARIA APG (`role="dialog"`, `role="radiogroup"`, `role="spinbutton"`, …).
- `Title` / `Description` регистрируют свой ID в контексте Root; Root прокидывает их в `aria-labelledby` / `aria-describedby` контента.
### 3.6. Slots, polymorphism, forwarding
- Корень обычно не рендерит DOM, кроме `<slot>` со scope-параметрами:
```vue
<template><slot :open="open" :close="() => open = false" /></template>
```
- **`Primitive` из `src/primitive/`** — базовый polymorphic-рендерер. Любая часть, рендерящая DOM, обязана прокидывать `as` через `Primitive`, а не рендерить тег напрямую. `as="template"` включает режим slot-merging (merged props + один дочерний элемент из слота — аналог `asChild`).
- **Доступ к элементу + проброс ref** — `useForwardExpose` из тулкита. Он даёт `{ forwardRef, currentElement }`: `forwardRef` привязывается к `Primitive` (`:ref="forwardRef"`), `currentElement` — реактивный `Ref<HTMLElement>`, работает даже при `as="template"` и условных рендерах (`v-if`/`v-else`).
- **`inheritAttrs: false`** на Root (через `defineOptions`), если Root не рендерит DOM — attrs не должны утекать на `<slot>`.
- **Минимум DOM:** не оборачивай контент лишними `<div>`-обёртками.
### 3.7. Утилиты — выноси, а не копируй
Паттерн повторяется в ≥2 примитивах → в `src/utils/`. Уже доступно:
- `roving-focus.ts` — клавиатурная навигация по коллекции
- `getRawChildren.ts` — VNode walk для slotted children
Не дублируй helper'ы внутри папок примитивов.
### 3.8. Composition-слои внутри одного примитива
Сложные примитивы (Dialog / Popover / Select) разбиваются на слои композиции, а не ветвятся внутри одного компонента.
- **`<Name>Content.vue`** — публичный wrapper: решает, какой внутренний импл рендерить на основе `modal`/`nonModal`/`forceMount`.
- **`<Name>ContentImpl.vue`** — общая часть (focus scope, dismiss layer, ариа-связки).
- **`<Name>ContentModal.vue` / `<Name>ContentNonModal.vue`** — ветки, отличающиеся только scroll-lock / `aria-hidden` соседей / трап фокуса.
- **`<Name>Overlay.vue` + `<Name>OverlayImpl.vue`** — такая же пара, если overlay нужен только в модальном режиме.
Результат: каждый файл плоский, без `v-if`-деревьев веток по режиму, и легко тестируется по отдельности.
### 3.9. Ordered collection вместо ручного реестра
Когда порядок элементов важен (roving focus, typeahead, indexOf), **не держи ручной `items: Ref<HTMLElement[]>` с push/splice** — порядок ломается при перепаковке детей. Используй паттерн:
1. Каждый item выставляет дата-атрибут (например `data-collection-item=""`).
2. `itemMap: Ref<Map<HTMLElement, ItemData>>` в контексте — регистрация через `watchEffect(cleanup)` в child:
```ts
watchEffect((onCleanup) => {
const el = currentElement.value
if (!el) return
const key = markRaw(el)
ctx.itemMap.value.set(key, { ref: el, value: props.value })
onCleanup(() => ctx.itemMap.value.delete(key))
})
```
3. `getItems()` считывает текущий DOM-порядок через `querySelectorAll('[data-collection-item]')` и сортирует значения мапы по `indexOf`.
4. `markRaw(el)` обязательно — иначе Vue сделает DOM-узел реактивным и сломает производительность.
### 3.10. Presence и анимации выхода
Для любого примитива, у которого часть может иметь exit-анимацию (Dialog.Content, Collapsible.Content, Popover.Content, …) рендер идёт через `Presence` из `src/presence/`:
- `<Presence :present="open">` монтирует слот при `present=true` и удерживает его, пока CSS-анимация не завершилась после `present=false`.
- Пропс `forceMount` — обязательный escape-hatch, для случаев, когда пользователь хочет держать DOM в дереве принудительно (например, для measurement).
- Императивный малый FSM для состояний `mounted` / `unmountSuspended` / `unmounted` — через `useStateMachine` (если добавим) или локально, но кратко и декларативно.
### 3.11. Single-or-multiple абстракция
Компоненты типа `Accordion` / `ToggleGroup` / `Combobox`, поддерживающие оба режима, делают это **через единый хелпер**, а не двумя ветками. Правила:
- Тип выводится из фактического `modelValue`/`defaultValue` (`Array` → multiple, иначе single).
- Явный `type="single" | "multiple"` перекрывает вывод, но если значение конфликтует — логируем warning и следуем типу значения.
- Единая функция `change(v)`: в single — переключает/сбрасывает; в multiple — добавляет/удаляет из массива.
- Сравнение значений — через deep equality (`ohash.isEqual` или аналог), а не `===`, иначе объектные значения не будут тоглиться.
### 3.12. Shared composables для глобальных стеков
Состояния уровня документа (scroll-lock, focus-scope stack, dismissable-layer stack, aria-hide других узлов) должны быть **одним экземпляром на приложение**, иначе два Dialog'а записывают `overflow: hidden` поверх друг друга и теряют исходное значение.
- Используй `createSharedComposable` (VueUse) для таких хелперов.
- Храни внутри counter / Map по ID лайера — фактический lock/hide включается, пока хотя бы один потребитель активен.
- Снимай побочные эффекты на эффект-скоупе последнего подписчика, не в `onBeforeUnmount` каждой копии.
- SSR: все такие композаблы guards'ят на `isClient` / `typeof document !== 'undefined'`.
### 3.13. ConfigProvider и глобальные настройки
Всё, что логично задавать раз на дерево (направление `dir`, кастомный `useId`, `nonce` для CSP, `scrollBody` конфиг), живёт в `ConfigProvider`. Правила:
- Примитив читает такие настройки через `inject` с **фолбэком-дефолтом** (`inject(fallback)`), а не бросает, если provider не стоит.
- Личный проп на Root перекрывает глобальную настройку (локальный `dir` на Popover > глобальный `dir`).
- `useId` из тулкита уже учитывает `ConfigProvider.useId` — не дублируй логику внутри компонента.
### 3.14. Focus и dismiss — всегда через примитивы, не ручной код
- **`FocusScope`** — любой modal/popover/menu оборачивает `Content` в `FocusScope` с `trapped` и/или `loop`. Ручный trap-код в компоненте запрещён.
- **`DismissableLayer`** — все escape/pointer-outside/focus-outside диспетчеры идут через этот компонент и его stack — иначе вложенный Popover закроет оба уровня по Esc.
- **`FocusGuards` / visually-hidden sentinel'ы** — в краях portaled-контента, чтобы tab не убегал в URL-бар браузера.
- **`useHideOthers`** (aria-hidden соседних деревьев) — для modal-Content, чтобы screen reader не уходил за пределы.
---
## 4. Стиль кода (eslint + @stylistic)
- Всегда `<script setup lang="ts">`. Composition API. Options API запрещён.
- Отдельный `<script lang="ts">` блок допускается **только** для экспорта типов/интерфейсов пропсов наружу.
- Импорты типов — через `import type { … }`.
- Не пиши docstring/комментарии к коду, который не меняешь. Не добавляй type-аннотации сверх необходимого.
- Не добавляй ненужный error handling «на всякий случай» — валидируй только на границах (props, входные DOM events).
- Без default exports в `.ts`. В `.vue` — только дефолтный экспорт компонента (через `<script setup>`).
После любых правок: `pnpm lint:fix`.
---
## 5. Тесты
### 5.1. Структура
- jsdom-тесты: `src/<component>/__test__/<Component>.test.ts`.
- `@vue/test-utils` (`mount`) + `vitest`.
- Требуется реальный focus-менеджмент / pointer events / scroll-lock / `inert` / IntersectionObserver → добавь browser-сьют.
### 5.2. Минимум сценариев для нового примитива
1. Корректный ARIA-каркас (`role`, `aria-*`, `data-state`).
2. Контролируемый режим (`v-model` обновляется → DOM реагирует).
3. Неконтролируемый режим (`default*` пропс задаёт начальное состояние).
4. Все интерактивные действия (click / keyboard).
5. Клавиатура: Enter/Space/Arrow*/Home/End/Esc — в зависимости от паттерна.
6. `disabled` блокирует мутации.
7. Edge-cases: пустые массивы, null, экстремальные min/max.
8. Модификаторы v-model, если поддерживаются (`trim`, кастомные).
### 5.3. Хелперы и гочи jsdom
```ts
function press(el: Element, key: string) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
// jsdom: DataTransfer не определён — стабь clipboardData вручную:
const event = new Event('paste', { bubbles: true, cancelable: true }) as unknown as ClipboardEvent;
Object.defineProperty(event, 'clipboardData', { value: { getData: () => 'text' } });
```
- Всегда `attachTo: document.body`, иначе focus-тесты ломаются.
- После каждого теста `wrapper.unmount()`.
- `await nextTick()` после программных мутаций перед assert'ом.
- Не используй `vi.useFakeTimers()` без необходимости.
### 5.4. Browser mode
- Файл: `src/<component>/__test__/<Component>.browser.test.ts`.
- Запуск: `pnpm run test:browser`.
- Перед коммитом прогоняй оба сьюта, если затронут focus/inert/scroll.
---
## 6. Workflow для нового примитива
1. **Спецификация.** WAI-ARIA APG для паттерна — единственный источник истины по ARIA/клавиатуре.
2. **Скаффолдинг папки** (`<name>/context.ts`, `<Name>Root.vue`, парты, `__test__/`, `index.ts`).
3. **Контекст** через `useContextFactory` — сначала определи поля и действия, потом пиши компоненты.
4. **Root** — модель состояния (`defineModel` с `get/set` или fallback-паттерн), `provide*Context`.
5. **Parts** — каждая часть тонкая, без дублирующего state.
6. **Тесты** пиши параллельно с кодом, не «потом».
7. **Регистрация в `src/index.ts`.**
8. **Локальный прогон:** `pnpm exec vitest run src/<name>` → 0 fail.
9. **Полный прогон:** `pnpm exec vitest run` (+ `test:browser` при необходимости).
10. **Сборка:** `pnpm exec tsdown` — проверь, что bundle не вырос аномально и `.d.ts` валиден.
11. **Линт:** `pnpm lint:fix`.
12. **Обнови session-память** (`/memories/session/progress-log.md`), если идёт многошаговая работа.
---
## 7. Доработка существующего компонента
- **Не ломай публичный API без миграции.** Имена пропсов/событий/контекстных полей — semver'но значимы.
- **Не добавляй фичи «попутно».** Меняй только то, что просили.
- **Сначала прочитай тесты** — они описывают контракт.
- **Расширил поведение → добавь тест.** Любое новое условие/ветка покрывается.
- **Удалил поведение → удали тест** и упомяни в PR-описании.
- **Баг в shared-утилите** (`utils/roving-focus`, `utils/getRawChildren`) — фикси в утилите, а не локально.
- **Регрессии:** при правке логики прогоняй **весь** sweep пакета.
---
## 8. Что **не** делать
- ❌ Тянуть внешние UI-зависимости (Radix, Headless UI, Ark, floating-vue, …). Все примитивы — свои.
- ❌ Создавать `Symbol(...)` / `InjectionKey<...>` вручную в папке примитива — только `useContextFactory`.
- ❌ Вызывать `provide()` / `inject()` из `vue` напрямую — только через `useContextFactory`.
- ❌ Писать имена сторонних UI-библиотек и внутренние кодовые имена бренда/форка в коде/доках/тестах.
- ❌ Деструктурировать пропсы в переменные (теряется реактивность); используй getter-форму и `toRef(() => props.x)`.
- ❌ Дублировать стейт между `defineModel` и локальным ref — используй `get/set` на модели.
- ❌ Класть бизнес-логику в `<template>` — выноси в `computed` / функции.
- ❌ Полагаться на DOM-walk для чтения данных у соседей — используй context / sub-context.
- ❌ `pnpm install <pkg>` без согласования: новые рантайм-зависимости должны проходить через catalog.
- ❌ Создавать markdown-документацию по компоненту без запроса.
- ❌ `git push --force`, `git reset --hard`, удалять файлы без подтверждения.
- ❌ `--no-verify` и обход pre-commit хуков.
---
## 9. Memory-протокол для агентов
- Большая задача (≥3 шага) — план в `/memories/session/plan.md`.
- Этап завершён — прогресс в `/memories/session/progress-log.md`.
- Repo-факты, верифицированные на практике (команды, версии, гочи) — в `/memories/repo/`.
- Встретил гочу, которая проявится снова — добавь её в этот `AGENTS.md` (§3 или §5.3).
---
## 10. Чеклист перед PR
- [ ] Папка примитива оформлена по §1.
- [ ] Контекст только через `useContextFactory`, 0 ручных `Symbol` / `InjectionKey`.
- [ ] v-model через `defineModel({ get, set })` (или обоснованный fallback по §3.2.3).
- [ ] Реактивность: `toValue` / `toRef(() => props.x)` / `useTemplateRef` вместо ручных паттернов.
- [ ] Полиморфизм через `Primitive` + `useForwardExpose`, не ручные teg-свитчи.
- [ ] Для анимируемых частей (Content/Overlay) использован `Presence`.
- [ ] Modal/popover-примитивы завёрнуты в `FocusScope` + `DismissableLayer`, не ручные листенеры.
- [ ] Глобальные стеки (scroll-lock, hide-others, focus-stack) — через shared composables, 0 дублирующегося state.
- [ ] Roving-focus через `src/utils/roving-focus.ts`, если применимо.
- [ ] ARIA-роли и `data-state` — соответствуют APG.
- [ ] Тесты: минимум из §5.2, всё зелёное.
- [ ] Browser-сьют не сломан (если затронут focus/inert/scroll).
- [ ] `pnpm exec tsdown` собирается, `.d.ts` валиден.
- [ ] `pnpm lint:fix` без diff'а.
- [ ] Никаких имён сторонних UI-либ и внутренних кодовых имён бренда.
- [ ] `src/index.ts` обновлён.
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
import { base, compose, imports, stylistic, typescript, vue } from '@robonen/eslint';
export default compose(base, typescript, vue, imports, stylistic, {
name: 'primitives/overrides',
files: ['**/*.vue'],
rules: {
'@stylistic/no-multiple-empty-lines': 'off',
},
});
-13
View File
@@ -1,13 +0,0 @@
import { base, compose, imports, stylistic, typescript } from '@robonen/oxlint';
import { defineConfig } from 'oxlint';
export default defineConfig(compose(base, typescript, imports, stylistic, {
overrides: [
{
files: ['**/*.vue'],
rules: {
'@stylistic/no-multiple-empty-lines': 'off',
},
},
],
}));
+20 -6
View File
@@ -33,31 +33,45 @@
"types": "./dist/index.d.cts", "types": "./dist/index.d.cts",
"default": "./dist/index.cjs" "default": "./dist/index.cjs"
} }
},
"./*": {
"import": {
"types": "./dist/*/index.d.mts",
"default": "./dist/*/index.mjs"
},
"require": {
"types": "./dist/*/index.d.cts",
"default": "./dist/*/index.cjs"
}
} }
}, },
"scripts": { "scripts": {
"lint:check": "oxlint -c oxlint.config.ts", "lint:check": "eslint .",
"lint:fix": "oxlint -c oxlint.config.ts --fix", "lint:fix": "eslint . --fix",
"test": "vitest run", "test": "vitest run",
"bench": "vitest bench", "bench": "vitest bench",
"dev": "vitest dev", "dev": "vitest dev",
"build": "tsdown" "build": "tsdown"
}, },
"devDependencies": { "devDependencies": {
"@robonen/oxlint": "workspace:*", "@robonen/eslint": "workspace:*",
"@robonen/tsconfig": "workspace:*", "@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*", "@robonen/tsdown": "workspace:*",
"@stylistic/eslint-plugin": "catalog:", "@vitest/browser": "catalog:",
"@vitest/browser-playwright": "^4.0.18",
"@vue/test-utils": "catalog:", "@vue/test-utils": "catalog:",
"axe-core": "^4.11.1", "axe-core": "^4.11.1",
"oxlint": "catalog:", "eslint": "catalog:",
"playwright": "^1.48.0",
"tsdown": "catalog:", "tsdown": "catalog:",
"unplugin-vue": "^7.1.1", "unplugin-vue": "^7.1.1",
"vitest-browser-vue": "^1.0.0",
"vue-tsc": "^3.2.5" "vue-tsc": "^3.2.5"
}, },
"dependencies": { "dependencies": {
"@floating-ui/vue": "^1.1.11",
"@robonen/platform": "workspace:*", "@robonen/platform": "workspace:*",
"@robonen/stdlib": "^0.0.9", "@robonen/stdlib": "workspace:*",
"@robonen/vue": "workspace:*", "@robonen/vue": "workspace:*",
"@vue/shared": "catalog:", "@vue/shared": "catalog:",
"vue": "catalog:" "vue": "catalog:"
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
.vite
+32
View File
@@ -0,0 +1,32 @@
# @robonen/primitives playground
Minimal Vite + Vue 3 sandbox for inspecting and debugging primitives from
[`@robonen/primitives`](../). Imports source directly via the `@primitives/*`
alias, so HMR works while editing components in `vue/primitives/src/`.
## Usage
```sh
pnpm --filter @robonen/primitives-playground dev
```
Then open http://localhost:5180.
## Adding a demo
Drop a `.vue` file into `src/demos/`. It will be picked up automatically by the
sidebar (`import.meta.glob('./demos/*.vue')`) and addressable via the URL hash
(e.g. `#/Accordion`).
```vue
<script setup lang="ts">
import { AccordionRoot } from '@primitives/accordion';
// or: import { AccordionRoot } from '@primitives';
</script>
```
## Notes
- Imports resolve to `vue/primitives/src/` directly (not the built `dist/`), so
you can poke at primitives and see changes instantly.
- The playground is `private: true` and is not published.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@robonen/primitives — playground</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@robonen/primitives-playground",
"version": "0.0.0",
"private": true,
"license": "Apache-2.0",
"description": "Minimal playground for @robonen/primitives — eyeball, debug, hack on hypotheses",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@robonen/primitives": "workspace:*",
"vue": "catalog:",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@robonen/tsconfig": "workspace:*",
"@tailwindcss/vite": "^4.1.0",
"@vitejs/plugin-vue": "^6.0.6",
"tailwindcss": "^4.1.0",
"vite": "^7.1.9",
"vue-tsc": "^3.2.5"
}
}
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { demos } from './router';
const route = useRoute();
const query = ref('');
const filtered = computed(() => {
const q = query.value.trim().toLowerCase();
return q ? demos.filter(d => d.name.toLowerCase().includes(q)) : demos;
});
const currentDemoName = computed(() => {
const n = route.meta.demoName;
return typeof n === 'string' ? n : '';
});
</script>
<template>
<div class="grid h-screen grid-cols-[15rem_1fr]">
<aside class="flex min-h-0 flex-col border-r border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
<header class="border-b border-neutral-200 px-4 py-3.5 font-semibold dark:border-neutral-800">
<RouterLink to="/" class="no-underline">
@robonen/primitives
</RouterLink>
<small class="mt-0.5 block font-normal text-neutral-500 dark:text-neutral-400">playground</small>
</header>
<input
v-model="query"
type="search"
placeholder="Filter demos…"
aria-label="Filter demos"
class="mx-3 my-2.5 rounded-md border border-neutral-200 bg-neutral-50 px-2 py-1.5 outline-none focus:border-blue-600 dark:border-neutral-800 dark:bg-neutral-950 dark:focus:border-blue-400"
>
<nav class="overflow-y-auto px-1.5 pb-3 pt-1">
<div class="px-2.5 pb-1 pt-3 text-[0.6875rem] uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
Demos ({{ filtered.length }})
</div>
<RouterLink
v-for="demo in filtered"
:key="demo.name"
:to="demo.routePath"
custom
>
<template #default="{ href, navigate, isActive }">
<a
:href="href"
:aria-current="isActive ? 'page' : undefined"
class="block rounded-md px-2.5 py-1.5 no-underline hover:bg-neutral-900/5 aria-[current=page]:bg-blue-600 aria-[current=page]:text-white dark:hover:bg-neutral-100/5 dark:aria-[current=page]:bg-blue-500"
@click="navigate"
>
{{ demo.name }}
</a>
</template>
</RouterLink>
<div v-if="!demos.length" class="px-2.5 pb-1 pt-3 text-[0.6875rem] uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
No demos yet
</div>
</nav>
</aside>
<main class="min-w-0 overflow-auto px-7 py-6">
<header
v-if="currentDemoName"
class="mb-4 flex items-baseline gap-3 border-b border-neutral-200 pb-3 dark:border-neutral-800"
>
<h1 class="m-0 text-lg font-semibold">
{{ currentDemoName }}
</h1>
<code class="text-xs text-neutral-500 dark:text-neutral-400">src/demos/{{ currentDemoName }}.vue</code>
</header>
<RouterView v-slot="{ Component }">
<Suspense>
<template #default>
<component :is="Component" v-if="Component" />
</template>
<template #fallback>
<div class="text-neutral-500 dark:text-neutral-400">
Loading
</div>
</template>
</Suspense>
</RouterView>
</main>
</div>
</template>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
AccordionContent,
AccordionItem,
AccordionRoot,
AccordionTrigger,
} from '@primitives/accordion';
const value = ref<string | string[] | undefined>('a');
const type = ref<'single' | 'multiple'>('single');
const collapsible = ref(true);
const disabled = ref(false);
const items = [
{ value: 'a', title: 'Item A', body: 'First panel content.' },
{ value: 'b', title: 'Item B', body: 'Second panel content.' },
{ value: 'c', title: 'Item C', body: 'Third panel content.' },
];
</script>
<template>
<section class="grid max-w-xl gap-4">
<div class="flex flex-wrap items-center gap-3 rounded-lg border border-neutral-200 bg-white px-3 py-2.5 dark:border-neutral-800 dark:bg-neutral-900">
<label class="inline-flex items-center gap-2">
type
<select v-model="type" class="rounded border border-neutral-200 bg-neutral-50 px-1.5 py-0.5 dark:border-neutral-800 dark:bg-neutral-950">
<option value="single">single</option>
<option value="multiple">multiple</option>
</select>
</label>
<label class="inline-flex items-center gap-1.5">
<input v-model="collapsible" type="checkbox"> collapsible
</label>
<label class="inline-flex items-center gap-1.5">
<input v-model="disabled" type="checkbox"> disabled
</label>
<output class="ml-auto font-mono text-xs text-neutral-500 dark:text-neutral-400">value = {{ JSON.stringify(value) }}</output>
</div>
<AccordionRoot
v-model="value"
class="grid gap-1"
:type="type"
:collapsible="collapsible"
:disabled="disabled"
>
<AccordionItem
v-for="item in items"
:key="item.value"
:value="item.value"
class="overflow-hidden rounded-md border border-neutral-200 dark:border-neutral-800"
>
<AccordionTrigger
class="block w-full cursor-pointer bg-white px-3 py-2.5 text-left data-[state=open]:bg-neutral-900/5 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-600 dark:bg-neutral-900 dark:data-[state=open]:bg-neutral-100/5 dark:focus-visible:outline-blue-400"
>
{{ item.title }}
</AccordionTrigger>
<AccordionContent class="px-3 py-2.5 text-neutral-500 dark:text-neutral-400">
{{ item.body }}
</AccordionContent>
</AccordionItem>
</AccordionRoot>
</section>
</template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { ref } from 'vue';
import type { CheckedState } from '@primitives/checkbox';
import { CheckboxIndicator, CheckboxRoot } from '@primitives/checkbox';
const checked = ref<CheckedState>(false);
const disabled = ref(false);
</script>
<template>
<section class="grid max-w-md gap-4">
<div class="flex flex-wrap items-center gap-3 rounded-lg border border-neutral-200 bg-white px-3 py-2.5 dark:border-neutral-800 dark:bg-neutral-900">
<label class="inline-flex items-center gap-1.5">
<input v-model="disabled" type="checkbox"> disabled
</label>
<button
type="button"
class="rounded border border-neutral-200 bg-neutral-50 px-2 py-1 text-xs hover:border-blue-600 dark:border-neutral-800 dark:bg-neutral-950 dark:hover:border-blue-400"
@click="checked = 'indeterminate'"
>
set indeterminate
</button>
<output class="ml-auto font-mono text-xs text-neutral-500 dark:text-neutral-400">checked = {{ JSON.stringify(checked) }}</output>
</div>
<label class="inline-flex cursor-pointer items-center gap-2.5">
<CheckboxRoot
v-model:checked="checked"
:disabled="disabled"
class="inline-grid h-5 w-5 place-items-center rounded border border-neutral-200 bg-white data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[state=checked]:border-blue-600 data-[state=checked]:bg-blue-600 data-[state=checked]:text-white data-[state=indeterminate]:border-blue-600 data-[state=indeterminate]:bg-blue-600 data-[state=indeterminate]:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-neutral-800 dark:bg-neutral-900 dark:data-[state=checked]:border-blue-500 dark:data-[state=checked]:bg-blue-500 dark:data-[state=indeterminate]:border-blue-500 dark:data-[state=indeterminate]:bg-blue-500 dark:focus-visible:outline-blue-400"
>
<CheckboxIndicator class="text-[0.8125rem] leading-none">
<span v-if="checked === 'indeterminate'"></span>
<span v-else-if="checked"></span>
</CheckboxIndicator>
</CheckboxRoot>
Accept terms and conditions
</label>
</section>
</template>
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import App from './App.vue';
import { router } from './router';
import './styles.css';
const app = createApp(App).use(router);
app.config.performance = true;
app.mount('#app');
+37
View File
@@ -0,0 +1,37 @@
import type { Component } from 'vue';
import type { RouteRecordRaw } from 'vue-router';
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from './views/Home.vue';
import NotFoundView from './views/NotFound.vue';
// Eager paths, lazy components → each demo ships as its own chunk.
const demoModules = import.meta.glob<{ default: Component }>('./demos/*.vue');
export interface DemoEntry {
name: string;
path: string;
routePath: string;
}
export const demos: DemoEntry[] = Object.keys(demoModules)
.map((path) => {
const name = path.replace(/^.*\/demos\//, '').replace(/\.vue$/, '');
return { name, path, routePath: `/demo/${encodeURIComponent(name)}` };
})
.sort((a, b) => a.name.localeCompare(b.name));
const demoRoutes: RouteRecordRaw[] = demos.map(demo => ({
path: demo.routePath,
name: `demo:${demo.name}`,
component: demoModules[demo.path]!,
meta: { demoName: demo.name },
}));
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: HomeView },
...demoRoutes,
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView },
],
});
+12
View File
@@ -0,0 +1,12 @@
@import "tailwindcss";
@layer base {
html,
body,
#app {
@apply m-0 h-full bg-neutral-50 text-sm text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-100;
color-scheme: light dark;
}
}
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { demos } from '../router';
</script>
<template>
<section class="max-w-3xl">
<h1 class="mb-2 mt-0 text-2xl font-semibold">
@robonen/primitives playground
</h1>
<p class="text-neutral-500 dark:text-neutral-400">
Pick a demo from the sidebar, or drop a new <code class="rounded border border-neutral-200 bg-white px-1.5 py-px text-xs dark:border-neutral-800 dark:bg-neutral-900">.vue</code> file into
<code class="rounded border border-neutral-200 bg-white px-1.5 py-px text-xs dark:border-neutral-800 dark:bg-neutral-900">src/demos/</code> it will be picked up automatically and become
addressable at <code class="rounded border border-neutral-200 bg-white px-1.5 py-px text-xs dark:border-neutral-800 dark:bg-neutral-900">/demo/&lt;FileName&gt;</code>.
</p>
<div class="mt-4 grid gap-2 grid-cols-[repeat(auto-fill,minmax(11.25rem,1fr))]">
<RouterLink
v-for="demo in demos"
:key="demo.name"
:to="demo.routePath"
class="grid gap-1 rounded-lg border border-neutral-200 bg-white p-3 no-underline hover:border-blue-600 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:border-blue-400"
>
<strong>{{ demo.name }}</strong>
<code class="text-neutral-500 dark:text-neutral-400">{{ demo.routePath }}</code>
</RouterLink>
</div>
<p v-if="!demos.length" class="text-neutral-500 dark:text-neutral-400">
No demos found yet.
</p>
</section>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { useRoute } from 'vue-router';
const route = useRoute();
</script>
<template>
<section class="grid max-w-md gap-2">
<h1 class="m-0 text-3xl font-semibold">
404
</h1>
<p>
No demo matches <code class="rounded border border-neutral-200 bg-white px-1.5 py-px dark:border-neutral-800 dark:bg-neutral-900">{{ route.fullPath }}</code>.
</p>
<RouterLink to="/" class="text-blue-600 hover:underline dark:text-blue-400">
Back to index
</RouterLink>
</section>
</template>
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "@robonen/tsconfig/tsconfig.vue.json",
"compilerOptions": {
"types": ["vite/client", "node"],
"allowImportingTsExtensions": false,
"paths": {
"@primitives/*": ["../src/*"],
"@primitives": ["../src/index.ts"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}
+31
View File
@@ -0,0 +1,31 @@
import { URL, fileURLToPath } from 'node:url';
import tailwindcss from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
export default defineConfig(({ mode }) => ({
plugins: [vue(), tailwindcss()],
define: {
__DEV__: JSON.stringify(mode !== 'production'),
},
resolve: {
alias: [
// Order matters: subpath alias must come before the bare-specifier one.
{
find: /^@primitives\/(.*)$/,
replacement: fileURLToPath(new URL('../src/$1', import.meta.url)),
},
{
find: /^@primitives$/,
replacement: fileURLToPath(new URL('../src/index.ts', import.meta.url)),
},
],
},
server: {
port: 5180,
fs: {
// Allow importing from primitives source one level up.
allow: [fileURLToPath(new URL('../', import.meta.url))],
},
},
}));
@@ -0,0 +1,39 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface AccordionContentProps extends PrimitiveProps {
/** Keep content mounted even when closed. */
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { useAccordionContext, useAccordionItemContext } from './context';
import { Presence } from '../presence';
import { Primitive } from '../primitive';
import { useForwardExpose } from '@robonen/vue';
const { as = 'div', forceMount = false } = defineProps<AccordionContentProps>();
const { forwardRef } = useForwardExpose();
const ctx = useAccordionContext();
const item = useAccordionItemContext();
</script>
<template>
<Presence :present="forceMount || item.open.value">
<Primitive
:ref="forwardRef"
:as="as"
role="region"
:id="item.contentId.value"
:aria-labelledby="item.triggerId.value"
:data-state="item.open.value ? 'open' : 'closed'"
:data-disabled="item.disabled.value ? '' : undefined"
:data-orientation="ctx.orientation.value"
:hidden="!item.open.value || undefined"
>
<slot :open="item.open.value" />
</Primitive>
</Presence>
</template>
@@ -0,0 +1,49 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface AccordionItemProps extends PrimitiveProps {
/** Unique value for this item. */
value: string;
/** Disable this item. */
disabled?: boolean;
}
</script>
<script setup lang="ts">
import { provideAccordionItemContext, useAccordionContext } from './context';
import { Primitive } from '../primitive';
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../config-provider';
const { value, disabled = false, as = 'div' } = defineProps<AccordionItemProps>();
const { forwardRef } = useForwardExpose();
const ctx = useAccordionContext();
const isOpen = computed(() => ctx.isOpen(value));
const isDisabled = computed(() => ctx.disabled.value || disabled);
const triggerId = useId(undefined, 'accordion-trigger');
const contentId = useId(undefined, 'accordion-content');
provideAccordionItemContext({
value,
open: isOpen,
disabled: isDisabled,
triggerId,
contentId,
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:data-state="isOpen ? 'open' : 'closed'"
:data-disabled="isDisabled ? '' : undefined"
:data-orientation="ctx.orientation.value"
>
<slot :open="isOpen" />
</Primitive>
</template>
@@ -0,0 +1,153 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
import type { RovingDirection } from '../utils/roving-focus';
export interface AccordionRootProps extends PrimitiveProps {
/** Current open value(s) for controlled mode. */
modelValue?: string | string[];
/** Initial value(s) for uncontrolled mode. */
defaultValue?: string | string[];
/** 'single' allows one panel; 'multiple' allows many. @default 'single' */
type?: 'single' | 'multiple';
/** Allow closing all panels in single mode. @default false */
collapsible?: boolean;
/** Disable all items. */
disabled?: boolean;
/** Orientation of the accordion. @default 'vertical' */
orientation?: 'horizontal' | 'vertical';
/** Writing direction. @default 'ltr' */
dir?: RovingDirection;
/** Wrap keyboard navigation. @default true */
loop?: boolean;
}
</script>
<script setup lang="ts">
import { computed, shallowRef, toRef, watch } from 'vue';
import { resolveNextIndex, rovingKeyToAction } from '../utils/roving-focus';
import { Primitive } from '../primitive';
import { provideAccordionContext } from './context';
import { toArray } from '@robonen/stdlib';
import { useCollectionProvider } from '../collection';
import { useForwardExpose } from '@robonen/vue';
const {
type = 'single',
collapsible = false,
disabled = false,
orientation = 'vertical',
dir = 'ltr',
loop = true,
modelValue,
defaultValue,
as = 'div',
} = defineProps<AccordionRootProps>();
const { forwardRef } = useForwardExpose();
const emit = defineEmits<{ 'update:modelValue': [value: string | string[] | undefined] }>();
type RovingAction = NonNullable<ReturnType<typeof rovingKeyToAction>>;
const openSet = shallowRef<Set<string>>(
new Set(toArray(modelValue ?? defaultValue)),
);
function setEqualsArray(set: Set<string>, arr: string[]): boolean {
if (arr.length !== set.size) return false;
for (let i = 0; i < arr.length; i++) if (!set.has(arr[i]!)) return false;
return true;
}
watch(() => modelValue, (v) => {
if (v === undefined) return;
const arr = toArray(v);
if (setEqualsArray(openSet.value, arr)) return;
openSet.value = new Set(arr);
});
function nextOpenSet(cur: Set<string>, value: string): Set<string> {
const present = cur.has(value);
if (type === 'single') {
if (!present) return new Set([value]);
return collapsible ? new Set() : cur;
}
const next = new Set(cur);
if (present) next.delete(value);
else next.add(value);
return next;
}
function toEmitValue(set: Set<string>): string | string[] | undefined {
return type === 'single' ? set.values().next().value : [...set];
}
function commit(next: Set<string>): void {
openSet.value = next;
emit('update:modelValue', toEmitValue(next));
}
function isOpen(value: string): boolean {
return openSet.value.has(value);
}
function toggle(value: string): void {
if (disabled) return;
const cur = openSet.value;
const next = nextOpenSet(cur, value);
if (next !== cur) commit(next);
}
const { getItems, CollectionSlot } = useCollectionProvider();
const triggerElements = computed(() => getItems(true).map(i => i.ref));
function resolveFocusIndex(action: RovingAction, current: number, count: number): number {
if (action.absolute === 'home') return 0;
if (action.absolute === 'end') return count - 1;
return resolveNextIndex(current === -1 ? 0 : current, action.delta, count, loop);
}
function onTriggerKeyDown(event: KeyboardEvent, el: HTMLElement): void {
const action = rovingKeyToAction(event, { orientation, dir, loop });
if (!action) return;
event.preventDefault();
const enabled = triggerElements.value.filter(x => !x.hasAttribute('data-disabled'));
if (enabled.length === 0) return;
enabled[resolveFocusIndex(action, enabled.indexOf(el), enabled.length)]!.focus();
}
provideAccordionContext({
disabled: toRef(() => disabled),
orientation: toRef(() => orientation),
direction: toRef(() => dir),
loop: toRef(() => loop),
collapsible: toRef(() => collapsible),
triggerElements,
isOpen,
toggle,
onTriggerKeyDown,
});
</script>
<template>
<CollectionSlot>
<Primitive
:ref="forwardRef"
:as="as"
:data-orientation="orientation"
:data-disabled="disabled ? '' : undefined"
>
<slot />
</Primitive>
</CollectionSlot>
</template>
@@ -0,0 +1,52 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface AccordionTriggerProps extends PrimitiveProps {
}
</script>
<script setup lang="ts">
import { useAccordionContext, useAccordionItemContext } from './context';
import { Primitive } from '../primitive';
import { useCollectionInjector } from '../collection';
import { useForwardExpose } from '@robonen/vue';
const { as = 'button' } = defineProps<AccordionTriggerProps>();
const ctx = useAccordionContext();
const item = useAccordionItemContext();
const { forwardRef, currentElement } = useForwardExpose();
const { CollectionItem } = useCollectionInjector();
function onClick(): void {
if (item.disabled.value) return;
ctx.toggle(item.value);
}
function onKeyDown(event: KeyboardEvent): void {
if (!currentElement.value) return;
ctx.onTriggerKeyDown(event, currentElement.value);
}
</script>
<template>
<CollectionItem>
<Primitive
:as="as"
:ref="forwardRef"
:type="as === 'button' ? 'button' : undefined"
:id="item.triggerId.value"
:aria-expanded="item.open.value"
:aria-controls="item.contentId.value"
:aria-disabled="item.disabled.value || undefined"
:data-state="item.open.value ? 'open' : 'closed'"
:data-disabled="item.disabled.value ? '' : undefined"
:data-orientation="ctx.orientation.value"
:disabled="item.disabled.value || undefined"
@click="onClick"
@keydown="onKeyDown"
>
<slot :open="item.open.value" />
</Primitive>
</CollectionItem>
</template>
@@ -0,0 +1,242 @@
import { AccordionContent, AccordionItem, AccordionRoot, AccordionTrigger } from '../index';
import { defineComponent, h, nextTick, ref } from 'vue';
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
function createAccordion(rootProps: Record<string, unknown> = {}, itemCount = 3) {
return mount(
defineComponent({
setup() {
return () => h(AccordionRoot, { ...rootProps }, {
default: () => Array.from({ length: itemCount }, (_, i) => {
const val = String.fromCodePoint(97 + i); // 'a', 'b', 'c'
return h(AccordionItem, { value: val, key: val, disabled: i === 2 ? true : undefined }, {
default: () => [
h(AccordionTrigger, null, { default: () => `Trigger ${val.toUpperCase()}` }),
h(AccordionContent, null, { default: () => `Content ${val.toUpperCase()}` }),
],
});
}),
});
},
}),
{ attachTo: document.body },
);
}
function press(el: Element, key: string) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
describe('Accordion', () => {
it('renders items with correct structure', () => {
const w = createAccordion();
const triggers = w.findAll('button');
expect(triggers).toHaveLength(3);
triggers.forEach((t) => {
expect(t.attributes('aria-expanded')).toBeDefined();
expect(t.attributes('aria-controls')).toBeDefined();
});
w.unmount();
});
it('all panels closed by default (single, non-collapsible)', () => {
const w = createAccordion();
const regions = w.findAll('[role="region"]');
expect(regions).toHaveLength(0);
w.unmount();
});
it('defaultValue opens a panel', () => {
const w = createAccordion({ defaultValue: 'a' });
const regions = w.findAll('[role="region"]');
expect(regions).toHaveLength(1);
expect(regions[0]!.text()).toBe('Content A');
w.unmount();
});
it('click toggles panel open/closed (single, collapsible)', async () => {
const w = createAccordion({ collapsible: true });
const triggers = w.findAll('button');
await triggers[0]!.trigger('click');
await nextTick();
expect(w.findAll('[role="region"]')).toHaveLength(1);
expect(w.find('[role="region"]').text()).toBe('Content A');
// clicking again closes it (collapsible)
await triggers[0]!.trigger('click');
await nextTick();
expect(w.findAll('[role="region"]')).toHaveLength(0);
w.unmount();
});
it('single mode: opening one closes previous', async () => {
const w = createAccordion({ defaultValue: 'a' });
const triggers = w.findAll('button');
await triggers[1]!.trigger('click');
await nextTick();
const regions = w.findAll('[role="region"]');
expect(regions).toHaveLength(1);
expect(regions[0]!.text()).toBe('Content B');
w.unmount();
});
it('single mode: cannot close when not collapsible', async () => {
const w = createAccordion({ defaultValue: 'a', collapsible: false });
const triggers = w.findAll('button');
await triggers[0]!.trigger('click');
await nextTick();
// should stay open
expect(w.findAll('[role="region"]')).toHaveLength(1);
expect(w.find('[role="region"]').text()).toBe('Content A');
w.unmount();
});
it('multiple mode: multiple panels open', async () => {
const w = createAccordion({ type: 'multiple' });
const triggers = w.findAll('button');
await triggers[0]!.trigger('click');
await nextTick();
await triggers[1]!.trigger('click');
await nextTick();
const regions = w.findAll('[role="region"]');
expect(regions).toHaveLength(2);
w.unmount();
});
it('multiple mode: toggle individual items', async () => {
const w = createAccordion({ type: 'multiple', defaultValue: ['a', 'b'] });
expect(w.findAll('[role="region"]')).toHaveLength(2);
const triggers = w.findAll('button');
await triggers[0]!.trigger('click');
await nextTick();
// 'a' closed, 'b' still open
const regions = w.findAll('[role="region"]');
expect(regions).toHaveLength(1);
expect(regions[0]!.text()).toBe('Content B');
w.unmount();
});
it('v-model works (single)', async () => {
const value = ref<string | undefined>('a');
const w = mount(
defineComponent({
setup() {
return () => h(AccordionRoot, {
modelValue: value.value,
'onUpdate:modelValue': (v: string | string[] | undefined) => { value.value = v as string | undefined; },
collapsible: true,
}, {
default: () => [
h(AccordionItem, { value: 'a' }, {
default: () => [
h(AccordionTrigger, null, { default: () => 'A' }),
h(AccordionContent, null, { default: () => 'PA' }),
],
}),
h(AccordionItem, { value: 'b' }, {
default: () => [
h(AccordionTrigger, null, { default: () => 'B' }),
h(AccordionContent, null, { default: () => 'PB' }),
],
}),
],
});
},
}),
{ attachTo: document.body },
);
expect(w.find('[role="region"]').text()).toBe('PA');
const triggers = w.findAll('button');
await triggers[1]!.trigger('click');
await nextTick();
expect(value.value).toBe('b');
w.unmount();
});
it('keyboard navigation (vertical, ArrowDown/ArrowUp)', async () => {
const w = createAccordion({ defaultValue: 'a' });
await nextTick();
const triggers = w.findAll('button');
const trigA = triggers[0]!.element as HTMLElement;
trigA.focus();
press(trigA, 'ArrowDown');
await nextTick();
expect(document.activeElement).toBe(triggers[1]!.element);
press(triggers[1]!.element, 'ArrowUp');
await nextTick();
expect(document.activeElement).toBe(triggers[0]!.element);
w.unmount();
});
it('Home/End keys move focus', async () => {
const w = createAccordion({ defaultValue: 'a' });
await nextTick();
const triggers = w.findAll('button');
const trigA = triggers[0]!.element as HTMLElement;
trigA.focus();
press(trigA, 'End');
await nextTick();
// End goes to last enabled trigger (B, since C is disabled)
expect(document.activeElement).toBe(triggers[1]!.element);
press(triggers[1]!.element, 'Home');
await nextTick();
expect(document.activeElement).toBe(triggers[0]!.element);
w.unmount();
});
it('disabled item cannot be toggled', async () => {
const w = createAccordion({ type: 'multiple' });
const triggers = w.findAll('button');
await triggers[2]!.trigger('click');
await nextTick();
expect(w.findAll('[role="region"]')).toHaveLength(0);
w.unmount();
});
it('disabled root blocks all interaction', async () => {
const w = createAccordion({ disabled: true });
const triggers = w.findAll('button');
await triggers[0]!.trigger('click');
await nextTick();
expect(w.findAll('[role="region"]')).toHaveLength(0);
w.unmount();
});
it('data-state and aria-expanded reflect open state', async () => {
const w = createAccordion({ defaultValue: 'a' });
const triggers = w.findAll('button');
expect(triggers[0]!.attributes('aria-expanded')).toBe('true');
expect(triggers[0]!.attributes('data-state')).toBe('open');
expect(triggers[1]!.attributes('aria-expanded')).toBe('false');
expect(triggers[1]!.attributes('data-state')).toBe('closed');
w.unmount();
});
it('content has role=region with aria-labelledby', () => {
const w = createAccordion({ defaultValue: 'a' });
const region = w.find('[role="region"]');
expect(region.attributes('aria-labelledby')).toBeDefined();
const trigger = w.findAll('button')[0]!;
expect(region.attributes('aria-labelledby')).toBe(trigger.attributes('id'));
w.unmount();
});
it('orientation reflects in data-orientation', () => {
const w = createAccordion({ orientation: 'horizontal' });
expect(w.find('[data-orientation="horizontal"]').exists()).toBe(true);
w.unmount();
});
});
+33
View File
@@ -0,0 +1,33 @@
import type { ComputedRef, Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export interface AccordionContext {
disabled: Ref<boolean>;
orientation: Ref<'horizontal' | 'vertical'>;
direction: Ref<'ltr' | 'rtl'>;
loop: Ref<boolean>;
collapsible: Ref<boolean>;
/** DOM-ordered trigger elements, sourced from the internal Collection. */
triggerElements: ComputedRef<HTMLElement[]>;
isOpen: (value: string) => boolean;
toggle: (value: string) => void;
onTriggerKeyDown: (event: KeyboardEvent, el: HTMLElement) => void;
}
export const {
inject: useAccordionContext,
provide: provideAccordionContext,
} = useContextFactory<AccordionContext>('AccordionContext');
export interface AccordionItemContext {
value: string;
open: ComputedRef<boolean>;
disabled: ComputedRef<boolean>;
triggerId: ComputedRef<string>;
contentId: ComputedRef<string>;
}
export const {
inject: useAccordionItemContext,
provide: provideAccordionItemContext,
} = useContextFactory<AccordionItemContext>('AccordionItemContext');
+12
View File
@@ -0,0 +1,12 @@
export { default as AccordionRoot } from './AccordionRoot.vue';
export { default as AccordionItem } from './AccordionItem.vue';
export { default as AccordionTrigger } from './AccordionTrigger.vue';
export { default as AccordionContent } from './AccordionContent.vue';
export { provideAccordionContext, useAccordionContext, provideAccordionItemContext, useAccordionItemContext } from './context';
export type { AccordionRootProps } from './AccordionRoot.vue';
export type { AccordionItemProps } from './AccordionItem.vue';
export type { AccordionTriggerProps } from './AccordionTrigger.vue';
export type { AccordionContentProps } from './AccordionContent.vue';
export type { AccordionContext, AccordionItemContext } from './context';
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface AlertDialogActionProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { DialogClose } from '../dialog';
const { as = 'button' } = defineProps<AlertDialogActionProps>();
</script>
<template>
<DialogClose :as="as" data-alert-dialog-action>
<slot />
</DialogClose>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface AlertDialogCancelProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { DialogClose } from '../dialog';
const { as = 'button' } = defineProps<AlertDialogCancelProps>();
</script>
<template>
<DialogClose :as="as" data-alert-dialog-cancel>
<slot />
</DialogClose>
</template>
@@ -0,0 +1,43 @@
<script lang="ts">
import type { DialogContentEmits, DialogContentProps } from '../dialog';
export interface AlertDialogContentProps extends Omit<DialogContentProps, 'role'> {}
export type AlertDialogContentEmits = DialogContentEmits;
</script>
<script setup lang="ts">
import { DialogContent } from '../dialog';
const props = defineProps<AlertDialogContentProps>();
const emit = defineEmits<AlertDialogContentEmits>();
function onOpenAutoFocus(event: Event) {
emit('openAutoFocus', event);
if (event.defaultPrevented) return;
queueMicrotask(() => {
const content = document.querySelector<HTMLElement>('[data-alert-dialog-content]');
const cancel = content?.querySelector<HTMLElement>('[data-alert-dialog-cancel]');
if (cancel) {
event.preventDefault();
cancel.focus();
}
});
}
</script>
<template>
<DialogContent
v-bind="props"
role="alertdialog"
data-alert-dialog-content
@open-auto-focus="onOpenAutoFocus"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="(e: PointerEvent | MouseEvent) => { e.preventDefault(); emit('pointerDownOutside', e); }"
@focus-outside="(e: FocusEvent) => { e.preventDefault(); emit('focusOutside', e); }"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
>
<slot />
</DialogContent>
</template>
@@ -0,0 +1,25 @@
<script lang="ts">
import type { DialogRootProps } from '../dialog';
export interface AlertDialogRootProps extends Omit<DialogRootProps, 'modal'> {}
</script>
<script setup lang="ts">
import { DialogRoot } from '../dialog';
defineOptions({ inheritAttrs: false });
const props = defineProps<AlertDialogRootProps>();
const openModel = defineModel<boolean | undefined>('open', { default: undefined });
</script>
<template>
<DialogRoot
:default-open="props.defaultOpen"
:modal="true"
:open="openModel"
@update:open="openModel = $event"
>
<slot :open="openModel" />
</DialogRoot>
</template>
@@ -0,0 +1,118 @@
import { mount } from '@vue/test-utils';
import type { VueWrapper } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
AlertDialogTrigger,
} from '../index';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
document.body.removeAttribute('style');
delete document.body.dataset['dismissableBlocking'];
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function mountAlert(initialOpen = true) {
const open = ref(initialOpen);
const Harness = defineComponent({
setup() {
return () => h(
AlertDialogRoot,
{
open: open.value,
'onUpdate:open': (v: boolean | undefined) => { open.value = v!; },
},
{
default: () => [
h(AlertDialogTrigger, null, { default: () => 'Open' }),
h(AlertDialogPortal, null, {
default: () => [
h(AlertDialogOverlay),
h(AlertDialogContent, null, {
default: () => [
h(AlertDialogTitle, null, { default: () => 'Are you sure?' }),
h(AlertDialogDescription, null, { default: () => 'This cannot be undone.' }),
h(AlertDialogCancel, null, { default: () => 'Cancel' }),
h(AlertDialogAction, null, { default: () => 'OK' }),
],
}),
],
}),
],
},
);
},
});
const w = track(mount(Harness, { attachTo: document.body }));
return { wrapper: w, open };
}
describe('AlertDialog', () => {
it('renders content with role="alertdialog"', async () => {
mountAlert(true);
await nextTick();
await nextTick();
const content = document.querySelector('[data-alert-dialog-content]');
expect(content).toBeTruthy();
expect(content!.getAttribute('role')).toBe('alertdialog');
});
it('labels content via Title and describes via Description', async () => {
mountAlert(true);
await nextTick();
await nextTick();
const content = document.querySelector<HTMLElement>('[data-alert-dialog-content]')!;
const labelledby = content.getAttribute('aria-labelledby');
const describedby = content.getAttribute('aria-describedby');
expect(labelledby).toMatch(/dialog-title/);
expect(describedby).toMatch(/dialog-description/);
expect(document.getElementById(labelledby!)?.textContent).toBe('Are you sure?');
expect(document.getElementById(describedby!)?.textContent).toBe('This cannot be undone.');
});
it('Cancel button closes the dialog', async () => {
const { open } = mountAlert(true);
await nextTick();
await nextTick();
const cancel = document.querySelector<HTMLButtonElement>('[data-alert-dialog-cancel]')!;
cancel.click();
await nextTick();
await nextTick();
expect(open.value).toBe(false);
});
it('Action button closes the dialog', async () => {
const { open } = mountAlert(true);
await nextTick();
await nextTick();
const action = document.querySelector<HTMLButtonElement>('[data-alert-dialog-action]')!;
action.click();
await nextTick();
await nextTick();
expect(open.value).toBe(false);
});
it('Cancel and Action carry data attributes', async () => {
mountAlert(true);
await nextTick();
await nextTick();
expect(document.querySelector('[data-alert-dialog-cancel]')).toBeTruthy();
expect(document.querySelector('[data-alert-dialog-action]')).toBeTruthy();
});
});
+11
View File
@@ -0,0 +1,11 @@
export { DialogDescription as AlertDialogDescription, DialogOverlay as AlertDialogOverlay, DialogPortal as AlertDialogPortal, DialogTitle as AlertDialogTitle, DialogTrigger as AlertDialogTrigger } from '../dialog';
export { default as AlertDialogAction } from './AlertDialogAction.vue';
export { default as AlertDialogCancel } from './AlertDialogCancel.vue';
export { default as AlertDialogContent } from './AlertDialogContent.vue';
export { default as AlertDialogRoot } from './AlertDialogRoot.vue';
export type { AlertDialogActionProps } from './AlertDialogAction.vue';
export type { AlertDialogCancelProps } from './AlertDialogCancel.vue';
export type { AlertDialogContentEmits, AlertDialogContentProps } from './AlertDialogContent.vue';
export type { AlertDialogRootProps } from './AlertDialogRoot.vue';
@@ -0,0 +1,41 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface AspectRatioProps extends PrimitiveProps {
/**
* Desired width-to-height ratio (e.g. `16 / 9`, `1`, `4 / 3`).
* @default 1
*/
ratio?: number;
}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { useForwardExpose } from '@robonen/vue';
useForwardExpose();
const { ratio = 1, as = 'div' } = defineProps<AspectRatioProps>();
const wrapperStyle = {
position: 'relative' as const,
width: '100%',
paddingBottom: `${(1 / ratio) * 100}%`,
};
// Hoisted constant — the inner style never depends on props, so a single
// module-level object is reused across all instances.
const INNER_STYLE = {
position: 'absolute' as const,
inset: 0,
};
</script>
<template>
<div :style="wrapperStyle" data-aspect-ratio-wrapper>
<Primitive :as="as" :style="INNER_STYLE" :data-aspect-ratio="true">
<slot />
</Primitive>
</div>
</template>
@@ -0,0 +1,24 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { AspectRatio } from '../index';
describe('AspectRatio', () => {
it('renders with default 1:1 ratio', () => {
const wrapper = mount(AspectRatio);
const outer = wrapper.element as HTMLElement;
expect(outer.style.paddingBottom).toBe('100%');
});
it('computes padding-bottom from ratio', () => {
const wrapper = mount(AspectRatio, { props: { ratio: 16 / 9 } });
const outer = wrapper.element as HTMLElement;
expect(outer.style.paddingBottom).toMatch(/^56\.25%$/);
});
it('places inner element absolutely covering the wrapper', () => {
const wrapper = mount(AspectRatio, { props: { ratio: 4 / 3 }, slots: { default: '<img />' } });
const inner = wrapper.element.firstElementChild as HTMLElement;
expect(inner.style.position).toBe('absolute');
expect(inner.getAttribute('data-aspect-ratio')).toBe('true');
});
});
+2
View File
@@ -0,0 +1,2 @@
export { default as AspectRatio } from './AspectRatio.vue';
export type { AspectRatioProps } from './AspectRatio.vue';
@@ -0,0 +1,57 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface AvatarFallbackProps extends PrimitiveProps {
/** Delay in ms before rendering the fallback (avoids flicker on fast networks). */
delayMs?: number;
}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useAvatarContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'span', delayMs = 0 } = defineProps<AvatarFallbackProps>();
const { forwardRef } = useForwardExpose();
const ctx = useAvatarContext();
const canShow = ref<boolean>(delayMs === 0);
let timer: ReturnType<typeof setTimeout> | null = null;
watch(() => ctx.imageLoadingStatus.value, (status) => {
if (status === 'loaded') {
canShow.value = false;
if (timer) {
clearTimeout(timer);
timer = null;
}
return;
}
if (delayMs === 0) {
canShow.value = true;
return;
}
if (timer) clearTimeout(timer);
canShow.value = false;
timer = setTimeout(() => {
canShow.value = true;
}, delayMs);
}, { immediate: true });
onBeforeUnmount(() => {
if (timer) clearTimeout(timer);
});
const shouldRender = computed(() => canShow.value && ctx.imageLoadingStatus.value !== 'loaded');
</script>
<template>
<Primitive :ref="forwardRef" v-if="shouldRender" :as="as">
<slot />
</Primitive>
</template>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
import type { AvatarImageLoadingStatus } from './context';
export interface AvatarImageProps extends PrimitiveProps {
src?: string;
alt?: string;
/** Optional hook to reject loaded images by their dimensions/src. */
onLoadingStatusChange?: (status: AvatarImageLoadingStatus) => void;
}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useAvatarContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'img', src, alt, onLoadingStatusChange } = defineProps<AvatarImageProps>();
const { forwardRef } = useForwardExpose();
const ctx = useAvatarContext();
const status = ref<AvatarImageLoadingStatus>('idle');
function setStatus(next: AvatarImageLoadingStatus) {
status.value = next;
ctx.onImageLoadingStatusChange(next);
onLoadingStatusChange?.(next);
}
let currentImage: HTMLImageElement | null = null;
function load(nextSrc: string | undefined) {
if (currentImage) {
currentImage.onload = null;
currentImage.onerror = null;
currentImage = null;
}
if (!nextSrc) {
setStatus('error');
return;
}
if (typeof globalThis.window === 'undefined') {
setStatus('loading');
return;
}
setStatus('loading');
const img = new globalThis.Image();
currentImage = img;
img.onload = () => {
if (currentImage === img) setStatus('loaded');
};
img.onerror = () => {
if (currentImage === img) setStatus('error');
};
img.src = nextSrc;
}
watch(() => src, load, { immediate: true });
onBeforeUnmount(() => {
if (currentImage) {
currentImage.onload = null;
currentImage.onerror = null;
currentImage = null;
}
});
const shouldRender = computed(() => status.value === 'loaded');
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
v-if="shouldRender"
:src="src"
:alt="alt"
/>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface AvatarRootProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import type { AvatarImageLoadingStatus } from './context';
import { Primitive } from '../primitive';
import { provideAvatarContext } from './context';
import { ref } from 'vue';
import { useForwardExpose } from '@robonen/vue';
const { as = 'span' } = defineProps<AvatarRootProps>();
const { forwardRef } = useForwardExpose();
const imageLoadingStatus = ref<AvatarImageLoadingStatus>('idle');
provideAvatarContext({
imageLoadingStatus,
onImageLoadingStatusChange: (status) => { imageLoadingStatus.value = status; },
});
</script>
<template>
<Primitive :ref="forwardRef" :as="as" :data-status="imageLoadingStatus">
<slot />
</Primitive>
</template>
@@ -0,0 +1,93 @@
import { mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { AvatarFallback, AvatarImage, AvatarRoot } from '../index';
class MockImage {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
private _src = '';
set src(value: string) {
this._src = value;
queueMicrotask(() => {
if (value.includes('broken')) this.onerror?.();
else this.onload?.();
});
}
get src() { return this._src; }
}
describe('Avatar', () => {
beforeEach(() => {
vi.stubGlobal('Image', MockImage as unknown as typeof Image);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('renders fallback until image loads', async () => {
const w = mount(defineComponent({
setup: () => () => h(AvatarRoot, null, {
default: () => [
h(AvatarImage, { src: '/ok.png', alt: 'user' }),
h(AvatarFallback, { class: 'fb' }, { default: () => 'AB' }),
],
}),
}));
expect(w.find('.fb').exists()).toBe(true);
expect(w.find('img').exists()).toBe(false);
await new Promise(r => queueMicrotask(() => r(null)));
await nextTick();
expect(w.find('img').exists()).toBe(true);
expect(w.find('img').attributes('src')).toBe('/ok.png');
expect(w.find('.fb').exists()).toBe(false);
});
it('keeps fallback visible on error', async () => {
const w = mount(defineComponent({
setup: () => () => h(AvatarRoot, null, {
default: () => [
h(AvatarImage, { src: '/broken.png' }),
h(AvatarFallback, { class: 'fb' }, { default: () => 'AB' }),
],
}),
}));
await new Promise(r => queueMicrotask(() => r(null)));
await nextTick();
expect(w.find('img').exists()).toBe(false);
expect(w.find('.fb').exists()).toBe(true);
});
it('delays fallback rendering when delayMs is set', async () => {
vi.useFakeTimers();
const w = mount(defineComponent({
setup: () => () => h(AvatarRoot, null, {
default: () => [
h(AvatarFallback, { class: 'fb', delayMs: 500 }, { default: () => 'AB' }),
],
}),
}));
expect(w.find('.fb').exists()).toBe(false);
vi.advanceTimersByTime(500);
await nextTick();
expect(w.find('.fb').exists()).toBe(true);
vi.useRealTimers();
});
it('sets data-status on the root element', async () => {
const w = mount(defineComponent({
setup: () => () => h(AvatarRoot, null, {
default: () => [
h(AvatarImage, { src: '/ok.png' }),
h(AvatarFallback, null, { default: () => '?' }),
],
}),
}));
await nextTick();
expect(w.element.getAttribute('data-status')).toBe('loading');
await new Promise(r => queueMicrotask(() => r(null)));
await nextTick();
expect(w.element.getAttribute('data-status')).toBe('loaded');
});
});
+14
View File
@@ -0,0 +1,14 @@
import type { Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export type AvatarImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error';
export interface AvatarContext {
imageLoadingStatus: Ref<AvatarImageLoadingStatus>;
onImageLoadingStatusChange: (status: AvatarImageLoadingStatus) => void;
}
const ctx = useContextFactory<AvatarContext>('AvatarContext');
export const provideAvatarContext = ctx.provide;
export const useAvatarContext = ctx.inject;
+8
View File
@@ -0,0 +1,8 @@
export { default as AvatarRoot } from './AvatarRoot.vue';
export { default as AvatarImage } from './AvatarImage.vue';
export { default as AvatarFallback } from './AvatarFallback.vue';
export type { AvatarRootProps } from './AvatarRoot.vue';
export type { AvatarImageProps } from './AvatarImage.vue';
export type { AvatarFallbackProps } from './AvatarFallback.vue';
export { provideAvatarContext, useAvatarContext } from './context';
export type { AvatarContext, AvatarImageLoadingStatus } from './context';
@@ -0,0 +1,43 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarCellProps extends PrimitiveProps {
/** The date this cell represents. */
date: Date;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../primitive';
import { useCalendarGridContext, useCalendarRootContext } from './context';
import { isSameDay, isSameMonth } from './utils';
const { as = 'td', date } = defineProps<CalendarCellProps>();
const ctx = useCalendarRootContext();
const gridCtx = useCalendarGridContext();
const isSelected = computed(() => ctx.isDateSelected(date));
const isDisabled = computed(() => ctx.isDateDisabled(date));
const isUnavailable = computed(() => ctx.isDateUnavailable(date));
const isOutsideView = computed(() => !isSameMonth(date, gridCtx.month.value));
const isToday = computed(() => isSameDay(date, new Date()));
</script>
<template>
<Primitive
:as="as"
role="gridcell"
:aria-selected="isSelected ? true : undefined"
:aria-disabled="(isDisabled || isUnavailable) ? true : undefined"
:data-primitives-calendar-cell="''"
:data-selected="isSelected ? '' : undefined"
:data-disabled="isDisabled ? '' : undefined"
:data-unavailable="isUnavailable ? '' : undefined"
:data-outside-view="isOutsideView ? '' : undefined"
:data-today="isToday ? '' : undefined"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,198 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarCellTriggerProps extends PrimitiveProps {
/** The day this trigger represents. */
day: Date;
/** The month this trigger's cell belongs to. Defaults to grid context. */
month?: Date;
}
export interface CalendarCellTriggerSlotProps {
dayValue: string;
disabled: boolean;
selected: boolean;
today: boolean;
outsideView: boolean;
unavailable: boolean;
}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { computed, nextTick } from 'vue';
import { Primitive } from '../primitive';
import { useCalendarGridContext, useCalendarRootContext } from './context';
import { addDays, addMonths, addYears, formatFullDate, isSameDay, isSameMonth } from './utils';
const { as = 'div', day, month } = defineProps<CalendarCellTriggerProps>();
defineSlots<{
default?: (props: CalendarCellTriggerSlotProps) => unknown;
}>();
const ctx = useCalendarRootContext();
const gridCtx = useCalendarGridContext();
const { forwardRef, currentElement } = useForwardExpose();
const monthValue = computed(() => month ?? gridCtx.month.value);
const isOutsideView = computed(() => !isSameMonth(day, monthValue.value));
const isDisabled = computed(() => ctx.isDateDisabled(day));
const isUnavailable = computed(() => ctx.isDateUnavailable(day));
const isSelected = computed(() => ctx.isDateSelected(day));
const isToday = computed(() => isSameDay(day, new Date()));
const dayValue = computed(() => day.getDate().toLocaleString(ctx.locale.value));
const labelText = computed(() => formatFullDate(day, ctx.locale.value));
const isFocusedDate = computed(() => {
if (isOutsideView.value || isDisabled.value) return false;
if (ctx.focusedDate.value) return isSameDay(day, ctx.focusedDate.value);
// Fallback focusable: selected, else today (if in view), else first day of month.
if (ctx.modelValue.value && isSameMonth(ctx.modelValue.value, monthValue.value))
return isSameDay(day, ctx.modelValue.value);
const today = new Date();
if (isSameMonth(today, monthValue.value))
return isSameDay(day, today);
return day.getDate() === 1 && isSameMonth(day, monthValue.value);
});
function selectIfAllowed() {
if (ctx.readonly.value) return;
if (isDisabled.value || isUnavailable.value) return;
ctx.setDate(day);
ctx.focusedDate.value = day;
}
function handleClick() {
selectIfAllowed();
}
function focusByDataValue(target: Date) {
const parent = ctx.parentElement.value;
if (!parent) return false;
const el = parent.querySelector<HTMLElement>(
`[data-primitives-calendar-cell-trigger][data-value="${target.toISOString().slice(0, 10)}"]:not([data-outside-view])`,
);
if (el) {
el.focus();
return true;
}
return false;
}
function shiftFocus(target: Date) {
if (ctx.minValue.value && target < ctx.minValue.value) return;
if (ctx.maxValue.value && target > ctx.maxValue.value) return;
ctx.focusedDate.value = target;
if (focusByDataValue(target)) return;
// Crossed visible range — page placeholder and retry.
if (target > ctx.placeholder.value) {
if (ctx.isNextButtonDisabled()) return;
ctx.nextPage();
}
else {
if (ctx.isPrevButtonDisabled()) return;
ctx.prevPage();
}
nextTick(() => focusByDataValue(target));
}
function handleKeyDown(e: KeyboardEvent) {
if (isDisabled.value) return;
const rtl = ctx.dir.value === 'rtl' ? -1 : 1;
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
shiftFocus(addDays(day, rtl));
break;
case 'ArrowLeft':
e.preventDefault();
shiftFocus(addDays(day, -rtl));
break;
case 'ArrowUp':
e.preventDefault();
shiftFocus(addDays(day, -7));
break;
case 'ArrowDown':
e.preventDefault();
shiftFocus(addDays(day, 7));
break;
case 'Home': {
e.preventDefault();
const dow = day.getDay();
const offset = (dow - ctx.weekStartsOn.value + 7) % 7;
shiftFocus(addDays(day, -offset));
break;
}
case 'End': {
e.preventDefault();
const dow = day.getDay();
const offset = (dow - ctx.weekStartsOn.value + 7) % 7;
shiftFocus(addDays(day, 6 - offset));
break;
}
case 'PageUp':
e.preventDefault();
shiftFocus(e.shiftKey ? addYears(day, -1) : addMonths(day, -1));
break;
case 'PageDown':
e.preventDefault();
shiftFocus(e.shiftKey ? addYears(day, 1) : addMonths(day, 1));
break;
case 'Enter':
case ' ':
e.preventDefault();
selectIfAllowed();
break;
}
}
function handleFocus() {
ctx.focusedDate.value = day;
}
const dataValue = computed(() => day.toISOString().slice(0, 10));
const tabindex = computed(() => {
if (isFocusedDate.value) return 0;
if (isOutsideView.value || isDisabled.value) return undefined;
return -1;
});
defineExpose({ currentElement });
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="button"
:aria-label="labelText"
:aria-disabled="(isDisabled || isUnavailable) ? true : undefined"
:aria-selected="isSelected ? true : undefined"
:tabindex="tabindex"
:data-primitives-calendar-cell-trigger="''"
:data-value="dataValue"
:data-selected="isSelected ? '' : undefined"
:data-disabled="isDisabled ? '' : undefined"
:data-unavailable="isUnavailable ? '' : undefined"
:data-outside-view="isOutsideView ? '' : undefined"
:data-today="isToday ? '' : undefined"
:data-focused="isFocusedDate ? '' : undefined"
@click="handleClick"
@focus="handleFocus"
@keydown="handleKeyDown"
>
<slot
:day-value="dayValue"
:disabled="isDisabled"
:selected="isSelected"
:today="isToday"
:outside-view="isOutsideView"
:unavailable="isUnavailable"
>
{{ dayValue }}
</slot>
</Primitive>
</template>
@@ -0,0 +1,40 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarGridProps extends PrimitiveProps {
/** The month this grid represents. Defaults to the root placeholder's month. */
month?: Date;
}
</script>
<script setup lang="ts">
import { computed, toRef } from 'vue';
import { Primitive } from '../primitive';
import { provideCalendarGridContext, useCalendarRootContext } from './context';
const { as = 'table', month } = defineProps<CalendarGridProps>();
const ctx = useCalendarRootContext();
const monthRef = toRef(() => month ?? ctx.placeholder.value);
provideCalendarGridContext({ month: monthRef });
const readonly = computed(() => ctx.readonly.value || undefined);
const disabled = computed(() => ctx.disabled.value || undefined);
</script>
<template>
<Primitive
:as="as"
role="grid"
tabindex="-1"
:aria-label="ctx.fullCalendarLabel.value"
:aria-readonly="readonly ? true : undefined"
:aria-disabled="disabled ? true : undefined"
:data-primitives-calendar-grid="''"
:data-readonly="readonly ? '' : undefined"
:data-disabled="disabled ? '' : undefined"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarGridBodyProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
const { as = 'tbody' } = defineProps<CalendarGridBodyProps>();
</script>
<template>
<Primitive :as="as" :data-primitives-calendar-grid-body="''">
<slot />
</Primitive>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarGridHeadProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
const { as = 'thead' } = defineProps<CalendarGridHeadProps>();
</script>
<template>
<Primitive :as="as" :data-primitives-calendar-grid-head="''">
<slot />
</Primitive>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarGridRowProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
const { as = 'tr' } = defineProps<CalendarGridRowProps>();
</script>
<template>
<Primitive :as="as" :data-primitives-calendar-grid-row="''">
<slot />
</Primitive>
</template>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarHeadCellProps extends PrimitiveProps {
/** The day this header cell represents — used for `aria-label`. */
day?: Date;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../primitive';
import { useCalendarRootContext } from './context';
import { formatWeekday } from './utils';
const { as = 'th', day } = defineProps<CalendarHeadCellProps>();
const ctx = useCalendarRootContext();
const longLabel = computed(() => (day ? formatWeekday(day, ctx.locale.value, 'long') : undefined));
</script>
<template>
<Primitive
:as="as"
scope="col"
:aria-label="longLabel"
:data-primitives-calendar-head-cell="''"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarHeaderProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
const { as = 'div' } = defineProps<CalendarHeaderProps>();
</script>
<template>
<Primitive :as="as" :data-primitives-calendar-header="''">
<slot />
</Primitive>
</template>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarHeadingProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { useCalendarRootContext } from './context';
const { as = 'div' } = defineProps<CalendarHeadingProps>();
defineSlots<{
default?: (props: { headingValue: string }) => unknown;
}>();
const ctx = useCalendarRootContext();
</script>
<template>
<Primitive
:as="as"
aria-hidden="true"
:data-primitives-calendar-heading="''"
:data-disabled="ctx.disabled.value ? '' : undefined"
>
<slot :heading-value="ctx.headingValue.value">
{{ ctx.headingValue.value }}
</slot>
</Primitive>
</template>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarNextProps extends PrimitiveProps {
/** Override the root's `nextPage` for just this button. */
nextPage?: (placeholder: Date) => Date;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../primitive';
import { useCalendarRootContext } from './context';
const { as = 'button', nextPage: nextPageProp } = defineProps<CalendarNextProps>();
defineSlots<{
default?: (props: { disabled: boolean }) => unknown;
}>();
const ctx = useCalendarRootContext();
const disabled = computed(() => ctx.disabled.value || ctx.isNextButtonDisabled(nextPageProp));
function handleClick() {
if (disabled.value) return;
ctx.nextPage(nextPageProp);
}
</script>
<template>
<Primitive
:as="as"
:type="as === 'button' ? 'button' : undefined"
aria-label="Next"
:aria-disabled="disabled || undefined"
:data-primitives-calendar-next="''"
:data-disabled="disabled ? '' : undefined"
:disabled="as === 'button' ? disabled : undefined"
@click="handleClick"
>
<slot :disabled="disabled">
Next
</slot>
</Primitive>
</template>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarPrevProps extends PrimitiveProps {
/** Override the root's `prevPage` for just this button. */
prevPage?: (placeholder: Date) => Date;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../primitive';
import { useCalendarRootContext } from './context';
const { as = 'button', prevPage: prevPageProp } = defineProps<CalendarPrevProps>();
defineSlots<{
default?: (props: { disabled: boolean }) => unknown;
}>();
const ctx = useCalendarRootContext();
const disabled = computed(() => ctx.disabled.value || ctx.isPrevButtonDisabled(prevPageProp));
function handleClick() {
if (disabled.value) return;
ctx.prevPage(prevPageProp);
}
</script>
<template>
<Primitive
:as="as"
:type="as === 'button' ? 'button' : undefined"
aria-label="Previous"
:aria-disabled="disabled || undefined"
:data-primitives-calendar-prev="''"
:data-disabled="disabled ? '' : undefined"
:disabled="as === 'button' ? disabled : undefined"
@click="handleClick"
>
<slot :disabled="disabled">
Previous
</slot>
</Primitive>
</template>
@@ -0,0 +1,324 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
import type { CalendarMonth, WeekDayFormat } from './utils';
export interface CalendarRootProps extends PrimitiveProps {
/** Uncontrolled default selected date. */
defaultValue?: Date;
/** Uncontrolled default placeholder (displayed month). */
defaultPlaceholder?: Date;
/** Minimum selectable date. */
minValue?: Date;
/** Maximum selectable date. */
maxValue?: Date;
/** Predicate marking a date as unavailable (not selectable). */
isDateUnavailable?: (date: Date) => boolean;
/** Predicate marking a date as disabled. */
isDateDisabled?: (date: Date) => boolean;
/** Prev/Next navigate by `numberOfMonths` instead of one month. @default false */
pagedNavigation?: boolean;
/** First day of week (0=Sun ... 6=Sat). @default 0 */
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** Width of localized weekday names. @default 'short' */
weekdayFormat?: WeekDayFormat;
/** Always render 6 weeks per month. @default true */
fixedWeeks?: boolean;
/** Number of months displayed simultaneously. @default 1 */
numberOfMonths?: number;
/** Disable the whole calendar. @default false */
disabled?: boolean;
/** Make the calendar read-only. @default false */
readonly?: boolean;
/** Auto-focus the calendar on mount. @default false */
initialFocus?: boolean;
/** Locale for `Intl` formatting. @default 'en' */
locale?: string;
/** Reading direction. */
dir?: 'ltr' | 'rtl';
/** Override "next page" navigation logic. */
nextPage?: (placeholder: Date) => Date;
/** Override "prev page" navigation logic. */
prevPage?: (placeholder: Date) => Date;
/** Calendar accessible label prefix. @default 'Calendar' */
calendarLabel?: string;
}
export interface CalendarRootEmits {
'update:modelValue': [date: Date | undefined];
'update:placeholder': [date: Date];
}
</script>
<script setup lang="ts">
import { useEventListener, useForwardExpose } from '@robonen/vue';
import { computed, onMounted, ref, toRef, watch } from 'vue';
import { Primitive } from '../primitive';
import { provideCalendarRootContext } from './context';
import {
addMonths,
addYears,
clamp,
createMonths,
formatMonthYear,
getWeekdayLabels,
isAfter,
isBefore,
isSameDay,
isSameMonth,
isDateUnavailable as isUnavailable,
toDateOnly,
} from './utils';
defineOptions({ inheritAttrs: false });
const {
as = 'div',
defaultValue,
defaultPlaceholder,
minValue,
maxValue,
isDateUnavailable: propsIsDateUnavailable,
isDateDisabled: propsIsDateDisabled,
pagedNavigation = false,
weekStartsOn = 0,
weekdayFormat = 'short',
fixedWeeks = true,
numberOfMonths = 1,
disabled = false,
readonly = false,
initialFocus = false,
locale = 'en',
dir = 'ltr',
nextPage: propsNextPage,
prevPage: propsPrevPage,
calendarLabel = 'Calendar',
} = defineProps<CalendarRootProps>();
defineEmits<CalendarRootEmits>();
defineSlots<{
default?: (props: {
date: Date;
grid: CalendarMonth[];
weekDays: string[];
weekStartsOn: number;
locale: string;
modelValue: Date | undefined;
}) => unknown;
}>();
const localValue = ref<Date | undefined>(defaultValue);
const modelValue = defineModel<Date | undefined>('modelValue', {
default: undefined,
get: v => v ?? localValue.value,
set: (v) => {
localValue.value = v;
return v;
},
});
const localPlaceholder = ref<Date>(
toDateOnly(defaultPlaceholder ?? modelValue.value ?? new Date()),
);
const placeholder = defineModel<Date>('placeholder', {
default: undefined,
get: v => v ?? localPlaceholder.value,
set: (v) => {
localPlaceholder.value = toDateOnly(v);
return localPlaceholder.value;
},
});
const { forwardRef, currentElement: parentElement } = useForwardExpose();
const focusedDate = ref<Date | undefined>();
const localeRef = toRef(() => locale);
const dirRef = toRef(() => dir);
const weekStartsOnRef = toRef(() => weekStartsOn);
const weekdayFormatRef = toRef(() => weekdayFormat);
const fixedWeeksRef = toRef(() => fixedWeeks);
const numberOfMonthsRef = toRef(() => numberOfMonths);
const disabledRef = toRef(() => disabled);
const readonlyRef = toRef(() => readonly);
const pagedNavigationRef = toRef(() => pagedNavigation);
const minValueRef = toRef(() => minValue);
const maxValueRef = toRef(() => maxValue);
const grid = computed<CalendarMonth[]>(() => createMonths({
date: placeholder.value,
numberOfMonths,
weekStartsOn,
}));
const weekDays = computed(() => getWeekdayLabels(weekStartsOn, locale, weekdayFormat));
const headingValue = computed(() => {
const months = grid.value;
if (!months.length) return '';
if (months.length === 1) return formatMonthYear(months[0]!.value, locale);
const first = formatMonthYear(months[0]!.value, locale);
const last = formatMonthYear(months[months.length - 1]!.value, locale);
return `${first} - ${last}`;
});
const fullCalendarLabel = computed(() => `${calendarLabel}, ${headingValue.value}`);
function isDateDisabled(date: Date): boolean {
if (disabled) return true;
if (propsIsDateDisabled?.(date)) return true;
if (minValue && isBefore(date, minValue)) return true;
if (maxValue && isAfter(date, maxValue)) return true;
return false;
}
function isDateUnavailableLocal(date: Date): boolean {
return isUnavailable(date, propsIsDateUnavailable, minValue, maxValue);
}
function isDateSelected(date: Date): boolean {
return modelValue.value ? isSameDay(modelValue.value, date) : false;
}
function isOutsideVisibleView(date: Date): boolean {
return !grid.value.some(m => isSameMonth(m.value, date));
}
const isInvalid = computed(() => {
if (!modelValue.value) return false;
return isDateDisabled(modelValue.value) || isDateUnavailableLocal(modelValue.value);
});
function setDate(date: Date | undefined) {
if (readonly) return;
if (date && (isDateDisabled(date) || isDateUnavailableLocal(date))) return;
modelValue.value = date ? toDateOnly(date) : undefined;
}
function setPlaceholder(date: Date) {
placeholder.value = clamp(date, minValue, maxValue);
}
function pageStep(): number {
return pagedNavigation ? numberOfMonths : 1;
}
function nextPage(fn?: (placeholder: Date) => Date) {
const fnToUse = fn ?? propsNextPage;
placeholder.value = fnToUse
? toDateOnly(fnToUse(placeholder.value))
: addMonths(placeholder.value, pageStep());
}
function prevPage(fn?: (placeholder: Date) => Date) {
const fnToUse = fn ?? propsPrevPage;
placeholder.value = fnToUse
? toDateOnly(fnToUse(placeholder.value))
: addMonths(placeholder.value, -pageStep());
}
function nextYear() {
placeholder.value = addYears(placeholder.value, 1);
}
function prevYear() {
placeholder.value = addYears(placeholder.value, -1);
}
function isNextButtonDisabled(fn?: (placeholder: Date) => Date): boolean {
if (disabled) return true;
if (!maxValue) return false;
const lastMonth = grid.value[grid.value.length - 1]?.value;
if (!lastMonth) return false;
const fnToUse = fn ?? propsNextPage;
const probe = fnToUse
? toDateOnly(fnToUse(placeholder.value))
: addMonths(lastMonth, 1);
return isAfter(probe, maxValue);
}
function isPrevButtonDisabled(fn?: (placeholder: Date) => Date): boolean {
if (disabled) return true;
if (!minValue) return false;
const firstMonth = grid.value[0]?.value;
if (!firstMonth) return false;
const fnToUse = fn ?? propsPrevPage;
const probe = fnToUse
? toDateOnly(fnToUse(placeholder.value))
: addMonths(firstMonth, -1);
return isBefore(probe, minValue);
}
watch(modelValue, (v) => {
if (v && !isSameMonth(v, placeholder.value))
placeholder.value = toDateOnly(v);
});
onMounted(() => {
if (!initialFocus || !parentElement.value) return;
const target = parentElement.value.querySelector<HTMLElement>(
'[data-primitives-calendar-cell-trigger][data-selected]'
+ ',[data-primitives-calendar-cell-trigger][data-today]'
+ ',[data-primitives-calendar-cell-trigger]:not([data-outside-view]):not([data-disabled])',
);
target?.focus();
});
useEventListener(parentElement, 'focusout', (e) => {
if (!parentElement.value?.contains(e.relatedTarget as Node | null))
focusedDate.value = undefined;
});
provideCalendarRootContext({
modelValue,
placeholder,
locale: localeRef,
dir: dirRef,
grid,
weekDays,
headingValue,
fullCalendarLabel,
weekStartsOn: weekStartsOnRef,
weekdayFormat: weekdayFormatRef,
fixedWeeks: fixedWeeksRef,
numberOfMonths: numberOfMonthsRef,
disabled: disabledRef,
readonly: readonlyRef,
pagedNavigation: pagedNavigationRef,
minValue: minValueRef,
maxValue: maxValueRef,
isDateDisabled,
isDateUnavailable: isDateUnavailableLocal,
isDateSelected,
isOutsideVisibleView,
isInvalid,
parentElement,
focusedDate,
setDate,
setPlaceholder,
nextPage,
prevPage,
nextYear,
prevYear,
isNextButtonDisabled,
isPrevButtonDisabled,
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="application"
:aria-label="fullCalendarLabel"
:dir="dir"
:data-primitives-calendar-root="''"
:data-disabled="disabled ? '' : undefined"
:data-readonly="readonly ? '' : undefined"
:data-invalid="isInvalid ? '' : undefined"
>
<slot
:date="placeholder"
:grid="grid"
:week-days="weekDays"
:week-starts-on="weekStartsOn"
:locale="locale"
:model-value="modelValue"
/>
</Primitive>
</template>
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import {
addMonths,
getWeeks,
isDateUnavailable,
isSameDay,
startOfWeek,
} from '../date-utils';
describe('date-utils', () => {
it('getWeeks returns 6 rows × 7 cols', () => {
const weeks = getWeeks(new Date(2024, 0, 15), 0);
expect(weeks).toHaveLength(6);
for (const row of weeks)
expect(row).toHaveLength(7);
});
it('startOfWeek respects weekStartsOn', () => {
// 2024-01-10 is a Wednesday.
const wed = new Date(2024, 0, 10);
expect(startOfWeek(wed, 0).getDay()).toBe(0);
expect(startOfWeek(wed, 1).getDay()).toBe(1);
});
it('addMonths clamps Jan 31 → Feb 28/29', () => {
const r = addMonths(new Date(2023, 0, 31), 1);
expect(r.getMonth()).toBe(1);
expect(r.getDate()).toBe(28);
});
it('isSameDay ignores time component', () => {
const a = new Date(2024, 5, 1, 1, 2, 3);
const b = new Date(2024, 5, 1, 23, 59);
expect(isSameDay(a, b)).toBe(true);
expect(isSameDay(a, new Date(2024, 5, 2))).toBe(false);
});
it('isDateUnavailable honors min/max and predicate', () => {
const min = new Date(2024, 0, 5);
const max = new Date(2024, 0, 25);
expect(isDateUnavailable(new Date(2024, 0, 1), undefined, min, max)).toBe(true);
expect(isDateUnavailable(new Date(2024, 0, 31), undefined, min, max)).toBe(true);
expect(isDateUnavailable(new Date(2024, 0, 10), undefined, min, max)).toBe(false);
expect(isDateUnavailable(new Date(2024, 0, 10), d => d.getDate() === 10)).toBe(true);
});
});
+67
View File
@@ -0,0 +1,67 @@
import type { ComputedRef, Ref } from 'vue';
import type { CalendarMonth, WeekDayFormat } from './utils';
import { useContextFactory } from '@robonen/vue';
export interface CalendarRootContext {
/** Currently selected date (or undefined). */
modelValue: Ref<Date | undefined>;
/** Displayed month anchor. */
placeholder: Ref<Date>;
/** Locale identifier for `Intl` formatting. */
locale: Ref<string>;
/** Reading direction. */
dir: Ref<'ltr' | 'rtl'>;
/** Computed grid of months (each with 6×7 weeks). */
grid: ComputedRef<CalendarMonth[]>;
/** Localized weekday labels (length 7). */
weekDays: ComputedRef<string[]>;
/** Heading text (month + year). */
headingValue: ComputedRef<string>;
/** Full aria-label for the calendar region. */
fullCalendarLabel: ComputedRef<string>;
weekStartsOn: Ref<0 | 1 | 2 | 3 | 4 | 5 | 6>;
weekdayFormat: Ref<WeekDayFormat>;
fixedWeeks: Ref<boolean>;
numberOfMonths: Ref<number>;
disabled: Ref<boolean>;
readonly: Ref<boolean>;
pagedNavigation: Ref<boolean>;
minValue: Ref<Date | undefined>;
maxValue: Ref<Date | undefined>;
isDateDisabled: (date: Date) => boolean;
isDateUnavailable: (date: Date) => boolean;
isDateSelected: (date: Date) => boolean;
isOutsideVisibleView: (date: Date) => boolean;
isInvalid: ComputedRef<boolean>;
/** Element hosting the calendar grid(s); used for keyboard focus shifting. */
parentElement: Ref<HTMLElement | undefined>;
/** Currently focused day, drives `tabindex`. */
focusedDate: Ref<Date | undefined>;
setDate: (date: Date | undefined) => void;
setPlaceholder: (date: Date) => void;
nextPage: (fn?: (placeholder: Date) => Date) => void;
prevPage: (fn?: (placeholder: Date) => Date) => void;
nextYear: () => void;
prevYear: () => void;
isNextButtonDisabled: (fn?: (placeholder: Date) => Date) => boolean;
isPrevButtonDisabled: (fn?: (placeholder: Date) => Date) => boolean;
}
const ctx = useContextFactory<CalendarRootContext>('CalendarRoot');
export const provideCalendarRootContext = ctx.provide;
export const useCalendarRootContext = ctx.inject;
export interface CalendarGridContext {
/** The month this `<table>` is rendering. */
month: Ref<Date>;
}
const gridCtx = useContextFactory<CalendarGridContext>('CalendarGrid');
export const provideCalendarGridContext = gridCtx.provide;
export const useCalendarGridContext = gridCtx.inject;
+125
View File
@@ -0,0 +1,125 @@
export type WeekDayFormat = 'narrow' | 'short' | 'long';
export interface DateRange {
start?: Date;
end?: Date;
}
export function toDateOnly(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
}
export function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate();
}
export function isSameMonth(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
}
export function isBefore(a: Date, b: Date): boolean {
return toDateOnly(a).getTime() < toDateOnly(b).getTime();
}
export function isAfter(a: Date, b: Date): boolean {
return toDateOnly(a).getTime() > toDateOnly(b).getTime();
}
export function addDays(d: Date, n: number): Date {
const r = toDateOnly(d);
r.setDate(r.getDate() + n);
return r;
}
export function addMonths(d: Date, n: number): Date {
const r = toDateOnly(d);
const day = r.getDate();
// Move to first of month, shift, then clamp day to month length.
r.setDate(1);
r.setMonth(r.getMonth() + n);
const lastDay = new Date(r.getFullYear(), r.getMonth() + 1, 0).getDate();
r.setDate(Math.min(day, lastDay));
return r;
}
export function addYears(d: Date, n: number): Date {
return addMonths(d, n * 12);
}
export function startOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1, 0, 0, 0, 0);
}
export function endOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth() + 1, 0, 0, 0, 0, 0);
}
export function getDaysInMonth(d: Date): number {
return endOfMonth(d).getDate();
}
export function startOfWeek(d: Date, weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6 = 0): Date {
const r = toDateOnly(d);
const day = r.getDay();
const diff = (day - weekStartsOn + 7) % 7;
r.setDate(r.getDate() - diff);
return r;
}
/**
* Returns a 6×7 matrix of dates for the month containing `month`,
* padded with leading/trailing days from adjacent months.
*/
export function getWeeks(month: Date, weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6 = 0): Date[][] {
const first = startOfMonth(month);
const gridStart = startOfWeek(first, weekStartsOn);
const weeks: Date[][] = [];
for (let w = 0; w < 6; w++) {
const row: Date[] = [];
for (let i = 0; i < 7; i++)
row.push(addDays(gridStart, w * 7 + i));
weeks.push(row);
}
return weeks;
}
export function clamp(date: Date, min?: Date, max?: Date): Date {
if (min && isBefore(date, min))
return toDateOnly(min);
if (max && isAfter(date, max))
return toDateOnly(max);
return toDateOnly(date);
}
export function isDateUnavailable(
d: Date,
predicate?: (d: Date) => boolean,
min?: Date,
max?: Date,
): boolean {
if (min && isBefore(d, min))
return true;
if (max && isAfter(d, max))
return true;
if (predicate?.(d))
return true;
return false;
}
export function formatDate(
d: Date,
opts: Intl.DateTimeFormatOptions,
locale: string,
): string {
return new Intl.DateTimeFormat(locale, opts).format(d);
}
export function formatWeekday(
d: Date,
locale: string,
width: WeekDayFormat = 'short',
): string {
return new Intl.DateTimeFormat(locale, { weekday: width }).format(d);
}
+42
View File
@@ -0,0 +1,42 @@
export { default as CalendarRoot } from './CalendarRoot.vue';
export { default as CalendarHeader } from './CalendarHeader.vue';
export { default as CalendarHeading } from './CalendarHeading.vue';
export { default as CalendarPrev } from './CalendarPrev.vue';
export { default as CalendarNext } from './CalendarNext.vue';
export { default as CalendarGrid } from './CalendarGrid.vue';
export { default as CalendarGridHead } from './CalendarGridHead.vue';
export { default as CalendarGridBody } from './CalendarGridBody.vue';
export { default as CalendarGridRow } from './CalendarGridRow.vue';
export { default as CalendarHeadCell } from './CalendarHeadCell.vue';
export { default as CalendarCell } from './CalendarCell.vue';
export { default as CalendarCellTrigger } from './CalendarCellTrigger.vue';
export {
provideCalendarRootContext,
useCalendarRootContext,
provideCalendarGridContext,
useCalendarGridContext,
} from './context';
export type {
CalendarRootContext,
CalendarGridContext,
} from './context';
export * from './utils';
export type { CalendarRootEmits, CalendarRootProps } from './CalendarRoot.vue';
export type { CalendarHeaderProps } from './CalendarHeader.vue';
export type { CalendarHeadingProps } from './CalendarHeading.vue';
export type { CalendarPrevProps } from './CalendarPrev.vue';
export type { CalendarNextProps } from './CalendarNext.vue';
export type { CalendarGridProps } from './CalendarGrid.vue';
export type { CalendarGridHeadProps } from './CalendarGridHead.vue';
export type { CalendarGridBodyProps } from './CalendarGridBody.vue';
export type { CalendarGridRowProps } from './CalendarGridRow.vue';
export type { CalendarHeadCellProps } from './CalendarHeadCell.vue';
export type { CalendarCellProps } from './CalendarCell.vue';
export type {
CalendarCellTriggerProps,
CalendarCellTriggerSlotProps,
} from './CalendarCellTrigger.vue';
+64
View File
@@ -0,0 +1,64 @@
import type { WeekDayFormat } from './date-utils';
import {
addMonths,
formatDate,
formatWeekday,
getWeeks,
startOfMonth,
startOfWeek,
} from './date-utils';
export * from './date-utils';
export interface CalendarMonth {
/** First day of this month (date-only). */
value: Date;
/** 6×7 grid of dates including leading/trailing adjacent-month days. */
weeks: Date[][];
}
export interface CreateMonthsOptions {
date: Date;
numberOfMonths: number;
weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6;
}
/** Build N consecutive months starting from `date`'s month. */
export function createMonths(opts: CreateMonthsOptions): CalendarMonth[] {
const months: CalendarMonth[] = [];
for (let i = 0; i < opts.numberOfMonths; i++) {
const m = startOfMonth(addMonths(opts.date, i));
months.push({ value: m, weeks: getWeeks(m, opts.weekStartsOn) });
}
return months;
}
/** Localized short/narrow/long weekday names starting from `weekStartsOn`. */
export function getWeekdayLabels(
weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6,
locale: string,
width: WeekDayFormat,
): string[] {
// Pick any known Sunday (1970-01-04 is a Sunday) as anchor.
const anchorSunday = new Date(1970, 0, 4);
const start = startOfWeek(anchorSunday, weekStartsOn);
const labels: string[] = [];
for (let i = 0; i < 7; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
labels.push(formatWeekday(d, locale, width));
}
return labels;
}
export function formatMonthYear(d: Date, locale: string): string {
return formatDate(d, { month: 'long', year: 'numeric' }, locale);
}
export function formatFullDate(d: Date, locale: string): string {
return formatDate(
d,
{ weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' },
locale,
);
}
@@ -0,0 +1,32 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CheckboxIndicatorProps extends PrimitiveProps {
/** Keep mounted even when unchecked (for CSS exit animations). */
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { useCheckboxContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'span', forceMount = false } = defineProps<CheckboxIndicatorProps>();
const ctx = useCheckboxContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
v-if="forceMount || ctx.checked.value !== false"
:data-state="ctx.checked.value === 'indeterminate' ? 'indeterminate' : (ctx.checked.value ? 'checked' : 'unchecked')"
:data-disabled="ctx.disabled.value ? '' : undefined"
style="pointer-events: none;"
>
<slot :checked="ctx.checked.value" />
</Primitive>
</template>
@@ -0,0 +1,99 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
import type { CheckedState } from './context';
export interface CheckboxRootProps extends PrimitiveProps {
/** Uncontrolled initial checked state. */
defaultChecked?: CheckedState;
/** Disable interaction. */
disabled?: boolean;
/** Mark associated hidden input as required. */
required?: boolean;
/** Hidden input name attribute. */
name?: string;
/** Hidden input value attribute. @default 'on' */
value?: string;
}
export interface CheckboxRootEmits {
checkedChange: [value: CheckedState];
}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { ref, toRef, watch } from 'vue';
import { provideCheckboxContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { disabled = false, required = false, value = 'on', defaultChecked, name, as = 'button' } = defineProps<CheckboxRootProps>();
const { forwardRef } = useForwardExpose();
const emit = defineEmits<CheckboxRootEmits>();
const model = defineModel<CheckedState | undefined>('checked', { default: undefined });
const localChecked = ref<CheckedState>(model.value ?? defaultChecked ?? false);
watch(model, (v) => {
if (v === undefined) return;
if (v !== localChecked.value) localChecked.value = v;
});
function setChecked(v: CheckedState): void {
localChecked.value = v;
model.value = v;
emit('checkedChange', v);
}
function toggle(): void {
if (disabled) return;
setChecked(localChecked.value !== true);
}
function onKeyDown(event: KeyboardEvent): void {
// Prevent form submit on Enter when inside a form.
if (event.key === 'Enter') event.preventDefault();
}
provideCheckboxContext({
// `localChecked` is already a `Ref<CheckedState>`; forward directly without
// wrapping in a computed. `toRef(() => disabled)` gives a reactive identity
// passthrough without `ReactiveEffect`/cache.
checked: localChecked,
disabled: toRef(() => disabled),
});
// Inlined in template — no need for a cached computed for a single call site.
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
role="checkbox"
:aria-checked="localChecked === 'indeterminate' ? 'mixed' : localChecked"
:aria-required="required || undefined"
:aria-disabled="disabled || undefined"
:data-state="localChecked === 'indeterminate' ? 'indeterminate' : (localChecked ? 'checked' : 'unchecked')"
:data-disabled="disabled ? '' : undefined"
:disabled="disabled || undefined"
@click="toggle"
@keydown="onKeyDown"
>
<slot :checked="localChecked" />
<input
v-if="name"
type="checkbox"
tabindex="-1"
aria-hidden="true"
:name="name"
:value="value"
:checked="localChecked === true"
:required="required"
:disabled="disabled"
style="position: absolute; pointer-events: none; opacity: 0; margin: 0; transform: translateX(-100%);"
>
</Primitive>
</template>
@@ -0,0 +1,109 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { CheckboxIndicator, CheckboxRoot } from '../index';
function mountCheckbox(props: Record<string, unknown> = {}) {
return mount(CheckboxRoot, {
attachTo: document.body,
props,
slots: {
default: () => h(CheckboxIndicator, null, { default: () => '✓' }),
},
});
}
describe('Checkbox', () => {
it('renders role="checkbox" with aria-checked="false" initially', () => {
const w = mountCheckbox();
const el = w.element;
expect(el.getAttribute('role')).toBe('checkbox');
expect(el.getAttribute('aria-checked')).toBe('false');
expect(el.getAttribute('data-state')).toBe('unchecked');
w.unmount();
});
it('toggles on click', async () => {
const w = mountCheckbox();
const el = w.element as HTMLElement;
el.click();
await nextTick();
expect(el.getAttribute('aria-checked')).toBe('true');
expect(el.getAttribute('data-state')).toBe('checked');
el.click();
await nextTick();
expect(el.getAttribute('aria-checked')).toBe('false');
w.unmount();
});
it('honours defaultChecked', () => {
const w = mountCheckbox({ defaultChecked: true });
expect(w.element.getAttribute('aria-checked')).toBe('true');
w.unmount();
});
it('supports indeterminate state with aria-checked="mixed"', async () => {
const checked = ref<boolean | 'indeterminate'>('indeterminate');
const Harness = defineComponent({
setup: () => () => h(CheckboxRoot, {
checked: checked.value,
'onUpdate:checked': (v: boolean | 'indeterminate' | undefined) => { checked.value = v!; },
}, { default: () => h(CheckboxIndicator) }),
});
const w = mount(Harness, { attachTo: document.body });
expect(w.element.getAttribute('aria-checked')).toBe('mixed');
(w.element as HTMLElement).click();
await nextTick();
// Click from indeterminate → true
expect(checked.value).toBe(true);
w.unmount();
});
it('disabled: no toggle on click, aria-disabled set', async () => {
const w = mountCheckbox({ disabled: true });
const el = w.element as HTMLElement;
expect(el.getAttribute('aria-disabled')).toBe('true');
el.click();
await nextTick();
expect(el.getAttribute('aria-checked')).toBe('false');
w.unmount();
});
it('emits checkedChange', async () => {
const w = mountCheckbox();
(w.element as HTMLElement).click();
await nextTick();
expect(w.emitted('checkedChange')).toEqual([[true]]);
w.unmount();
});
it('renders hidden input when name is set', async () => {
const w = mountCheckbox({ name: 'agree', value: 'yes', defaultChecked: true });
const input = w.element.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(input).toBeTruthy();
expect(input.name).toBe('agree');
expect(input.value).toBe('yes');
expect(input.checked).toBe(true);
w.unmount();
});
it('CheckboxIndicator only renders when checked (or forceMount)', async () => {
const w = mountCheckbox();
expect(w.element.querySelector('span')).toBeNull();
(w.element as HTMLElement).click();
await nextTick();
expect(w.element.querySelector('span')).toBeTruthy();
w.unmount();
});
it('CheckboxIndicator forceMount stays mounted when unchecked', () => {
const w = mount(CheckboxRoot, {
attachTo: document.body,
slots: {
default: () => h(CheckboxIndicator, { forceMount: true }, { default: () => '✓' }),
},
});
expect(w.element.querySelector('span')).toBeTruthy();
w.unmount();
});
});
+14
View File
@@ -0,0 +1,14 @@
import type { Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export type CheckedState = boolean | 'indeterminate';
export interface CheckboxContext {
checked: Ref<CheckedState>;
disabled: Ref<boolean>;
}
const ctx = useContextFactory<CheckboxContext>('CheckboxContext');
export const provideCheckboxContext = ctx.provide;
export const useCheckboxContext = ctx.inject;
+5
View File
@@ -0,0 +1,5 @@
export { default as CheckboxIndicator } from './CheckboxIndicator.vue';
export { default as CheckboxRoot } from './CheckboxRoot.vue';
export type { CheckedState } from './context';
export type { CheckboxIndicatorProps } from './CheckboxIndicator.vue';
export type { CheckboxRootEmits, CheckboxRootProps } from './CheckboxRoot.vue';
@@ -0,0 +1,36 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CollapsibleContentProps extends PrimitiveProps {
/** Render the content even when closed (useful for animation control). */
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { Presence } from '../presence';
import { Primitive } from '../primitive';
import { useCollapsibleContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'div', forceMount = false } = defineProps<CollapsibleContentProps>();
const { forwardRef } = useForwardExpose();
const ctx = useCollapsibleContext();
</script>
<template>
<Presence :present="forceMount || ctx.open.value">
<Primitive
:ref="forwardRef"
:id="ctx.contentId.value"
:as="as"
:data-state="ctx.open.value ? 'open' : 'closed'"
:data-disabled="ctx.disabled.value ? '' : undefined"
:hidden="!ctx.open.value ? true : undefined"
>
<slot :open="ctx.open.value" />
</Primitive>
</Presence>
</template>
@@ -0,0 +1,56 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CollapsibleRootProps extends PrimitiveProps {
defaultOpen?: boolean;
disabled?: boolean;
}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { ref, toRef } from 'vue';
import { provideCollapsibleContext } from './context';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../config-provider';
const { defaultOpen = false, disabled = false, as = 'div' } = defineProps<CollapsibleRootProps>();
const { forwardRef } = useForwardExpose();
const localOpen = ref<boolean>(defaultOpen);
const open = defineModel<boolean>('open', {
default: undefined,
get: v => v ?? localOpen.value,
set: (v) => {
localOpen.value = v;
return v;
},
});
// Identity passthrough via `toRef` — reactive without `computed`'s effect/cache.
const disabledRef = toRef(() => disabled);
const contentId = useId(undefined, 'collapsible-content');
provideCollapsibleContext({
open,
disabled: disabledRef,
contentId,
onToggle: () => { if (!disabled) open.value = !open.value; },
onOpen: () => { if (!disabled) open.value = true; },
onClose: () => { if (!disabled) open.value = false; },
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:data-state="open ? 'open' : 'closed'"
:data-disabled="disabled ? '' : undefined"
>
<slot :open="open" />
</Primitive>
</template>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CollapsibleTriggerProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { useCollapsibleContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'button' } = defineProps<CollapsibleTriggerProps>();
const { forwardRef } = useForwardExpose();
const ctx = useCollapsibleContext();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
:aria-expanded="ctx.open.value"
:aria-controls="ctx.contentId.value"
:data-state="ctx.open.value ? 'open' : 'closed'"
:data-disabled="ctx.disabled.value ? '' : undefined"
:disabled="as === 'button' ? ctx.disabled.value : undefined"
@click="ctx.onToggle"
>
<slot :open="ctx.open.value" />
</Primitive>
</template>
@@ -0,0 +1,65 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from '../index';
function mountCollapsible(props: Record<string, unknown> = {}) {
return mount(defineComponent({
setup: () => () => h(CollapsibleRoot, props, {
default: () => [
h(CollapsibleTrigger, { class: 'trig' }, { default: () => 'Toggle' }),
h(CollapsibleContent, { class: 'c' }, { default: () => 'Body' }),
],
}),
}));
}
describe('Collapsible', () => {
it('starts closed by default; trigger toggles state', async () => {
const w = mountCollapsible();
const trigger = w.find('.trig');
expect(trigger.attributes('aria-expanded')).toBe('false');
expect(w.find('.c').exists()).toBe(false);
await trigger.trigger('click');
expect(trigger.attributes('aria-expanded')).toBe('true');
expect(w.find('.c').exists()).toBe(true);
});
it('opens via defaultOpen', async () => {
const w = mountCollapsible({ defaultOpen: true });
await nextTick();
expect(w.find('.trig').attributes('aria-expanded')).toBe('true');
expect(w.find('.c').exists()).toBe(true);
expect(w.find('.c').text()).toBe('Body');
});
it('wires aria-controls to content id', async () => {
const w = mountCollapsible({ defaultOpen: true });
await nextTick();
const id = w.find('.c').attributes('id');
expect(id).toMatch(/collapsible-content/);
expect(w.find('.trig').attributes('aria-controls')).toBe(id);
});
it('respects disabled', async () => {
const w = mountCollapsible({ disabled: true });
await w.find('.trig').trigger('click');
expect(w.find('.trig').attributes('aria-expanded')).toBe('false');
expect(w.find('.trig').attributes('data-disabled')).toBe('');
});
it('forceMount keeps content in DOM when closed', () => {
const w = mount(defineComponent({
setup: () => () => h(CollapsibleRoot, null, {
default: () => [
h(CollapsibleTrigger, { class: 'trig' }),
h(CollapsibleContent, { class: 'c', forceMount: true }, { default: () => 'Body' }),
],
}),
}));
const content = w.find('.c');
expect(content.exists()).toBe(true);
expect(content.attributes('hidden')).toBeDefined();
expect(content.attributes('data-state')).toBe('closed');
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { ComputedRef, Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export interface CollapsibleContext {
open: Ref<boolean>;
disabled: Ref<boolean>;
contentId: ComputedRef<string>;
onToggle: () => void;
onOpen: () => void;
onClose: () => void;
}
const ctx = useContextFactory<CollapsibleContext>('CollapsibleContext');
export const provideCollapsibleContext = ctx.provide;
export const useCollapsibleContext = ctx.inject;
+8
View File
@@ -0,0 +1,8 @@
export { default as CollapsibleRoot } from './CollapsibleRoot.vue';
export { default as CollapsibleTrigger } from './CollapsibleTrigger.vue';
export { default as CollapsibleContent } from './CollapsibleContent.vue';
export type { CollapsibleRootProps } from './CollapsibleRoot.vue';
export type { CollapsibleTriggerProps } from './CollapsibleTrigger.vue';
export type { CollapsibleContentProps } from './CollapsibleContent.vue';
export { provideCollapsibleContext, useCollapsibleContext } from './context';
export type { CollapsibleContext } from './context';
+6
View File
@@ -0,0 +1,6 @@
export {
useCollectionProvider,
useCollectionInjector,
type CollectionContext,
type CollectionItemData,
} from './useCollection';
@@ -0,0 +1,185 @@
import type { ComputedRef, DefineComponent, ShallowRef } from 'vue';
import {
computed,
defineComponent,
h,
markRaw,
shallowRef,
triggerRef,
watch,
} from 'vue';
import { unrefElement, useContextFactory } from '@robonen/vue';
import { Slot } from '../primitive';
/**
* Data attribute used to locate items inside a collection via `querySelectorAll`.
* Rendered automatically by `<CollectionItem>`.
*/
const ITEM_DATA_ATTR = 'data-collection-item';
export interface CollectionItemData<Value = unknown> {
/** DOM element that represents the item. */
ref: HTMLElement;
/** Arbitrary `value` associated with the item via `<CollectionItem :value>`. */
value?: Value;
}
export interface CollectionContext<Value = unknown> {
/** Root element of the collection (set by `<CollectionSlot>`). */
collectionRef: ShallowRef<HTMLElement | undefined>;
/** Raw element→data map. Mutated via `triggerRef` — do not rely on deep reactivity. */
itemMap: ShallowRef<Map<HTMLElement, CollectionItemData<Value>>>;
/**
* Returns items sorted by their DOM order. Items with `data-disabled` are
* skipped unless `includeDisabled` is `true`.
*
* The ordering comes from `collectionRef.querySelectorAll(...)`, which means
* it survives `<Teleport>`, `<Suspense>` and `v-for` reorders — unlike a
* mount-order based registry.
*/
getItems: (includeDisabled?: boolean) => Array<CollectionItemData<Value>>;
/** Reactive snapshot of all items (unsorted). Invalidated when `itemMap` changes. */
reactiveItems: ComputedRef<Array<CollectionItemData<Value>>>;
/** Reactive count of items. */
itemMapSize: ComputedRef<number>;
/** Root marker component — render at the collection's root. */
CollectionSlot: DefineComponent;
/** Item marker component — wrap each focusable/selectable child. */
CollectionItem: DefineComponent<{ value?: unknown }>;
}
function createCollectionState<Value = unknown>(): CollectionContext<Value> {
// `shallowRef` + manual `triggerRef` avoids wrapping the Map in a deep Proxy.
// For collections with many items (large lists, menus, listboxes) this is
// measurably cheaper than `ref(new Map())`.
const collectionRef = shallowRef<HTMLElement>();
const itemMap = shallowRef(
new Map<HTMLElement, CollectionItemData<Value>>(),
);
const getItems = (includeDisabled = false) => {
const collectionNode = collectionRef.value;
if (!collectionNode) return [];
const orderedNodes = Array.from(
collectionNode.querySelectorAll(`[${ITEM_DATA_ATTR}]`),
);
const items = Array.from(itemMap.value.values());
items.sort(
(a, b) => orderedNodes.indexOf(a.ref) - orderedNodes.indexOf(b.ref),
);
return includeDisabled
? items
: items.filter(i => i.ref.dataset['disabled'] !== '');
};
const CollectionSlot = defineComponent({
name: 'CollectionSlot',
inheritAttrs: false,
setup(_, { slots, attrs }) {
return () =>
h(
Slot,
{
...attrs,
ref: (el: unknown) => {
const element = unrefElement(el as Parameters<typeof unrefElement>[0]);
if (element instanceof HTMLElement) {
collectionRef.value = element;
}
},
},
slots,
);
},
}) as DefineComponent;
const CollectionItem = defineComponent({
name: 'CollectionItem',
inheritAttrs: false,
props: {
value: {
// Accepts any value.
validator: () => true,
},
},
setup(props, { slots, attrs }) {
const currentElement = shallowRef<HTMLElement>();
watch(
[currentElement, () => props.value],
([el], _prev, onCleanup) => {
if (!el) return;
// `markRaw` keeps Vue from trying to make the element reactive —
// we only care about identity as a Map key.
const key = markRaw(el);
itemMap.value.set(key, { ref: el, value: props.value as Value });
triggerRef(itemMap);
onCleanup(() => {
itemMap.value.delete(key);
triggerRef(itemMap);
});
},
{ immediate: true },
);
return () =>
h(
Slot,
{
...attrs,
[ITEM_DATA_ATTR]: '',
ref: (el: unknown) => {
const element = unrefElement(el as Parameters<typeof unrefElement>[0]);
if (element instanceof HTMLElement) {
currentElement.value = element;
}
},
},
slots,
);
},
}) as DefineComponent<{ value?: unknown }>;
const reactiveItems = computed(() => Array.from(itemMap.value.values()));
const itemMapSize = computed(() => itemMap.value.size);
return {
collectionRef,
itemMap,
getItems,
reactiveItems,
itemMapSize,
CollectionSlot,
CollectionItem,
};
}
const CollectionCtx = useContextFactory<CollectionContext>('CollectionContext');
/**
* Creates a new collection state and provides it to descendants.
* Call this in the parent (e.g. `RovingFocusGroup`, `ListboxRoot`).
*
* @example
* ```ts
* const { getItems, CollectionSlot } = useCollectionProvider();
* ```
*/
export function useCollectionProvider<Value = unknown>(): CollectionContext<Value> {
const ctx = createCollectionState<Value>();
CollectionCtx.provide(ctx as CollectionContext);
return ctx;
}
/**
* Injects the collection context from the nearest `useCollectionProvider()`.
* Call this in children (e.g. `RovingFocusItem`, `ListboxItem`).
*
* @throws when used outside a provider.
*/
export function useCollectionInjector<Value = unknown>(): CollectionContext<Value> {
return CollectionCtx.inject() as CollectionContext<Value>;
}
@@ -0,0 +1,33 @@
<script lang="ts">
import type { PopperAnchorProps } from '../popper';
export interface ComboboxAnchorProps extends PopperAnchorProps {}
</script>
<script setup lang="ts">
import { onBeforeUnmount, watchPostEffect } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { PopperAnchor } from '../popper';
import { Primitive } from '../primitive';
import { useComboboxRootContext } from './context';
const props = defineProps<ComboboxAnchorProps>();
const { forwardRef, currentElement } = useForwardExpose();
const rootCtx = useComboboxRootContext();
watchPostEffect(() => rootCtx.onParentChange(currentElement.value));
onBeforeUnmount(() => rootCtx.onParentChange(undefined));
</script>
<template>
<PopperAnchor :reference="props.reference">
<Primitive
:ref="forwardRef"
:as="props.as ?? 'div'"
>
<slot />
</Primitive>
</PopperAnchor>
</template>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { PopperArrowProps } from '../popper';
export type ComboboxArrowProps = PopperArrowProps;
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { PopperArrow } from '../popper';
import { useComboboxRootContext } from './context';
const props = defineProps<ComboboxArrowProps>();
const { forwardRef } = useForwardExpose();
const rootCtx = useComboboxRootContext();
</script>
<template>
<PopperArrow
v-if="rootCtx.open.value"
:ref="forwardRef"
v-bind="props"
/>
</template>
@@ -0,0 +1,40 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxCancelProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxRootContext } from './context';
const { as = 'button' } = defineProps<ComboboxCancelProps>();
const { forwardRef } = useForwardExpose();
const rootCtx = useComboboxRootContext();
function handleClick() {
rootCtx.onSearchTermChange('');
const input = rootCtx.inputElement.value;
if (input) {
input.value = '';
input.focus();
}
rootCtx.onUserInputtedChange(false);
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
tabindex="-1"
aria-label="Clear"
@click="handleClick"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,30 @@
<script lang="ts">
import type { ComboboxContentImplEmits, ComboboxContentImplProps } from './ComboboxContentImpl.vue';
export type ComboboxContentProps = ComboboxContentImplProps;
export type ComboboxContentEmits = ComboboxContentImplEmits;
</script>
<script setup lang="ts">
import { Presence } from '../presence';
import ComboboxContentImpl from './ComboboxContentImpl.vue';
import { useComboboxRootContext } from './context';
const props = defineProps<ComboboxContentProps>();
const emit = defineEmits<ComboboxContentEmits>();
const rootCtx = useComboboxRootContext();
</script>
<template>
<Presence :present="rootCtx.open.value">
<ComboboxContentImpl
v-bind="props"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
>
<slot />
</ComboboxContentImpl>
</Presence>
</template>
@@ -0,0 +1,139 @@
<script lang="ts">
import type { DismissableLayerEmits } from '../dismissable-layer';
import type { FocusScopeEmits } from '../focus-scope';
import type { PopperContentProps } from '../popper';
import type { PrimitiveProps } from '../primitive';
export interface ComboboxContentImplProps extends PrimitiveProps, /* @vue-ignore */ Partial<PopperContentProps> {
/** Position strategy. @default 'popper' */
position?: 'inline' | 'popper';
/** Block outside pointer events. @default false */
disableOutsidePointerEvents?: boolean;
}
export interface ComboboxContentImplEmits {
closeAutoFocus: FocusScopeEmits['unmountAutoFocus'];
escapeKeyDown: DismissableLayerEmits['escapeKeyDown'];
pointerDownOutside: DismissableLayerEmits['pointerDownOutside'];
focusOutside: FocusScopeEmits['unmountAutoFocus'];
}
</script>
<script setup lang="ts">
import { onBeforeUnmount, shallowRef, toRef, watchPostEffect } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { DismissableLayer } from '../dismissable-layer';
import { FocusScope } from '../focus-scope';
import { PopperContent } from '../popper';
import { Primitive } from '../primitive';
import { VisuallyHidden } from '../visually-hidden';
import { useHideOthers } from '../utils/useHideOthers';
import { provideComboboxContentContext, useComboboxRootContext } from './context';
const props = defineProps<ComboboxContentImplProps>();
const emit = defineEmits<ComboboxContentImplEmits>();
const { forwardRef, currentElement } = useForwardExpose();
const rootCtx = useComboboxRootContext();
const viewportElement = shallowRef<HTMLElement | undefined>(undefined);
watchPostEffect(() => rootCtx.onContentChange(currentElement.value));
onBeforeUnmount(() => rootCtx.onContentChange(undefined));
useHideOthers(toRef(() => rootCtx.parentElement.value));
provideComboboxContentContext({
viewportElement,
onViewportChange: (el) => { viewportElement.value = el; },
position: toRef(() => props.position ?? 'popper'),
});
function handleEscape(event: KeyboardEvent) {
rootCtx.onOpenChange(false);
emit('escapeKeyDown', event);
}
function handlePointerDownOutside(event: any) {
const target = event.target as Element | null;
const input = rootCtx.inputElement.value;
const trigger = rootCtx.triggerElement.value;
if (target && (input?.contains(target) || trigger?.contains(target))) {
event.preventDefault();
return;
}
emit('pointerDownOutside', event);
if (!event.defaultPrevented) rootCtx.onOpenChange(false);
}
function handleFocusOutside(event: any) {
emit('focusOutside', event);
}
function handleCloseAutoFocus(event: Event) {
emit('closeAutoFocus', event);
}
</script>
<template>
<FocusScope
as="template"
:loop="false"
:trapped="false"
@mount-auto-focus.prevent
@unmount-auto-focus="handleCloseAutoFocus"
>
<DismissableLayer
as="template"
:disable-outside-pointer-events="props.disableOutsidePointerEvents ?? false"
@escape-key-down="handleEscape"
@pointer-down-outside="handlePointerDownOutside"
@focus-outside="handleFocusOutside"
@dismiss="rootCtx.onOpenChange(false)"
>
<PopperContent
v-if="(props.position ?? 'popper') === 'popper'"
:ref="forwardRef"
:as="props.as ?? 'div'"
:side="props.side ?? 'bottom'"
:side-offset="props.sideOffset ?? 4"
:align="props.align ?? 'start'"
:align-offset="props.alignOffset"
:avoid-collisions="props.avoidCollisions"
:collision-boundary="props.collisionBoundary"
:collision-padding="props.collisionPadding"
:arrow-padding="props.arrowPadding"
:sticky="props.sticky"
:hide-when-detached="props.hideWhenDetached"
:update-position-strategy="props.updatePositionStrategy"
:id="rootCtx.contentId.value"
role="listbox"
:aria-multiselectable="rootCtx.multiple.value || undefined"
:data-state="rootCtx.open.value ? 'open' : 'closed'"
data-primitives-combobox-content
>
<VisuallyHidden role="status" aria-live="polite" data-primitives-combobox-announce>
{{ rootCtx.filterState.value.count === 1 ? '1 result available.' : `${rootCtx.filterState.value.count} results available.` }}
</VisuallyHidden>
<slot />
</PopperContent>
<Primitive
v-else
:ref="forwardRef"
:as="props.as ?? 'div'"
:id="rootCtx.contentId.value"
role="listbox"
:aria-multiselectable="rootCtx.multiple.value || undefined"
:data-state="rootCtx.open.value ? 'open' : 'closed'"
data-primitives-combobox-content
>
<VisuallyHidden role="status" aria-live="polite" data-primitives-combobox-announce>
{{ rootCtx.filterState.value.count === 1 ? '1 result available.' : `${rootCtx.filterState.value.count} results available.` }}
</VisuallyHidden>
<slot />
</Primitive>
</DismissableLayer>
</FocusScope>
</template>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxEmptyProps extends PrimitiveProps {
/** Render even when items exist but none are filtered out. */
always?: boolean;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxRootContext } from './context';
const { as = 'div', always = false } = defineProps<ComboboxEmptyProps>();
const { forwardRef } = useForwardExpose();
const rootCtx = useComboboxRootContext();
const shouldRender = computed(() => {
if (always) return true;
return rootCtx.filterState.value.count === 0;
});
</script>
<template>
<Primitive
v-if="shouldRender"
:ref="forwardRef"
:as="as"
role="presentation"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,39 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxGroupProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../config-provider';
import { Primitive } from '../primitive';
import { provideComboboxGroupContext, useComboboxRootContext } from './context';
const { as = 'div' } = defineProps<ComboboxGroupProps>();
const { forwardRef } = useForwardExpose();
const rootCtx = useComboboxRootContext();
const id = useId(undefined, 'combobox-group');
const isVisible = computed(() => rootCtx.filterState.value.groups.has(id.value));
onMounted(() => rootCtx.onGroupRegister(id.value));
onBeforeUnmount(() => rootCtx.onGroupUnregister(id.value));
provideComboboxGroupContext({ id });
</script>
<template>
<Primitive
v-show="isVisible"
:ref="forwardRef"
:as="as"
role="group"
:aria-labelledby="id"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,221 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxInputProps extends PrimitiveProps {
/** Disable the input. */
disabled?: boolean;
/** Focus the input on mount. */
autoFocus?: boolean;
/** Open the combobox when the input is focused. */
openOnFocus?: boolean;
/** Open the combobox when the input is clicked. */
openOnClick?: boolean;
}
</script>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxRootContext } from './context';
import { OPEN_KEYS } from './utils';
const {
as = 'input',
disabled = false,
autoFocus = false,
openOnFocus = false,
openOnClick = false,
} = defineProps<ComboboxInputProps>();
const { forwardRef, currentElement } = useForwardExpose();
const rootCtx = useComboboxRootContext();
const isDisabled = computed(() => disabled || rootCtx.disabled.value);
const activeDescendant = computed(() => rootCtx.selectedValueId.value);
function displayString(value: unknown): string {
if (rootCtx.displayValue) return rootCtx.displayValue(value);
if (value === undefined || value === null) return '';
if (Array.isArray(value)) return '';
if (typeof value === 'object') return '';
return String(value);
}
function syncDisplayValue() {
const input = currentElement.value as HTMLInputElement | undefined;
if (!input) return;
const next = displayString(rootCtx.modelValue.value);
if (input.value !== next) input.value = next;
}
onMounted(() => {
const el = currentElement.value as HTMLInputElement | undefined;
rootCtx.onInputChange(el);
if (el) {
el.value = rootCtx.searchTerm.value || displayString(rootCtx.modelValue.value);
}
if (autoFocus) setTimeout(() => el?.focus(), 1);
});
onBeforeUnmount(() => rootCtx.onInputChange(undefined));
watch(() => rootCtx.modelValue.value, () => {
if (rootCtx.isUserInputted.value) return;
if (!rootCtx.resetSearchTermOnSelect.value && rootCtx.searchTerm.value) return;
rootCtx.onSearchTermChange('');
syncDisplayValue();
}, { deep: true });
watch(() => rootCtx.searchTerm.value, (v) => {
const input = currentElement.value as HTMLInputElement | undefined;
if (!input) return;
if (!v && !rootCtx.isUserInputted.value) {
syncDisplayValue();
return;
}
if (input.value !== v) input.value = v;
});
watch(() => rootCtx.filterState.value, (newState, oldState) => {
if (oldState && oldState.count === 0 && newState.count > 0) {
rootCtx.highlightFirstItem();
}
});
function moveHighlight(delta: number) {
const els = rootCtx.getVisibleItemElements();
if (els.length === 0) return;
const curId = rootCtx.selectedValueId.value;
let idx = -1;
if (curId) {
for (let i = 0; i < els.length; i++) {
if (els[i]!.id === curId) {
idx = i;
break;
}
}
}
let nextIdx: number;
if (idx === -1) nextIdx = delta > 0 ? 0 : els.length - 1;
else nextIdx = (idx + delta + els.length) % els.length;
rootCtx.highlightItemById(els[nextIdx]!.id);
}
function commitHighlighted() {
const value = rootCtx.selectedValue.value;
if (value === undefined) return false;
rootCtx.onValueChange(value);
return true;
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
const next = target.value;
rootCtx.onUserInputtedChange(true);
rootCtx.onSearchTermChange(next);
if (!rootCtx.open.value) {
rootCtx.onOpenChange(true);
nextTick(() => rootCtx.highlightFirstItem());
}
else {
nextTick(() => rootCtx.highlightFirstItem());
}
}
function handleKeyDown(event: KeyboardEvent) {
if (isDisabled.value) return;
const { key } = event;
if (!rootCtx.open.value && OPEN_KEYS.includes(key)) {
event.preventDefault();
rootCtx.onOpenChange(true);
return;
}
if (!rootCtx.open.value) return;
switch (key) {
case 'ArrowDown':
event.preventDefault();
moveHighlight(1);
break;
case 'ArrowUp':
event.preventDefault();
moveHighlight(-1);
break;
case 'Home': {
event.preventDefault();
const first = rootCtx.getVisibleItemElements()[0];
if (first) rootCtx.highlightItemById(first.id);
break;
}
case 'End': {
event.preventDefault();
const list = rootCtx.getVisibleItemElements();
const last = list[list.length - 1];
if (last) rootCtx.highlightItemById(last.id);
break;
}
case 'Enter':
if (commitHighlighted()) event.preventDefault();
break;
case 'Escape':
event.preventDefault();
rootCtx.onOpenChange(false);
if (rootCtx.resetSearchTermOnBlur.value) rootCtx.onSearchTermChange('');
break;
case 'Tab':
rootCtx.onOpenChange(false);
break;
}
}
function handleFocus() {
if (openOnFocus && !rootCtx.open.value) rootCtx.onOpenChange(true);
}
function handleClick() {
if (openOnClick && !rootCtx.open.value) rootCtx.onOpenChange(true);
}
function handleBlur(event: FocusEvent) {
if (!rootCtx.open.value) return;
const nextFocus = event.relatedTarget as Element | null;
if (!nextFocus) return;
const parent = rootCtx.parentElement.value;
const content = rootCtx.contentElement.value;
if (parent?.contains(nextFocus) || content?.contains(nextFocus)) return;
rootCtx.onOpenChange(false);
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
type="text"
role="combobox"
autocomplete="off"
spellcheck="false"
aria-autocomplete="list"
:aria-expanded="rootCtx.open.value"
:aria-controls="rootCtx.contentId.value"
:aria-activedescendant="activeDescendant"
:aria-disabled="isDisabled || undefined"
:aria-required="rootCtx.required.value || undefined"
:disabled="isDisabled || undefined"
:data-state="rootCtx.open.value ? 'open' : 'closed'"
:data-disabled="isDisabled ? '' : undefined"
@input="handleInput"
@keydown="handleKeyDown"
@focus="handleFocus"
@click="handleClick"
@blur="handleBlur"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,120 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
import type { AcceptableValue } from './utils';
export interface ComboboxItemProps<T extends AcceptableValue = AcceptableValue> extends PrimitiveProps {
/** Item value. Selected/registered identity. */
value: T;
/** Optional explicit text for filter + typeahead. */
textValue?: string;
/** Disable this item. */
disabled?: boolean;
}
</script>
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../config-provider';
import { Primitive } from '../primitive';
import { provideComboboxItemContext, useComboboxGroupContext, useComboboxRootContext } from './context';
const props = defineProps<ComboboxItemProps<T>>();
const { forwardRef, currentElement } = useForwardExpose();
const rootCtx = useComboboxRootContext();
let groupCtx: { id: { value: string } } | null = null;
try {
groupCtx = useComboboxGroupContext() as any;
}
catch {
groupCtx = null;
}
const id = useId(undefined, 'combobox-item');
const textValue = ref(props.textValue ?? '');
const isDisabled = computed(() => rootCtx.disabled.value || !!props.disabled);
const isSelected = computed(() => rootCtx.isSelected(props.value));
const isHighlighted = computed(() => rootCtx.selectedValueId.value === id.value);
const isVisible = computed(() => rootCtx.filterState.value.items.has(id.value));
function syncRegistration() {
rootCtx.onItemRegister(id.value, {
value: props.value,
textValue: textValue.value,
disabled: isDisabled.value,
});
}
onMounted(() => {
const el = currentElement.value as HTMLElement | undefined;
if (el && !props.textValue) {
textValue.value = el.textContent?.trim() ?? '';
}
syncRegistration();
if (groupCtx) rootCtx.onGroupItemRegister(groupCtx.id.value, id.value);
});
watch(() => [props.value, props.textValue, isDisabled.value], () => {
if (props.textValue) textValue.value = props.textValue;
syncRegistration();
});
onBeforeUnmount(() => {
rootCtx.onItemUnregister(id.value);
if (groupCtx) rootCtx.onGroupItemUnregister(groupCtx.id.value, id.value);
if (rootCtx.selectedValueId.value === id.value) {
rootCtx.onSelectedValueChange(undefined, undefined);
}
});
function handleClick(event: MouseEvent) {
if (isDisabled.value) return;
event.preventDefault();
rootCtx.onValueChange(props.value);
if (rootCtx.resetSearchTermOnSelect.value && !rootCtx.multiple.value) {
rootCtx.onSearchTermChange('');
rootCtx.onUserInputtedChange(false);
}
}
function handlePointerMove() {
if (isDisabled.value) return;
if (rootCtx.selectedValueId.value !== id.value) {
rootCtx.onSelectedValueChange(props.value, id.value);
}
}
provideComboboxItemContext({
id,
value: props.value,
textValue,
isSelected,
isDisabled,
});
defineExpose({ id, isVisible, isHighlighted });
</script>
<template>
<Primitive
v-show="isVisible"
:ref="forwardRef"
:id="id"
:as="props.as ?? 'div'"
role="option"
:aria-selected="isSelected"
:aria-disabled="isDisabled || undefined"
:data-state="isSelected ? 'checked' : 'unchecked'"
:data-highlighted="isHighlighted ? '' : undefined"
:data-disabled="isDisabled ? '' : undefined"
:tabindex="-1"
data-primitives-combobox-item
@click="handleClick"
@pointermove="handlePointerMove"
>
<slot :selected="isSelected" :highlighted="isHighlighted" />
</Primitive>
</template>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxItemIndicatorProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxItemContext } from './context';
const { as = 'span' } = defineProps<ComboboxItemIndicatorProps>();
const { forwardRef } = useForwardExpose();
const itemCtx = useComboboxItemContext();
</script>
<template>
<Primitive
v-if="itemCtx.isSelected.value"
:ref="forwardRef"
:as="as"
aria-hidden="true"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,26 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxLabelProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxGroupContext } from './context';
const { as = 'div' } = defineProps<ComboboxLabelProps>();
const { forwardRef } = useForwardExpose();
const groupCtx = useComboboxGroupContext();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:id="groupCtx.id.value"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PortalProps } from '../teleport';
export interface ComboboxPortalProps extends PortalProps {}
</script>
<script setup lang="ts">
import { Portal } from '../teleport';
const { to, defer, disabled } = defineProps<ComboboxPortalProps>();
</script>
<template>
<Portal :to="to" :defer="defer" :disabled="disabled">
<slot />
</Portal>
</template>
@@ -0,0 +1,400 @@
<script lang="ts">
import type { Direction } from '../config-provider';
import type { AcceptableValue, ComboboxFilterFunction, ComboboxFilterItem } from './utils';
export interface ComboboxRootProps<T extends AcceptableValue = AcceptableValue> {
/** Controlled selected value. Use `v-model`. */
modelValue?: T | T[];
/** Uncontrolled initial value. */
defaultValue?: T | T[];
/** Controlled open state. Use `v-model:open`. */
open?: boolean;
/** Uncontrolled default open state. */
defaultOpen?: boolean;
/** Allow selecting multiple values. */
multiple?: boolean;
/** Reading direction. Falls back to `ConfigProvider`. */
dir?: Direction;
/** Disable the whole combobox. */
disabled?: boolean;
/** Mark as required for native form validation. */
required?: boolean;
/** Native input name for form submission. */
name?: string;
/** Reset the search term when the input is blurred. @default true */
resetSearchTermOnBlur?: boolean;
/** Reset the search term when a value is selected (single mode). @default true */
resetSearchTermOnSelect?: boolean;
/** Skip the built-in filter; render every item regardless of search term. */
ignoreFilter?: boolean;
/** Custom filter implementation. Overrides the default substring match. */
filterFunction?: ComboboxFilterFunction;
/** Map the current model value to the input's display value. */
displayValue?: (value: T | T[] | undefined) => string;
/** Compare values by key, or via a custom comparator. */
by?: string | ((a: T, b: T) => boolean);
}
export interface ComboboxRootEmits<T extends AcceptableValue = AcceptableValue> {
'update:modelValue': [value: T | T[] | undefined];
'update:open': [open: boolean];
}
</script>
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">
import type { ShallowRef } from 'vue';
import type { ComboboxFilterState, ComboboxItemInfo } from './context';
import { computed, nextTick, ref, shallowRef, toRef, triggerRef, watch } from 'vue';
import { useConfig, useId } from '../config-provider';
import { PopperRoot } from '../popper';
import { provideComboboxRootContext } from './context';
import { defaultFilter, valueComparator } from './utils';
defineOptions({ inheritAttrs: false });
const {
modelValue,
defaultValue,
defaultOpen = false,
multiple = false,
dir,
disabled = false,
required = false,
name,
resetSearchTermOnBlur = true,
resetSearchTermOnSelect = true,
ignoreFilter = false,
filterFunction,
displayValue,
by,
} = defineProps<ComboboxRootProps<T>>();
const emit = defineEmits<ComboboxRootEmits<T>>();
const config = useConfig();
const direction = computed(() => dir ?? config.dir.value);
const localOpen = ref<boolean>(defaultOpen);
const open = defineModel<boolean>('open', {
default: undefined,
get: v => v ?? localOpen.value,
set: (v) => {
localOpen.value = v;
return v;
},
});
const initial = (modelValue ?? defaultValue) as T | T[] | undefined;
const localValue = shallowRef<T | T[] | undefined>(
multiple
? (Array.isArray(initial) ? initial.slice() : (initial === undefined ? [] : [initial]))
: (Array.isArray(initial) ? initial[0] : initial),
);
const value = defineModel<T | T[] | undefined>('modelValue', {
default: undefined,
get: v => v ?? localValue.value,
set: (v) => {
localValue.value = v;
return v;
},
});
const searchTerm = ref('');
const isUserInputted = ref(false);
const contentId = useId(undefined, 'combobox-content');
const triggerElement = shallowRef<HTMLElement | undefined>(undefined);
const inputElement = shallowRef<HTMLInputElement | undefined>(undefined);
const contentElement = shallowRef<HTMLElement | undefined>(undefined);
const parentElement = shallowRef<HTMLElement | undefined>(undefined);
const selectedValue = shallowRef<T | undefined>(undefined) as ShallowRef<T | undefined>;
const selectedValueId = ref<string | undefined>(undefined);
const allItems = shallowRef(new Map<string, ComboboxItemInfo<T>>());
const allGroups = shallowRef(new Map<string, Set<string>>());
function onItemRegister(id: string, info: ComboboxItemInfo<T>) {
allItems.value.set(id, info);
triggerRef(allItems);
}
function onItemUnregister(id: string) {
allItems.value.delete(id);
triggerRef(allItems);
}
function onGroupRegister(groupId: string) {
if (!allGroups.value.has(groupId)) {
allGroups.value.set(groupId, new Set());
triggerRef(allGroups);
}
}
function onGroupUnregister(groupId: string) {
allGroups.value.delete(groupId);
triggerRef(allGroups);
}
function onGroupItemRegister(groupId: string, itemId: string) {
let set = allGroups.value.get(groupId);
if (!set) {
set = new Set();
allGroups.value.set(groupId, set);
}
set.add(itemId);
triggerRef(allGroups);
}
function onGroupItemUnregister(groupId: string, itemId: string) {
const set = allGroups.value.get(groupId);
if (set) {
set.delete(itemId);
triggerRef(allGroups);
}
}
const filterRef = toRef(() => filterFunction);
const ignoreFilterRef = toRef(() => ignoreFilter);
const filterState = computed<ComboboxFilterState>(() => {
const items = allItems.value;
const groups = allGroups.value;
if (!searchTerm.value || ignoreFilterRef.value || !isUserInputted.value) {
return {
count: items.size,
items: new Set(items.keys()),
groups: new Set(groups.keys()),
};
}
const candidates: ComboboxFilterItem[] = [];
for (const [id, info] of items) candidates.push({ id, textValue: info.textValue });
const fn = filterRef.value ?? defaultFilter;
const filtered = fn(candidates, searchTerm.value);
const visibleItems = new Set<string>();
for (let i = 0; i < filtered.length; i++) visibleItems.add(filtered[i]!.id);
const visibleGroups = new Set<string>();
for (const [groupId, set] of groups) {
for (const itemId of set) {
if (visibleItems.has(itemId)) {
visibleGroups.add(groupId);
break;
}
}
}
return {
count: visibleItems.size,
items: visibleItems,
groups: visibleGroups,
};
});
function isSelected(v: T): boolean {
return valueComparator(value.value as T | T[] | undefined, v, by);
}
function commitValue(next: T | T[] | undefined) {
value.value = next;
emit('update:modelValue', next);
}
function onValueChange(v: T) {
if (multiple) {
const cur = Array.isArray(value.value) ? [...(value.value as T[])] : [];
const idx = cur.findIndex(i => valueComparator(i, v, by));
if (idx === -1) cur.push(v);
else cur.splice(idx, 1);
commitValue(cur);
inputElement.value?.focus();
}
else {
commitValue(v);
open.value = false;
}
}
function onOpenChange(next: boolean) {
open.value = next;
if (next) {
isUserInputted.value = false;
searchTerm.value = '';
nextTick(() => {
inputElement.value?.focus();
highlightSelectedOrFirst();
});
}
else {
setTimeout(() => {
if (resetSearchTermOnBlur) searchTerm.value = '';
isUserInputted.value = false;
}, 1);
}
}
function onSelectedValueChange(v: T | undefined, id?: string) {
selectedValue.value = v;
selectedValueId.value = id;
}
function getVisibleItemElements(): HTMLElement[] {
const root = contentElement.value ?? parentElement.value;
if (!root) return [];
const all = Array.from(root.querySelectorAll<HTMLElement>('[data-primitives-combobox-item]'));
const visible: HTMLElement[] = [];
const filterIds = filterState.value.items;
for (let i = 0; i < all.length; i++) {
const el = all[i]!;
if (el.dataset['disabled'] === '') continue;
const id = el.id;
if (!id || filterIds.has(id)) visible.push(el);
}
return visible;
}
function readValueFromElement(el: HTMLElement): T | undefined {
const id = el.id;
if (!id) return undefined;
return allItems.value.get(id)?.value;
}
function highlightItemById(id: string | undefined) {
if (!id) {
selectedValue.value = undefined;
selectedValueId.value = undefined;
return;
}
const info = allItems.value.get(id);
if (!info) return;
selectedValue.value = info.value;
selectedValueId.value = id;
const root = contentElement.value ?? parentElement.value;
const el = root?.querySelector<HTMLElement>(`#${CSS.escape(id)}`);
el?.scrollIntoView({ block: 'nearest' });
}
function highlightFirstItem() {
const els = getVisibleItemElements();
if (els.length === 0) {
selectedValue.value = undefined;
selectedValueId.value = undefined;
return;
}
highlightItemById(els[0]!.id);
}
function highlightSelectedOrFirst() {
const cur = value.value;
if (cur !== undefined && !Array.isArray(cur)) {
for (const [id, info] of allItems.value) {
if (valueComparator(cur, info.value, by) && !info.disabled) {
highlightItemById(id);
return;
}
}
}
highlightFirstItem();
}
watch(open, (isOpen) => {
if (!isOpen) {
selectedValue.value = undefined;
selectedValueId.value = undefined;
}
});
function onSearchTermChange(v: string) {
searchTerm.value = v;
}
function onUserInputtedChange(v: boolean) {
isUserInputted.value = v;
}
provideComboboxRootContext({
modelValue: value,
onValueChange,
multiple: toRef(() => multiple),
open,
onOpenChange,
disabled: toRef(() => disabled),
dir: direction,
name: toRef(() => name),
required: toRef(() => required),
by,
isSelected,
searchTerm,
onSearchTermChange,
resetSearchTermOnBlur: toRef(() => resetSearchTermOnBlur),
resetSearchTermOnSelect: toRef(() => resetSearchTermOnSelect),
ignoreFilter: ignoreFilterRef,
filterFunction: filterRef,
displayValue: displayValue as ((v: unknown) => string) | undefined,
isUserInputted,
onUserInputtedChange,
contentId,
triggerElement,
onTriggerChange: (el) => { triggerElement.value = el; },
inputElement,
onInputChange: (el) => { inputElement.value = el; },
contentElement,
onContentChange: (el) => { contentElement.value = el; },
parentElement,
onParentChange: (el) => { parentElement.value = el; },
selectedValue,
selectedValueId,
onSelectedValueChange,
allItems,
onItemRegister,
onItemUnregister,
allGroups,
onGroupRegister,
onGroupUnregister,
onGroupItemRegister,
onGroupItemUnregister,
filterState,
getVisibleItemElements,
highlightItemById,
highlightFirstItem,
});
defineExpose({
filterState,
highlightFirstItem,
highlightItemById,
// Avoid unused warnings — surfaced for advanced consumers
readValueFromElement,
});
</script>
<template>
<PopperRoot>
<slot :open="open" :model-value="value" />
<input
v-if="name"
type="hidden"
:name="name"
:value="Array.isArray(value) ? JSON.stringify(value) : (value ?? '')"
:required="required"
:disabled="disabled"
aria-hidden="true"
style="display: none"
tabindex="-1"
/>
</PopperRoot>
</template>
@@ -0,0 +1,26 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxSeparatorProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
const { as = 'div' } = defineProps<ComboboxSeparatorProps>();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="separator"
aria-orientation="horizontal"
aria-hidden="true"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,53 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxTriggerProps extends PrimitiveProps {
/** Disable the trigger independently from the root. */
disabled?: boolean;
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, watchPostEffect } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxRootContext } from './context';
import { getOpenState } from './utils';
const { as = 'button', disabled = false } = defineProps<ComboboxTriggerProps>();
const { forwardRef, currentElement } = useForwardExpose();
const rootCtx = useComboboxRootContext();
const isDisabled = computed(() => disabled || rootCtx.disabled.value);
watchPostEffect(() => rootCtx.onTriggerChange(currentElement.value));
onBeforeUnmount(() => rootCtx.onTriggerChange(undefined));
function handleClick(event: MouseEvent) {
if (isDisabled.value) return;
event.preventDefault();
rootCtx.onOpenChange(!rootCtx.open.value);
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
tabindex="-1"
aria-haspopup="listbox"
aria-label="Show options"
:aria-controls="rootCtx.contentId.value"
:aria-expanded="rootCtx.open.value"
:aria-disabled="isDisabled || undefined"
:disabled="isDisabled || undefined"
:data-state="getOpenState(rootCtx.open.value)"
:data-disabled="isDisabled ? '' : undefined"
@click="handleClick"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ComboboxViewportProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { watchPostEffect } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useComboboxContentContext } from './context';
const { as = 'div' } = defineProps<ComboboxViewportProps>();
const { forwardRef, currentElement } = useForwardExpose();
const contentCtx = useComboboxContentContext();
watchPostEffect(() => contentCtx.onViewportChange(currentElement.value));
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="presentation"
data-primitives-combobox-viewport
style="position: relative; flex: 1 1 0%; overflow: hidden auto"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,81 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { userEvent } from 'vitest/browser';
import { defineComponent, h, nextTick, ref } from 'vue';
import {
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
ComboboxViewport,
} from '../index';
function mountCombobox() {
const search = ref('');
const Harness = defineComponent({
setup: () => () => h(ComboboxRoot, { defaultOpen: true, multiple: false }, {
default: () => [
h(ComboboxTrigger, { id: 'trigger' }, {
default: () => h(ComboboxInput, {
id: 'input',
'onUpdate:searchTerm': (v: string) => { search.value = v; },
}),
}),
h(ComboboxPortal, {}, {
default: () => h(ComboboxContent, {}, {
default: () => h(ComboboxViewport, {}, {
default: () => [
h(ComboboxItem, { value: 'apple', textValue: 'Apple' }, { default: () => 'Apple' }),
h(ComboboxItem, { value: 'banana', textValue: 'Banana' }, { default: () => 'Banana' }),
h(ComboboxItem, { value: 'cherry', textValue: 'Cherry' }, { default: () => 'Cherry' }),
],
}),
}),
}),
],
}),
});
return { wrapper: mount(Harness, { attachTo: document.body }), search };
}
function getLiveRegion(): HTMLElement | null {
return document.querySelector('[data-primitives-combobox-announce]');
}
describe('Combobox — filtered-results live region', () => {
it('announces "N results available." reflecting the unfiltered count on open', async () => {
const { wrapper } = mountCombobox();
await nextTick();
await nextTick();
await nextTick();
const live = getLiveRegion();
expect(live).toBeTruthy();
expect(live!.getAttribute('role')).toBe('status');
expect(live!.getAttribute('aria-live')).toBe('polite');
expect(live!.textContent?.trim()).toBe('3 results available.');
wrapper.unmount();
});
it('updates the count as the search term filters items', async () => {
const { wrapper } = mountCombobox();
await nextTick();
await nextTick();
await nextTick();
const input = document.querySelector<HTMLInputElement>('#input')!;
await userEvent.click(input);
await userEvent.type(input, 'app');
await nextTick();
await nextTick();
expect(getLiveRegion()!.textContent?.trim()).toBe('1 result available.');
await userEvent.clear(input);
await userEvent.type(input, 'zz');
await nextTick();
await nextTick();
expect(getLiveRegion()!.textContent?.trim()).toBe('0 results available.');
wrapper.unmount();
});
});
+112
View File
@@ -0,0 +1,112 @@
import type { ComputedRef, Ref, ShallowRef } from 'vue';
import type { Direction } from '../config-provider';
import type { AcceptableValue, ComboboxFilterFunction } from './utils';
import { useContextFactory } from '@robonen/vue';
export interface ComboboxItemInfo<T = AcceptableValue> {
value: T;
textValue: string;
disabled: boolean;
}
export interface ComboboxFilterState {
count: number;
items: Set<string>;
groups: Set<string>;
}
export interface ComboboxRootContext<T = AcceptableValue> {
modelValue: Ref<T | T[] | undefined>;
onValueChange: (value: T) => void;
multiple: Ref<boolean>;
open: Ref<boolean>;
onOpenChange: (open: boolean) => void;
disabled: Ref<boolean>;
dir: Ref<Direction>;
name: Ref<string | undefined>;
required: Ref<boolean>;
by?: string | ((a: T, b: T) => boolean);
isSelected: (value: T) => boolean;
searchTerm: Ref<string>;
onSearchTermChange: (value: string) => void;
resetSearchTermOnBlur: Ref<boolean>;
resetSearchTermOnSelect: Ref<boolean>;
ignoreFilter: Ref<boolean>;
filterFunction: Ref<ComboboxFilterFunction | undefined>;
displayValue?: (value: T | T[] | undefined) => string;
isUserInputted: Ref<boolean>;
onUserInputtedChange: (value: boolean) => void;
contentId: Ref<string>;
triggerElement: ShallowRef<HTMLElement | undefined>;
onTriggerChange: (el: HTMLElement | undefined) => void;
inputElement: ShallowRef<HTMLInputElement | undefined>;
onInputChange: (el: HTMLInputElement | undefined) => void;
contentElement: ShallowRef<HTMLElement | undefined>;
onContentChange: (el: HTMLElement | undefined) => void;
parentElement: ShallowRef<HTMLElement | undefined>;
onParentChange: (el: HTMLElement | undefined) => void;
selectedValue: ShallowRef<T | undefined>;
selectedValueId: Ref<string | undefined>;
onSelectedValueChange: (value: T | undefined, id?: string) => void;
allItems: ShallowRef<Map<string, ComboboxItemInfo<T>>>;
onItemRegister: (id: string, info: ComboboxItemInfo<T>) => void;
onItemUnregister: (id: string) => void;
allGroups: ShallowRef<Map<string, Set<string>>>;
onGroupRegister: (groupId: string) => void;
onGroupUnregister: (groupId: string) => void;
onGroupItemRegister: (groupId: string, itemId: string) => void;
onGroupItemUnregister: (groupId: string, itemId: string) => void;
filterState: ComputedRef<ComboboxFilterState>;
/** Returns visible, enabled item elements in DOM order. */
getVisibleItemElements: () => HTMLElement[];
/** Highlights an item element by its id. */
highlightItemById: (id: string | undefined) => void;
/** Highlights the first visible item. */
highlightFirstItem: () => void;
}
export interface ComboboxContentContext {
viewportElement: ShallowRef<HTMLElement | undefined>;
onViewportChange: (el: HTMLElement | undefined) => void;
position: Ref<'inline' | 'popper'>;
}
export interface ComboboxGroupContext {
id: Ref<string>;
}
export interface ComboboxItemContext<T = AcceptableValue> {
id: Ref<string>;
value: T;
textValue: Ref<string>;
isSelected: Ref<boolean>;
isDisabled: Ref<boolean>;
}
export const {
inject: useComboboxRootContext,
provide: provideComboboxRootContext,
} = useContextFactory<ComboboxRootContext<any>>('ComboboxRoot');
export const {
inject: useComboboxContentContext,
provide: provideComboboxContentContext,
} = useContextFactory<ComboboxContentContext>('ComboboxContent');
export const {
inject: useComboboxGroupContext,
provide: provideComboboxGroupContext,
} = useContextFactory<ComboboxGroupContext>('ComboboxGroup');
export const {
inject: useComboboxItemContext,
provide: provideComboboxItemContext,
} = useContextFactory<ComboboxItemContext<any>>('ComboboxItem');
+51
View File
@@ -0,0 +1,51 @@
export { default as ComboboxAnchor } from './ComboboxAnchor.vue';
export { default as ComboboxArrow } from './ComboboxArrow.vue';
export { default as ComboboxCancel } from './ComboboxCancel.vue';
export { default as ComboboxContent } from './ComboboxContent.vue';
export { default as ComboboxContentImpl } from './ComboboxContentImpl.vue';
export { default as ComboboxEmpty } from './ComboboxEmpty.vue';
export { default as ComboboxGroup } from './ComboboxGroup.vue';
export { default as ComboboxInput } from './ComboboxInput.vue';
export { default as ComboboxItem } from './ComboboxItem.vue';
export { default as ComboboxItemIndicator } from './ComboboxItemIndicator.vue';
export { default as ComboboxLabel } from './ComboboxLabel.vue';
export { default as ComboboxPortal } from './ComboboxPortal.vue';
export { default as ComboboxRoot } from './ComboboxRoot.vue';
export { default as ComboboxSeparator } from './ComboboxSeparator.vue';
export { default as ComboboxTrigger } from './ComboboxTrigger.vue';
export { default as ComboboxViewport } from './ComboboxViewport.vue';
export {
useComboboxContentContext,
useComboboxGroupContext,
useComboboxItemContext,
useComboboxRootContext,
} from './context';
export type {
ComboboxContentContext,
ComboboxFilterState,
ComboboxGroupContext,
ComboboxItemContext,
ComboboxItemInfo,
ComboboxRootContext,
} from './context';
export type { AcceptableValue, ComboboxFilterFunction, ComboboxFilterItem } from './utils';
export type { ComboboxAnchorProps } from './ComboboxAnchor.vue';
export type { ComboboxArrowProps } from './ComboboxArrow.vue';
export type { ComboboxCancelProps } from './ComboboxCancel.vue';
export type { ComboboxContentEmits, ComboboxContentProps } from './ComboboxContent.vue';
export type { ComboboxContentImplEmits, ComboboxContentImplProps } from './ComboboxContentImpl.vue';
export type { ComboboxEmptyProps } from './ComboboxEmpty.vue';
export type { ComboboxGroupProps } from './ComboboxGroup.vue';
export type { ComboboxInputProps } from './ComboboxInput.vue';
export type { ComboboxItemIndicatorProps } from './ComboboxItemIndicator.vue';
export type { ComboboxItemProps } from './ComboboxItem.vue';
export type { ComboboxLabelProps } from './ComboboxLabel.vue';
export type { ComboboxPortalProps } from './ComboboxPortal.vue';
export type { ComboboxRootEmits, ComboboxRootProps } from './ComboboxRoot.vue';
export type { ComboboxSeparatorProps } from './ComboboxSeparator.vue';
export type { ComboboxTriggerProps } from './ComboboxTrigger.vue';
export type { ComboboxViewportProps } from './ComboboxViewport.vue';
+57
View File
@@ -0,0 +1,57 @@
export type AcceptableValue = string | number | boolean | Record<string, unknown>;
export const OPEN_KEYS = ['Enter', ' ', 'ArrowDown', 'ArrowUp', 'PageUp', 'PageDown', 'Home', 'End'];
export const SELECTION_KEYS = ['Enter', ' '];
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
export function getOpenState(open: boolean): 'open' | 'closed' {
return open ? 'open' : 'closed';
}
export function compare<T>(
a: T | undefined,
b: T | undefined,
by?: string | ((a: T, b: T) => boolean),
): boolean {
if (a === undefined || b === undefined) return false;
if (by === undefined) return a === b;
if (typeof by === 'function') return by(a as T, b as T);
return (a as any)?.[by] === (b as any)?.[by];
}
export function valueComparator<T>(
value: T | T[] | undefined,
current: T,
by?: string | ((a: T, b: T) => boolean),
): boolean {
if (value === undefined) return false;
if (!Array.isArray(value)) return compare(value, current, by);
for (const v of value) {
if (compare(v, current, by)) return true;
}
return false;
}
export interface ComboboxFilterItem {
id: string;
textValue: string;
}
export type ComboboxFilterFunction = (
items: ComboboxFilterItem[],
searchTerm: string,
) => ComboboxFilterItem[];
export const defaultFilter: ComboboxFilterFunction = (items, searchTerm) => {
const term = searchTerm.toLowerCase();
if (!term) return items;
const out: ComboboxFilterItem[] = [];
for (let i = 0; i < items.length; i++) {
const it = items[i]!;
if (it.textValue.toLowerCase().includes(term)) out.push(it);
}
return out;
};
@@ -0,0 +1,39 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CommandEmptyProps extends PrimitiveProps {
/** Render even while there is no active search term. */
always?: boolean;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useCommandContext } from './context';
const { as = 'div', always = false } = defineProps<CommandEmptyProps>();
const { forwardRef } = useForwardExpose();
const ctx = useCommandContext();
const shouldRender = computed(() => {
if (ctx.filteredItems.value.size !== 0) return false;
if (always) return true;
return ctx.searchTerm.value.length > 0;
});
</script>
<template>
<Primitive
v-if="shouldRender"
:ref="forwardRef"
:as="as"
role="presentation"
data-primitives-command-empty
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,73 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CommandGroupProps extends PrimitiveProps {
/** Group heading text (rendered when the default slot doesn't override it). */
heading?: string;
/** Stable identifier for the group. Auto-generated when omitted. */
value?: string;
/** Render the group even when all of its items are filtered out. */
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, toRef } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../config-provider';
import { Primitive } from '../primitive';
import { provideCommandGroupContext, useCommandContext } from './context';
const {
as = 'div',
heading,
value,
forceMount = false,
} = defineProps<CommandGroupProps>();
const { forwardRef } = useForwardExpose();
const ctx = useCommandContext();
const id = useId(() => value, 'command-group');
const headingId = useId(undefined, 'command-group-heading');
const hasVisibleItem = computed(() => {
const set = ctx.allGroups.value.get(id.value);
if (!set || set.size === 0) return false;
for (const v of set) {
const info = ctx.allItems.value.get(v);
if (!info || info.disabled) continue;
if (ctx.filteredItems.value.has(v)) return true;
}
return false;
});
const isVisible = computed(() => forceMount || hasVisibleItem.value);
onMounted(() => ctx.registerGroup(id.value));
onBeforeUnmount(() => ctx.unregisterGroup(id.value));
provideCommandGroupContext({
id,
forceMount: toRef(() => forceMount),
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="presentation"
:data-primitives-state="isVisible ? 'visible' : 'hidden'"
:hidden="!isVisible || undefined"
data-primitives-command-group
>
<div v-if="heading" :id="headingId" data-primitives-command-group-heading>
{{ heading }}
</div>
<div role="group" :aria-labelledby="heading ? headingId : undefined">
<slot />
</div>
</Primitive>
</template>
+165
View File
@@ -0,0 +1,165 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CommandInputProps extends PrimitiveProps {
/** Controlled value; falls back to root `searchTerm`. */
modelValue?: string;
/** Disable the input. */
disabled?: boolean;
/** Focus the input on mount. */
autoFocus?: boolean;
}
export interface CommandInputEmits {
'update:modelValue': [value: string];
'update:searchTerm': [value: string];
}
</script>
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useCommandContext } from './context';
const {
as = 'input',
modelValue,
disabled = false,
autoFocus = false,
} = defineProps<CommandInputProps>();
const emit = defineEmits<CommandInputEmits>();
const { forwardRef, currentElement } = useForwardExpose();
const ctx = useCommandContext();
const activeDescendant = computed(() => {
const v = ctx.selectedValue.value;
return v === undefined ? undefined : ctx.getItemId(v);
});
onMounted(() => {
const el = currentElement.value as HTMLInputElement | undefined;
if (!el) return;
if (modelValue !== undefined && modelValue !== ctx.searchTerm.value) {
ctx.setSearchTerm(modelValue);
}
if (el.value !== ctx.searchTerm.value) el.value = ctx.searchTerm.value;
if (autoFocus) setTimeout(() => el.focus(), 0);
});
watch(
() => modelValue,
(v) => {
if (v === undefined) return;
if (v !== ctx.searchTerm.value) ctx.setSearchTerm(v);
},
);
watch(
() => ctx.searchTerm.value,
(v) => {
const el = currentElement.value as HTMLInputElement | undefined;
if (el && el.value !== v) el.value = v;
},
);
function moveBy(delta: number) {
const items = ctx.getSelectableItems();
if (items.length === 0) return;
const cur = ctx.selectedValue.value;
const idx = cur === undefined ? -1 : items.indexOf(cur);
let next: number;
if (idx === -1) {
next = delta > 0 ? 0 : items.length - 1;
}
else {
next = idx + delta;
if (ctx.loop.value) {
next = (next + items.length) % items.length;
}
else {
if (next < 0) next = 0;
if (next > items.length - 1) next = items.length - 1;
}
}
ctx.setSelectedValue(items[next]);
scrollSelectedIntoView();
}
function moveTo(position: 'first' | 'last') {
const items = ctx.getSelectableItems();
if (items.length === 0) return;
ctx.setSelectedValue(position === 'first' ? items[0] : items[items.length - 1]);
scrollSelectedIntoView();
}
function scrollSelectedIntoView() {
const v = ctx.selectedValue.value;
const root = ctx.listElement.value;
if (v === undefined || !root) return;
const id = ctx.getItemId(v);
const el = root.querySelector<HTMLElement>(`#${CSS.escape(id)}`);
el?.scrollIntoView({ block: 'nearest' });
}
function handleInput(event: Event) {
const next = (event.target as HTMLInputElement).value;
ctx.setSearchTerm(next);
emit('update:modelValue', next);
emit('update:searchTerm', next);
}
function handleKeyDown(event: KeyboardEvent) {
if (disabled) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
moveBy(1);
break;
case 'ArrowUp':
event.preventDefault();
moveBy(-1);
break;
case 'Home':
event.preventDefault();
moveTo('first');
break;
case 'End':
event.preventDefault();
moveTo('last');
break;
case 'Enter':
if (ctx.selectedValue.value !== undefined) {
event.preventDefault();
ctx.commitSelected();
}
break;
}
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
type="text"
role="combobox"
autocomplete="off"
spellcheck="false"
aria-autocomplete="list"
:aria-expanded="true"
:aria-controls="ctx.listId.value"
:aria-activedescendant="activeDescendant"
:aria-disabled="disabled || undefined"
:disabled="disabled || undefined"
:data-disabled="disabled ? '' : undefined"
data-primitives-command-input
@input="handleInput"
@keydown="handleKeyDown"
>
<slot />
</Primitive>
</template>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CommandItemProps extends PrimitiveProps {
/** Item value — used by filter, selection, and `data-value`. */
value: string;
/** Extra terms the default filter should match against. */
keywords?: string[];
/** Disable this item — it is skipped by keyboard nav and filtering. */
disabled?: boolean;
/** Render even when filtered out. */
forceMount?: boolean;
}
export interface CommandItemEmits {
select: [value: string];
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useCommandContext, useCommandGroupContext } from './context';
const {
as = 'div',
value,
keywords,
disabled = false,
forceMount = false,
} = defineProps<CommandItemProps>();
const emit = defineEmits<CommandItemEmits>();
const { forwardRef } = useForwardExpose();
const ctx = useCommandContext();
let groupCtx: ReturnType<typeof useCommandGroupContext> | null = null;
try {
groupCtx = useCommandGroupContext();
}
catch {
groupCtx = null;
}
const itemId = computed(() => ctx.getItemId(value));
const isVisible = computed(() => forceMount || ctx.filteredItems.value.has(value));
const isHighlighted = computed(() => ctx.selectedValue.value === value);
const isSelected = computed(() => ctx.modelValue.value === value);
function syncRegistration() {
ctx.registerItem({
value,
keywords: keywords ?? [],
disabled,
onSelect: () => emit('select', value),
});
}
onMounted(() => {
syncRegistration();
if (groupCtx) ctx.registerGroupItem(groupCtx.id.value, value);
});
watch(
() => [value, disabled, (keywords ?? []).join('\u0001')] as const,
(_next, prev) => {
const [prevValue] = prev ?? [];
if (prevValue !== undefined && prevValue !== value) {
ctx.unregisterItem(prevValue);
if (groupCtx) ctx.unregisterGroupItem(groupCtx.id.value, prevValue);
syncRegistration();
if (groupCtx) ctx.registerGroupItem(groupCtx.id.value, value);
}
else {
syncRegistration();
}
},
);
onBeforeUnmount(() => {
ctx.unregisterItem(value);
if (groupCtx) ctx.unregisterGroupItem(groupCtx.id.value, value);
});
function handlePointerMove(event: PointerEvent) {
if (disabled) return;
// Only react to genuine mouse / pen movement; keyboard nav already manages highlight.
if (event.pointerType === 'touch') return;
if (ctx.selectedValue.value !== value) ctx.setSelectedValue(value);
}
function handleClick(event: MouseEvent) {
if (disabled) {
event.preventDefault();
return;
}
event.preventDefault();
ctx.setSelectedValue(value);
ctx.commitSelected();
}
</script>
<template>
<Primitive
v-show="isVisible"
:ref="forwardRef"
:id="itemId"
:as="as"
role="option"
:aria-selected="isHighlighted || undefined"
:aria-disabled="disabled || undefined"
:data-state="isHighlighted ? 'selected' : ''"
:data-selected="isSelected ? '' : undefined"
:data-disabled="disabled ? '' : undefined"
:data-primitives-state="isVisible ? 'visible' : 'hidden'"
:tabindex="-1"
data-primitives-command-item
:data-value="value"
@click="handleClick"
@pointermove="handlePointerMove"
>
<slot :highlighted="isHighlighted" :selected="isSelected" :disabled="disabled" />
</Primitive>
</template>
@@ -0,0 +1,92 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CommandListProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { useCommandContext } from './context';
const { as = 'div' } = defineProps<CommandListProps>();
const { forwardRef, currentElement } = useForwardExpose();
const ctx = useCommandContext();
let resizeObserver: ResizeObserver | undefined;
let observedChild: Element | undefined;
function setHeight(height: number) {
const list = currentElement.value as HTMLElement | undefined;
if (!list) return;
list.style.setProperty('--primitives-command-list-height', `${height}px`);
}
function observeFirstChild() {
const list = currentElement.value as HTMLElement | undefined;
if (!list) return;
const child = list.firstElementChild ?? undefined;
if (child === observedChild) return;
if (resizeObserver && observedChild) resizeObserver.unobserve(observedChild);
observedChild = child;
if (!child) {
setHeight(0);
return;
}
resizeObserver?.observe(child);
setHeight((child as HTMLElement).offsetHeight);
}
onMounted(() => {
const list = currentElement.value as HTMLElement | undefined;
if (!list) return;
ctx.setListElement(list);
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const target = entry.target as HTMLElement;
setHeight(target.offsetHeight);
}
});
}
observeFirstChild();
// React to subtree changes (items added/removed/reordered).
const mo = new MutationObserver(observeFirstChild);
mo.observe(list, { childList: true });
onBeforeUnmount(() => {
mo.disconnect();
resizeObserver?.disconnect();
resizeObserver = undefined;
observedChild = undefined;
ctx.setListElement(undefined);
});
});
// Re-evaluate the observed child whenever the filter result changes (items hide/show).
watch(
() => ctx.filteredItems.value,
() => observeFirstChild(),
);
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:id="ctx.listId.value"
role="listbox"
:aria-labelledby="ctx.labelId.value"
data-primitives-command-list
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,40 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CommandLoadingProps extends PrimitiveProps {
/** Accessible label describing the loading state. */
label?: string;
/** Optional 0..100 progress value — published via `aria-valuenow`. */
progress?: number;
}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
const {
as = 'div',
label = 'Loading',
progress,
} = defineProps<CommandLoadingProps>();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="progressbar"
:aria-valuetext="label"
:aria-valuenow="progress"
:aria-valuemin="progress === undefined ? undefined : 0"
:aria-valuemax="progress === undefined ? undefined : 100"
aria-live="polite"
data-primitives-command-loading
>
<slot :progress="progress" />
</Primitive>
</template>

Some files were not shown because too many files have changed in this diff Show More