- 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.
31 KiB
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).
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, и т.п.).
// 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');
// 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 }) — встроенный способ навесить преобразование/нормализацию прямо на модель:
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, кастомные):
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, …):
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-реакция нужна сразу; иначе первая синхронизация делается отдельно.watchEffectvswatch.watchEffect— для реактивных деревьев без явного источника;watch— когда важна старая-новая пара и источник известен.shallowRefдля ссылок на массивы/объекты, которые меняются целиком (replace), — меньше лишних триггеров.
3.4. Roving focus
Для коллекций фокусируемых элементов (Toolbar, RadioGroup, ToggleGroup, NavigationMenu …) используй src/utils/roving-focus.ts:
rovingKeyToAction(event, { orientation, dir, loop })→{ delta, absolute? } | nullresolveNextIndex(current, delta, count, loop)— wrap-or-clamp
Items сами регистрируются в Root через item sub-context (useContextFactory). Root хранит Ref<HTMLElement[]> и activeIndex. Фильтрация disabled:
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-параметрами:<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 — порядок ломается при перепаковке детей. Используй паттерн:
- Каждый item выставляет дата-атрибут (например
data-collection-item=""). itemMap: Ref<Map<HTMLElement, ItemData>>в контексте — регистрация черезwatchEffect(cleanup)в child: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)) })getItems()считывает текущий DOM-порядок черезquerySelectorAll('[data-collection-item]')и сортирует значения мапы поindexOf.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. Минимум сценариев для нового примитива
- Корректный ARIA-каркас (
role,aria-*,data-state). - Контролируемый режим (
v-modelобновляется → DOM реагирует). - Неконтролируемый режим (
default*пропс задаёт начальное состояние). - Все интерактивные действия (click / keyboard).
- Клавиатура: Enter/Space/Arrow*/Home/End/Esc — в зависимости от паттерна.
disabledблокирует мутации.- Edge-cases: пустые массивы, null, экстремальные min/max.
- Модификаторы v-model, если поддерживаются (
trim, кастомные).
5.3. Хелперы и гочи jsdom
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 для нового примитива
- Спецификация. WAI-ARIA APG для паттерна — единственный источник истины по ARIA/клавиатуре.
- Скаффолдинг папки (
<name>/context.ts,<Name>Root.vue, парты,__test__/,index.ts). - Контекст через
useContextFactory— сначала определи поля и действия, потом пиши компоненты. - Root — модель состояния (
defineModelсget/setили fallback-паттерн),provide*Context. - Parts — каждая часть тонкая, без дублирующего state.
- Тесты пиши параллельно с кодом, не «потом».
- Регистрация в
src/index.ts. - Локальный прогон:
pnpm exec vitest run src/<name>→ 0 fail. - Полный прогон:
pnpm exec vitest run(+test:browserпри необходимости). - Сборка:
pnpm exec tsdown— проверь, что bundle не вырос аномально и.d.tsвалиден. - Линт:
pnpm lint:fix. - Обнови 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обновлён.