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:
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
@@ -0,0 +1,32 @@
|
||||
# @robonen/primitives playground
|
||||
|
||||
Minimal Vite + Vue 3 sandbox for inspecting and debugging primitives from
|
||||
[`@robonen/primitives`](../). Imports source directly via the `@primitives/*`
|
||||
alias, so HMR works while editing components in `vue/primitives/src/`.
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
pnpm --filter @robonen/primitives-playground dev
|
||||
```
|
||||
|
||||
Then open http://localhost:5180.
|
||||
|
||||
## Adding a demo
|
||||
|
||||
Drop a `.vue` file into `src/demos/`. It will be picked up automatically by the
|
||||
sidebar (`import.meta.glob('./demos/*.vue')`) and addressable via the URL hash
|
||||
(e.g. `#/Accordion`).
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { AccordionRoot } from '@primitives/accordion';
|
||||
// or: import { AccordionRoot } from '@primitives';
|
||||
</script>
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Imports resolve to `vue/primitives/src/` directly (not the built `dist/`), so
|
||||
you can poke at primitives and see changes instantly.
|
||||
- The playground is `private: true` and is not published.
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@robonen/primitives — playground</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@robonen/primitives-playground",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"description": "Minimal playground for @robonen/primitives — eyeball, debug, hack on hypotheses",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@robonen/primitives": "workspace:*",
|
||||
"vue": "catalog:",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"vite": "^7.1.9",
|
||||
"vue-tsc": "^3.2.5"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
@@ -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/<FileName></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>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.vue.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vite/client", "node"],
|
||||
"allowImportingTsExtensions": false,
|
||||
"paths": {
|
||||
"@primitives/*": ["../src/*"],
|
||||
"@primitives": ["../src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { URL, fileURLToPath } from 'node:url';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
define: {
|
||||
__DEV__: JSON.stringify(mode !== 'production'),
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
// Order matters: subpath alias must come before the bare-specifier one.
|
||||
{
|
||||
find: /^@primitives\/(.*)$/,
|
||||
replacement: fileURLToPath(new URL('../src/$1', import.meta.url)),
|
||||
},
|
||||
{
|
||||
find: /^@primitives$/,
|
||||
replacement: fileURLToPath(new URL('../src/index.ts', import.meta.url)),
|
||||
},
|
||||
],
|
||||
},
|
||||
server: {
|
||||
port: 5180,
|
||||
fs: {
|
||||
// Allow importing from primitives source one level up.
|
||||
allow: [fileURLToPath(new URL('../', import.meta.url))],
|
||||
},
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user