# 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/ # один компонент pnpm run test:browser # browser mode (Playwright) pnpm exec tsdown # сборка ESM + CJS + .d.ts pnpm lint:check / pnpm lint:fix # eslint ``` --- ## 1. Структура пакета ``` src/ / # kebab-case Root.vue # PascalCase, корневой провайдер контекста .vue # части (Trigger / Content / Item / Indicator…) context.ts # фабрика + типы контекста index.ts # барель __test__/ .test.ts # jsdom-тесты .browser.test.ts # опционально, если нужен реальный браузер utils/ # общие хелперы (roving-focus, getRawChildren …) primitive/ # polymorphic index.ts # реэкспорт всех компонентов ``` **Правила структуры:** - Каждый примитив = отдельная папка. Никаких «всё в одном файле». - Имя папки — `kebab-case`, файлы — `PascalCase.vue`. - Корневой компонент **обязан** называться `Root.vue` и быть провайдером контекста. - Никаких циклических зависимостей между примитивами. Общий код — в `src/utils/`. - `index.ts` примитива экспортирует **все** компоненты + контекст-хук + типы. - После создания примитива добавь `export * from './';` в `src/index.ts`. --- ## 2. Нейминг и data-атрибуты В коде, комментариях, доках, тестах и коммитах **не должно быть имён сторонних UI-библиотек** и внутренних кодовых названий бренда/форка. Описывай компоненты через их роль (`dialog`, `radio-group`, `spinbutton`) и паттерн (`roving-focus`, `dismissable-layer`). | Артефакт | Формат | |---|---| | Имя контекста | `''` — аргумент в `useContextFactory('')` | | ID-префикс | `'-'` — аргумент в `useId(undefined, '-')` | | 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 ''; export interface FooContext { open: Ref; disabled: ComputedRef; onToggle: () => void; } export const { inject: useFooContext, provide: provideFooContext, } = useContextFactory('foo'); ``` ```ts // FooRoot.vue provideFooContext({ open, disabled, onToggle }); // FooTrigger.vue const ctx = useFooContext(); // кидает, если нет const ctx = useFooContext(fallback); // опционально: дефолт без ошибки ``` **Правила:** - Никаких ручных `Symbol(...)` / `InjectionKey<...>` в папке примитива. - Имя фабрики описательное (`'foo'`, `'foo-item'`, `'dialog'`), без префиксов брендов. - Контекст хранит **`Ref` / `ComputedRef`** для реактивных полей и **функции** для действий. Не передавай голые значения — потеряешь реактивность у потребителей. - Для per-item данных (например, `RadioGroupIndicator` смотрит на свой `RadioGroupItem`) создавай **item sub-context** отдельной фабрикой (`useContextFactory('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(); const local = ref(defaultOpen); const open = defineModel('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({ 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(), { modelValue: undefined }); const emit = defineEmits<{ 'update:modelValue': [v: string[] | string | undefined] }>(); const local = shallowRef(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()` | | Развернуть `MaybeRef` | `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`, не вычисление в `