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:
2026-06-07 16:29:56 +07:00
parent c7644ade69
commit 626fbc70d8
408 changed files with 27367 additions and 154 deletions
+91
View File
@@ -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>
+10
View File
@@ -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');
+37
View File
@@ -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 },
],
});
+12
View File
@@ -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/&lt;FileName&gt;</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>