diff --git a/vue/primitives/.vitest-attachments/12dbc42b176f989336a161e4f129aeb48944d8d4.png b/vue/primitives/.vitest-attachments/12dbc42b176f989336a161e4f129aeb48944d8d4.png new file mode 100644 index 0000000..d1c5f08 Binary files /dev/null and b/vue/primitives/.vitest-attachments/12dbc42b176f989336a161e4f129aeb48944d8d4.png differ diff --git a/vue/primitives/AGENTS.md b/vue/primitives/AGENTS.md new file mode 100644 index 0000000..c57db2b --- /dev/null +++ b/vue/primitives/AGENTS.md @@ -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/ # один компонент +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`, не вычисление в `