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:
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@@ -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
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
@@ -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:"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vite
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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');
|
||||||
@@ -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 },
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -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/<FileName></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>
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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';
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -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');
|
||||||
@@ -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';
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
Reference in New Issue
Block a user