Files
robonen 626fbc70d8 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.
2026-06-07 16:29:56 +07:00

31 KiB
Raw Permalink Blame History

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-реакция нужна сразу; иначе первая синхронизация делается отдельно.
  • 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:

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).
  • Доступ к элементу + проброс refuseForwardExpose из тулкита. Он даёт { 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:
    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

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 обновлён.