uifolio 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,46 +2,8 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/uifolio.svg)](https://www.npmjs.com/package/uifolio)
4
4
  [![license](https://img.shields.io/npm/l/uifolio.svg)](https://www.npmjs.com/package/uifolio)
5
- [![node](https://img.shields.io/node/v/uifolio.svg)](https://www.npmjs.com/package/uifolio)
6
5
 
7
- My component and animation library: the code lives in your project, gets copied and edited.
8
-
9
- The `uifolio` npm package contains the CLI, the 14 components and their 9 helpers — nothing else.
10
- The demo site (Vite, animations, playgrounds) lives in the project repository and is not part
11
- of the package.
12
-
13
- ## Getting started
14
-
15
- ```bash
16
- npm install
17
- npm run dev # demo server (http://localhost:5173)
18
- npm run build # typecheck + production build
19
- npm run preview # serve the build
20
- npm run lint # oxlint
21
- npm test # CLI tests (copy, color, npm manifest)
22
- ```
23
-
24
- Routes (hash):
25
-
26
- - `#/` — home page (UIFolio identity)
27
- - `#/docs` — introduction, installation, theme
28
- - `#/components` — documented catalog
29
- - `#/components/video` — VideoPlayer playground
30
- - `#/components/calendar`, `/time`, `/audio`, `/stories`, `/editor`, `/upload`,
31
- `/shortcuts`, `/panels`, `/choice`, `/color-picker`, `/icon-picker`
32
- - `#/animations/otp` — animated OTP demo
33
- - `#/animations/horizontal-accordion`, `/storytelling`, `/stacking`,
34
- `/scroll-text`, `/image-crossfade` — scroll animations and crossfades
35
-
36
- **Light/dark** theme: button in the header, choice persisted in
37
- `localStorage (uifolio-theme)`, initialized before the first paint (no flash),
38
- default = system preference.
39
-
40
- **Global customization**: the right panel (or the accordion on mobile) sets the
41
- `--primary` color and the `--radius` for every component. The advanced picker offers
42
- saturation/lightness, hue, alpha, eyedropper, RGB channels, presets and HEX/RGB/HSL/RGBA
43
- formats. The choice is persisted in `localStorage (uifolio:design)` and reused in the
44
- copied CLI commands (`--color`, `--radius`).
6
+ A small, theme-friendly UI registry (components live in the target project, copied and edited) with a shadcn-like CLI (`uifolio add`).
45
7
 
46
8
  ## Using the components in another project (CLI)
47
9
 
@@ -69,21 +31,19 @@ node cli/uifolio.mjs add file-upload --alias "#lib" --dry-run # simulate
69
31
  | `uifolio init` | writes `uifolio.json` (dir, lib, alias) in the target project |
70
32
  | `uifolio add <names...>` | copies the component **+ transitive dependencies** (helpers `@/lib/*`) |
71
33
 
72
- Options: `--cwd <dir>`, `--dir <path>` (default `src/components/ui`),
73
- `--lib <path>` (default `src/lib`), `--alias <alias>` (default `@`),
74
- `--page <path>` (created if missing), `--interactive` (force the questions),
75
- `--force` (overwrite), `--dry-run` (simulate), `--color <hex>` and `--radius <px>`.
34
+ Options: `--cwd <dir>`, `--dir <path>` (default `src/components/ui`), `--lib <path>` (default `src/lib`), `--alias <alias>` (default `@`), `--page <path>` (created if missing), `--interactive` (force the questions), `--force` (overwrite), `--dry-run` (simulate), `--color <hex>` and `--radius <px>`.
76
35
 
77
- `add` does not stop at the copy. Without `--dir` or `--page`, the CLI asks for the
78
- components folder, then:
36
+ `add` does not stop at the copy. The components always land in
37
+ `src/components/ui` (override with `--dir`) and the page imports them through
38
+ the `@/` alias. Without `--page`, the CLI asks where to use them:
79
39
 
80
- 1. *Do you already have a file ready to receive the component (Calendar)?*
81
- `y` → *In which file should the component (Calendar) be copied?* (format
82
- `src/pages/calendar.tsx`): the import and `<Component />` are added without
83
- duplication, and the file is created when missing;
84
- 2. `n` → *Would you like to create a new folder and an import file?*
85
- `y` → *Please specify the new path*: the folder and the file are created;
86
- `n` → install only, no file is touched.
40
+ 1. *Do you already have a file ready to receive the component (…component…) (y/N) ?* `yes` → *In which file should the component (…component…) be copied (e.g. src/pages/calendar.tsx) ?*: the import and a ready-to-render example are added without duplication, and the file is created when missing;
41
+ 2. `no` → *Would you like to create a new folder and an import file (Y/n) ?* `yes` → *Please specify the new path (e.g. src/pages/calendar.tsx) ?*: the folder and the file are created; `no` → install only, no file is touched.
42
+
43
+ The example is a small component (`<CalendarExample />` plus its definition)
44
+ that carries the data and props each component needs (items for `Stories` and
45
+ `CommandMenu`, a source for the players, children for the resizable panels…),
46
+ so the component is visible immediately instead of an empty `<Component />`.
87
47
 
88
48
  Answering `-` to a path question installs the component without creating or
89
49
  modifying any page.
@@ -103,65 +63,17 @@ At the end, the CLI prints the detected npm dependencies, for example:
103
63
  npm install clsx tailwind-merge lucide-react hls.js
104
64
  ```
105
65
 
106
- ## Light/dark themes
107
-
108
- - CSS tokens: `:root` (light) and `.dark` (dark) in `src/index.css`,
109
- wired to Tailwind through `@theme inline` + `@custom-variant dark`.
110
- - `useTheme()` (`src/lib/use-theme.ts`): `{ theme, setTheme, toggle }`,
111
- persistence in `localStorage('uifolio-theme')`.
112
- - `<ThemeToggle />` (`src/components/theme-toggle.tsx`): Sun/Moon button.
113
- - No fixed color anywhere in the components: everything goes through
114
- `background`, `foreground`, `card`, `muted`, `border`, `primary`, `accent`, `ring`.
115
-
116
- ## Real upload
117
-
118
- `FileUpload` knows nothing about your backend: it calls
119
- `uploader(file, onProgress) => Promise<url>` and shows progress, success and a link
120
- to the returned public URL.
121
-
122
- The demo uses `createUploader()` (`src/lib/uploader.ts`):
66
+ ### Preview parity
123
67
 
124
- 1. `POST /api/upload/sign` → `{ key, uploadUrl, publicUrl }` (signed URL);
125
- 2. `PUT` of the file through **XHR with progress**;
126
- 3. **automatic retry** (2 retries, backoff) reusing the same signed URL;
127
- a 4xx does not retry (final refusal).
68
+ The docs preview and the installed component render identically because the page
69
+ written by the CLI ships a demo of the same shape as the docs. If a component
70
+ needs data to show anything (for example Stories, CommandMenu, FileUpload,
71
+ AudioPlayer, VideoPlayer or the resizable panels), the page is created with that
72
+ data, not an empty instance.
128
73
 
129
- On the server side, the `upload-plugin.ts` plugin (wired in `vite.config.ts`):
130
-
131
- - **Local mode (default, zero config)**: writes into `public/uploads/` and serves
132
- `/uploads/<key>`;
133
- - **S3-compatible mode** (AWS S3, Cloudflare R2, MinIO): when `S3_ENDPOINT`,
134
- `S3_BUCKET`, `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` are set, the server
135
- returns a **SigV4** signed PUT URL (implemented with `node:crypto`, no SDK)
136
- and the browser uploads straight to the bucket.
137
-
138
- Configuration in `.env` — see `.env.example`.
139
-
140
- Plugging in your own uploader:
141
-
142
- ```tsx
143
- const upload = createUploader({ signEndpoint: '/api/upload/sign' })
144
-
145
- // or your own backend:
146
- const upload = async (file: File, onProgress: (v: number) => void) => {
147
- const form = new FormData()
148
- form.append('file', file)
149
- const res = await fetch('/api/your-endpoint', { method: 'POST', body: form })
150
- const { url } = await res.json()
151
- return url
152
- }
153
-
154
- <FileUpload accept="image/*" uploader={upload} onChange={setFiles} />
155
- ```
156
-
157
- ## Stack
158
-
159
- - Vite 8 · React 19 · TypeScript
160
- - Tailwind CSS 4 (CSS tokens, no JS config)
161
- - `lucide-react` for icons, `clsx` + `tailwind-merge` for `cn()`
162
- - `hls.js` imported **dynamically**, only when a `.m3u8` stream is played
163
- - No other dependency: editor, calendar, time picker, panels, palette,
164
- OTP and SigV4 signing are home-made
74
+ If the docs entry for a component is missing, the detail page does not show a blank
75
+ placeholder. It falls back to the preview as-is (or a minimal coherent state) and
76
+ only marks the installation as complete when that is relevant.
165
77
 
166
78
  ## Components
167
79
 
@@ -178,142 +90,24 @@ const upload = async (file: File, onProgress: (v: number) => void) => {
178
90
  | ResizablePanels | `src/components/ui/resizable.tsx` | horizontal/vertical groups, handles, min-size, keyboard |
179
91
  | OtpInput | `src/components/ui/otp-input.tsx` | 4 fields, paste, rotate + success check |
180
92
  | ColorPicker | `src/components/ui/color-picker.tsx` | advanced panel, saturation/lightness, hue, alpha, eyedropper, RGB channels, presets, HEX/RGB/HSL/RGBA |
93
+ | Table | `src/components/ui/table.tsx` | shadcn-style typed table, uniform `px-4` cells and a wider gap before Actions, select-all checkbox, row actions |
181
94
 
182
95
  Copyable helpers (the CLI follows them automatically): `src/lib/utils.ts`,
183
96
  `src/lib/video.ts`, `src/lib/vtt.ts`, `src/lib/date.ts`, `src/lib/uploader.ts`, `src/lib/color.ts`,
184
97
  `src/lib/use-theme.ts`, `src/lib/use-hotkeys.ts`, `src/lib/use-reduced-motion.ts`.
185
98
 
186
- ## VideoPlayer
187
-
188
- ```tsx
189
- <VideoPlayer
190
- sources={file ?? '/video.mp4'}
191
- aspectRatio="16/9"
192
- fit="cover"
193
- controls="full" // full | minimal | none
194
- accent="#facc15"
195
- autoPlay
196
- muted
197
- loop
198
- lazy
199
- subtitles={[
200
- { src: '/fr.vtt', label: 'French', lang: 'fr', default: true },
201
- { src: '/en.vtt', label: 'English', lang: 'en' },
202
- ]}
203
- subtitleClassName="bottom-20"
204
- overlay={(state) => <MyBadge playing={state.playing} />}
205
- />
206
- ```
207
-
208
- - Sources: direct URL, `File`/`Blob`, quality list (`{ src, quality }`), `.m3u8`,
209
- YouTube and Vimeo.
210
- - For embeds, the iframe is only injected on click (facade + thumbnail) and native
211
- controls are hidden: the component bar drives the platform player
212
- (progress, time, volume, speed, play/pause) through `postMessage`.
213
- - `ref` exposes `play`, `pause`, `seek`, `mute`, `setVolume`, `setPlaybackRate`,
214
- `requestFullscreen`, `getVideo`.
215
- - The progress bar and the volume follow the global `--radius` (handles
216
- included), like the rest of the catalog.
217
- - Optimizations: lazy loading, pause off-screen, controlled `preload`, `hls.js` in a separate
218
- chunk, revoked object URLs, quality switch without losing the position.
219
- - Subtitles: tolerant VTT parsing, file or URL tracks, free styling through
220
- `subtitleClassName`, CC menu.
221
-
222
- ## Calendar / TimePicker
223
-
224
- ```tsx
225
- const [date, setDate] = useState<CalendarValue>(null)
226
- <Calendar mode="single" value={date} onChange={setDate} min={min} max={max} />
227
- <Calendar mode="range" value={range} onChange={setRange} />
228
- <Calendar disabled={(day) => day.getDay() === 0} />
229
-
230
- <TimePicker value={time} onChange={setTime} withSeconds step={15} hour12 />
231
- ```
232
-
233
- Manual input accepts `dd/mm/yyyy`, `dd-mm-yyyy` and `yyyy-mm-dd`.
234
-
235
- ## Audio / Stories
236
-
237
- ```tsx
238
- <AudioPlayer src="/audio.wav" title="Title" cover="/cover.svg" accent="#facc15" />
239
- <AudioPlayer variant="compact" src="/audio.wav" title="Title" />
240
-
241
- <Stories items={stories} open={open} onOpenChange={setOpen} duration={5000} />
242
- ```
243
-
244
- A story can be an image, a video or a text slide with its own background and duration.
245
-
246
- ## Editor / Upload / Shortcuts / Panels / OTP
247
-
248
- ```tsx
249
- <RichTextEditor value={html} onChange={setHtml} toolbar />
250
-
251
- <FileUpload accept="image/*,video/*" maxFiles={3} uploader={upload} onChange={setFiles} />
252
-
253
- <CommandMenu items={items} open={open} onOpenChange={setOpen} />
254
- useHotkeys({ 'mod+k': () => setOpen(true) })
255
-
256
- <ResizablePanelGroup direction="horizontal">
257
- <ResizablePanel defaultSize={34} minSize={20}>…</ResizablePanel>
258
- <ResizableHandle />
259
- <ResizablePanel defaultSize={66} minSize={25}>…</ResizablePanel>
260
- </ResizablePanelGroup>
261
-
262
- <OtpInput length={4} value={code} onChange={setCode} onComplete={validate} />
263
- ```
264
-
265
- The OTP aligns 4 to 8 fields, forms a circle on validation and then shows a check.
266
- The animation respects `prefers-reduced-motion`.
267
-
268
- ## Animations
269
-
270
- Every animation has its own documented page: `#/animations/otp` for the OTP, then
271
- `#/animations/horizontal-accordion` and `#/animations/storytelling` for the
272
- horizontal cards, and `#/animations/stacking` for the cards that stack on scroll with
273
- a text color driven by progress. Colors follow `--primary`. The number of cards is set
274
- from 4 to 8 right in each preview. These stand-alone demos are not installable
275
- through the CLI.
276
-
277
99
  ## Publishing the package
278
100
 
279
- The published package only contains the CLI, the components and the helpers (`files`): no
280
- runtime dependency, React and Tailwind stay in `devDependencies` so they are not installed
281
- by people running `npx uifolio`.
282
-
283
101
  ```bash
284
- npm login # once per machine
285
- npm profile enable-2fa auth-and-writes # npm refuses to publish without 2FA
286
- npm publish --otp 123456 # prepublishOnly checks cli/uifolio.mjs
102
+ npm publish
287
103
  ```
288
104
 
289
- A *granular* token with “Bypass 2FA” also works, but npm is progressively restricting
290
- these tokens for publishing: 2FA + `--otp` is the safe path.
291
-
292
- Then `npx uifolio@latest add <component>` and `pnpm dlx uifolio@latest add <component>`
293
- work.
105
+ Automatic publishing is wired in `.github/workflows/publish.yml` and runs on tag push.
294
106
 
295
107
  ### Automatic publishing (GitHub Actions)
296
108
 
297
- `.github/workflows/publish.yml` publishes to npm on every `v*` tag: it checks that the tag
298
- matches the `package.json` version, installs with `npm ci`, runs `oxlint`,
299
- `npm pack --dry-run` and `npm test`, then publishes through **trusted publishing** (OIDC, no
300
- stored token). A second job then creates the tag's **GitHub Release** with notes generated
301
- from the commits.
302
-
303
- ```bash
304
- npm version patch # 0.3.0 → 0.3.1, commit + tag v0.3.1
305
- git push --follow-tags # triggers the workflow
306
- ```
307
-
308
- Prerequisites, once:
109
+ The workflow publishes on tag `v*`. Run `git tag vX.Y.Z && git push --tags` to publish.
309
110
 
310
- 1. first manual publication (above) — the package must exist on npm;
311
- 2. npmjs.com → `uifolio` → Settings → **Trusted publishing** → GitHub Actions:
312
- organization `Barronakn`, repository `UIfolio`, workflow `publish.yml`,
313
- “npm publish” action allowed;
314
- 3. a new configuration must complete its first publication **within 2 days**,
315
- otherwise it expires and has to be recreated.
111
+ More info: [README on GitHub](https://github.com/Barronakn/UIfolio#readme).
316
112
 
317
- The workflow must be present on the tagged commit. Once enabled, you can harden the
318
- package with Settings → Publishing access → “Require two-factor authentication and
319
- disallow tokens”.
113
+ License: [MIT](https://opensource.org/license/mit).
package/cli/uifolio.mjs CHANGED
@@ -22,8 +22,8 @@
22
22
  * --force overwrite existing files
23
23
  * --dry-run print what would be copied without writing anything
24
24
  *
25
- * Without --dir / --page, the CLI asks interactively (terminal) which folder
26
- * receives the components and where to use them.
25
+ * Without --page, the CLI asks interactively (terminal) where to use them.
26
+ * The components always land in src/components/ui unless --dir overrides it.
27
27
  *
28
28
  * Examples:
29
29
  * node cli/uifolio.mjs add calendar otp --cwd ../my-project
@@ -164,10 +164,11 @@ function externalDeps(source) {
164
164
  return [...deps]
165
165
  }
166
166
 
167
- /** Real exported name of the file (otp-input → OtpInput). */
167
+ /** Real exported name of the file (otp-input → OtpInput, icon-picker → IconPicker). */
168
168
  function componentExportName(source, fallback) {
169
- const matches = [...source.matchAll(/export\s+function\s+([A-Z][A-Za-z0-9_]*)/g)].map((match) => match[1])
170
- return matches[0] ?? fallback
169
+ const exported = [...source.matchAll(/export\s+function\s+([A-Z][A-Za-z0-9_]*)/g)].map((match) => match[1])
170
+ const pascal = fallback.replace(/(^|-)([a-z0-9])/g, (_, __, char) => char.toUpperCase())
171
+ return exported.find((name) => name === pascal) ?? exported[0] ?? fallback
171
172
  }
172
173
 
173
174
  /** .tsx files under src/pages of the target project, sorted. */
@@ -222,8 +223,9 @@ function createPrompter(flags) {
222
223
  })
223
224
 
224
225
  async function ask(question, fallback) {
226
+ const text = question.trim().replace(/\?+$/, '')
225
227
  const suffix = fallback ? ` (${fallback})` : ''
226
- process.stdout.write(`\n \x1b[36m?\x1b[0m ${question}${suffix} : `)
228
+ process.stdout.write(`\n \x1b[36m${text}${suffix} ?\x1b[0m `)
227
229
 
228
230
  let answer
229
231
  if (queued.length > 0) answer = queued.shift()
@@ -243,7 +245,7 @@ function createPrompter(flags) {
243
245
  ask,
244
246
  /** y / yes / n / no (French values tolerated as fallback). */
245
247
  async confirm(question, fallback = false) {
246
- const answer = await ask(`${question} (y/n)`)
248
+ const answer = await ask(question, fallback ? 'Y/n' : 'y/N')
247
249
  const value = String(answer ?? '')
248
250
  .trim()
249
251
  .toLowerCase()
@@ -463,10 +465,179 @@ function writeTheme({ cwd, accent, radius, dryRun }) {
463
465
  /* Usage du composant dans une page */
464
466
  /* -------------------------------------------------------------------------- */
465
467
 
466
- /** Chemin d'import relatif, sans extension, en slashs. */
467
- function importSpecifier(fromDir, target) {
468
- const rel = relative(fromDir, target).replace(/\\/g, '/')
469
- return rel.startsWith('.') ? rel : `./${rel}`
468
+ /** Import specifier through the project alias: @/components/ui/calendar. */
469
+ function aliasImport(cwd, componentsDir, name, alias) {
470
+ const relativePath = relative(resolve(cwd), join(resolve(cwd, componentsDir), name))
471
+ .replace(/\\/g, '/')
472
+ .replace(/^src\//, '')
473
+ return `${alias}/${relativePath}`
474
+ }
475
+
476
+ /**
477
+ * Example generated with the page: an instance that renders right away.
478
+ * A bare `<Component />` shows nothing for dialogs, players, radio groups or
479
+ * the animated wrappers that require children, so these examples carry the
480
+ * props and data each one needs.
481
+ */
482
+ const USAGE_EXAMPLES = {
483
+ 'audio-player': {
484
+ body: ` return (
485
+ <AudioPlayer
486
+ src="/sample.wav"
487
+ title="Test signal"
488
+ subtitle="Generated loop · 8 s · WAV"
489
+ accent="#10B981"
490
+ />
491
+ )
492
+ `,
493
+ },
494
+ 'video-player': {
495
+ body: ` return <VideoPlayer sources="/sample-720.mp4" aspectRatio="16/9" muted loop />
496
+ `,
497
+ },
498
+ 'file-upload': {
499
+ body: ` return <FileUpload accept="image/*,video/*" maxSizeMb={25} maxFiles={3} />
500
+ `,
501
+ },
502
+ choice: {
503
+ imports: ["import { useState } from 'react'"],
504
+ body: ` const [checked, setChecked] = useState(true)
505
+
506
+ return (
507
+ <Choice
508
+ checked={checked}
509
+ onChange={setChecked}
510
+ label="Email alerts"
511
+ description="Get a summary every week."
512
+ />
513
+ )
514
+ `,
515
+ },
516
+ 'command-menu': {
517
+ imports: ["import { useState } from 'react'"],
518
+ body: ` const [open, setOpen] = useState(false)
519
+
520
+ return (
521
+ <div className="space-y-3">
522
+ <button
523
+ type="button"
524
+ onClick={() => setOpen(true)}
525
+ className="rounded-full bg-primary px-4 py-2 text-xs font-medium text-primary-foreground"
526
+ >
527
+ Open the palette
528
+ </button>
529
+ <CommandMenu
530
+ open={open}
531
+ onOpenChange={setOpen}
532
+ items={[
533
+ { id: 'home', label: 'Home', group: 'Navigation', action: () => {} },
534
+ { id: 'components', label: 'Components', group: 'Navigation', action: () => {} },
535
+ ]}
536
+ />
537
+ </div>
538
+ )
539
+ `,
540
+ },
541
+ stories: {
542
+ imports: ["import { useState } from 'react'"],
543
+ types: ['StoryItem'],
544
+ body: ` const [open, setOpen] = useState(false)
545
+ const items: StoryItem[] = [
546
+ { type: 'image', src: '/story-1.svg', title: 'Dawn', duration: 5000 },
547
+ { type: 'text', title: 'Text only', text: 'A story can be plain text.', duration: 5000 },
548
+ ]
549
+
550
+ return (
551
+ <div className="space-y-3">
552
+ <button
553
+ type="button"
554
+ onClick={() => setOpen(true)}
555
+ className="rounded-full bg-primary px-4 py-2 text-xs font-medium text-primary-foreground"
556
+ >
557
+ Open the stories
558
+ </button>
559
+ <Stories items={items} open={open} onOpenChange={setOpen} />
560
+ </div>
561
+ )
562
+ `,
563
+ },
564
+ resizable: {
565
+ members: ['ResizablePanel', 'ResizableHandle'],
566
+ body: ` return (
567
+ <ResizablePanelGroup className="h-64 rounded-xl border">
568
+ <ResizablePanel className="p-4">Left panel</ResizablePanel>
569
+ <ResizableHandle />
570
+ <ResizablePanel className="p-4">Right panel</ResizablePanel>
571
+ </ResizablePanelGroup>
572
+ )
573
+ `,
574
+ },
575
+ motion: {
576
+ body: ` return (
577
+ <FadeIn>
578
+ <p className="text-sm">Fade in on mount.</p>
579
+ </FadeIn>
580
+ )
581
+ `,
582
+ },
583
+ table: {
584
+ imports: ["import { useState } from 'react'"],
585
+ members: ['type TableColumn'],
586
+ body: ` const [selected, setSelected] = useState<string[]>([])
587
+ type Row = { id: string; name: string; status: string; total: number }
588
+ const rows: Row[] = [
589
+ { id: 'finanx', name: 'Finanx', status: 'Active', total: 0 },
590
+ { id: 'facebook', name: 'Facebook', status: 'Active', total: 408221304 },
591
+ ]
592
+ const columns: TableColumn<Row>[] = [
593
+ { key: 'name', header: 'Name', cell: (row) => row.name },
594
+ { key: 'status', header: 'Status', cell: (row) => row.status },
595
+ { key: 'total', header: 'Total received', cell: (row) => row.total },
596
+ ]
597
+
598
+ return (
599
+ <Table
600
+ rows={rows}
601
+ columns={columns}
602
+ rowKey={(row) => row.id}
603
+ selectable
604
+ selectedKeys={selected}
605
+ onSelectionChange={setSelected}
606
+ />
607
+ )
608
+ `,
609
+ },
610
+ }
611
+
612
+ /** Example component written next to the page, plus the imports it needs. */
613
+ function exampleFor({ name, exportName }) {
614
+ const spec = USAGE_EXAMPLES[name]
615
+ const component = `${exportName}Example`
616
+ if (!spec) {
617
+ return {
618
+ component,
619
+ imports: [],
620
+ types: [],
621
+ members: [],
622
+ code: `function ${component}() {\n return <${exportName} />\n}\n`,
623
+ }
624
+ }
625
+ return {
626
+ component,
627
+ imports: spec.imports ?? [],
628
+ types: spec.types ?? [],
629
+ members: spec.members ?? [],
630
+ code: `function ${component}() {\n${spec.body}}\n`,
631
+ }
632
+ }
633
+
634
+ /** Appends the example components that are not in the file yet. */
635
+ function addExamples(source, examples) {
636
+ const missing = examples.filter(
637
+ ({ component }) => !new RegExp(`function\\s+${component}\\s*\\(`).test(source),
638
+ )
639
+ if (missing.length === 0) return source
640
+ return `${source.trimEnd()}\n\n${missing.map(({ code }) => code.trimEnd()).join('\n\n')}\n`
470
641
  }
471
642
 
472
643
  /** Inserts the imports after the last import of the file. */
@@ -563,33 +734,49 @@ function addJsxUsage(source, exportNames) {
563
734
  }
564
735
 
565
736
  /**
566
- * Writes (or completes) the chosen file: imports from the real folder of the
567
- * composants + appel `<Composant />` dans le JSX.
737
+ * Writes (or completes) the chosen file: `@/` imports from the real folder of
738
+ * the components, a working example (`<CalendarExample />`) and its definition.
568
739
  */
569
- function writeUsage({ cwd, target, componentsDir, components, dryRun }) {
740
+ function writeUsage({ cwd, target, componentsDir, components, alias, dryRun }) {
570
741
  if (existsSync(target) && statSync(target).isDirectory()) {
571
742
  fail(`${relative(cwd, target)} is a folder: give a file (e.g. src/pages/${components[0].name}.tsx).`)
572
743
  }
573
744
  const created = !existsSync(target)
574
- const lines = components.map(({ name, exportName }) =>
575
- `import { ${exportName} } from '${importSpecifier(dirname(target), join(resolve(cwd, componentsDir), name))}'`,
576
- )
745
+ const entries = components.map((component) => ({
746
+ exportName: component.exportName,
747
+ specifier: aliasImport(cwd, componentsDir, component.name, alias),
748
+ example: exampleFor(component),
749
+ }))
577
750
  const label = relative(cwd, target)
578
- const names = components.map((component) => component.exportName)
751
+
752
+ const imports = new Set()
753
+ for (const { exportName, specifier, example } of entries) {
754
+ for (const line of example.imports) imports.add(line)
755
+ imports.add(`import { ${[exportName, ...example.members].join(', ')} } from '${specifier}'`)
756
+ if (example.types.length > 0) {
757
+ imports.add(`import type { ${example.types.join(', ')} } from '${specifier}'`)
758
+ }
759
+ }
760
+
761
+ const orderedImports = [...imports].sort(
762
+ (left, right) => Number(left.includes(`'${alias}/`)) - Number(right.includes(`'${alias}/`)),
763
+ )
579
764
 
580
765
  if (created) {
581
766
  const body = [
582
- ...lines,
583
- '',
584
- `export default function ${names[0]}Page() {`,
585
- ' return (',
586
- ' <main className="space-y-6 p-6">',
587
- ...names.map((name) => ` <${name} />`),
588
- ' </main>',
589
- ' )',
590
- '}',
767
+ orderedImports.join('\n'),
768
+ entries.map(({ example }) => example.code.trimEnd()).join('\n\n'),
769
+ [
770
+ `export default function ${entries[0].exportName}Page() {`,
771
+ ' return (',
772
+ ' <main className="space-y-6 p-6">',
773
+ ...entries.map(({ example }) => ` <${example.component} />`),
774
+ ' </main>',
775
+ ' )',
776
+ '}',
777
+ ].join('\n'),
591
778
  '',
592
- ].join('\n')
779
+ ].join('\n\n')
593
780
  if (!dryRun) {
594
781
  try {
595
782
  mkdirSync(dirname(target), { recursive: true })
@@ -601,8 +788,13 @@ function writeUsage({ cwd, target, componentsDir, components, dryRun }) {
601
788
  ok(`${dryRun ? '[dry-run] ' : ''}page created: ${label}`)
602
789
  } else {
603
790
  const source = readFileSync(target, 'utf8')
604
- const withImports = addImports(source, lines)
605
- const { source: updated, added } = addJsxUsage(withImports, names)
791
+ const withImports = addImports(source, [...imports])
792
+ const names = entries.map(({ example }) => example.component)
793
+ const { source: withUsage, added } = addJsxUsage(withImports, names)
794
+ const updated = addExamples(
795
+ withUsage,
796
+ entries.map(({ example }) => example),
797
+ )
606
798
  if (!dryRun) {
607
799
  try {
608
800
  writeFileSync(target, updated, 'utf8')
@@ -617,7 +809,7 @@ function writeUsage({ cwd, target, componentsDir, components, dryRun }) {
617
809
  }
618
810
  }
619
811
 
620
- for (const name of names) info(`· <${name} />`)
812
+ for (const { example } of entries) info(`· <${example.component} />`)
621
813
  }
622
814
 
623
815
  /* -------------------------------------------------------------------------- */
@@ -672,20 +864,26 @@ async function chooseUsageTarget({ prompter, cwd, names, exportNames }) {
672
864
  if (candidates.length > 0) info(`existing files: ${candidates.join(', ')}`)
673
865
 
674
866
  const ready = await prompter.confirm(
675
- `Do you already have a file ready to receive the component (${label})?`,
867
+ `Do you already have a file ready to receive the component (${label})`,
676
868
  false,
677
869
  )
678
870
 
679
871
  let answer
680
872
  if (ready) {
681
- answer = await prompter.ask(`In which file should the component (${label}) be copied? (${pathFormat})`, suggestion)
873
+ answer = await prompter.ask(
874
+ `In which file should the component (${label}) be copied`,
875
+ pathFormat,
876
+ )
682
877
  } else {
683
- const create = await prompter.confirm('Would you like to create a new folder and an import file?', true)
878
+ const create = await prompter.confirm(
879
+ 'Would you like to create a new folder and an import file',
880
+ true,
881
+ )
684
882
  if (!create) {
685
883
  info('the component is installed but not used in any file.')
686
884
  return null
687
885
  }
688
- answer = await prompter.ask(`Please specify the new path (${pathFormat})`, suggestion)
886
+ answer = await prompter.ask('Please specify the new path', pathFormat)
689
887
  }
690
888
 
691
889
  // “ - ”: installation only, like the “no” answer.
@@ -730,20 +928,10 @@ async function commandAdd({ positional, flags }) {
730
928
 
731
929
  const prompter = createPrompter(flags)
732
930
  try {
733
- // 1. Dossier d'import des composants.
734
- let componentsDir = normalizeComponentsDir(
931
+ // 1. Dossier d'import des composants : toujours src/components/ui.
932
+ const componentsDir = normalizeComponentsDir(
735
933
  typeof flags.dir === 'string' ? flags.dir : config.dir ?? DEFAULT_COMPONENTS_DIR,
736
934
  )
737
- if (prompter && typeof flags.dir !== 'string') {
738
- // A file path was already accepted here (mkdir relative to the root).
739
- for (let attempts = 0; ; attempts += 1) {
740
- const fallback = attempts === 0 ? componentsDir : DEFAULT_COMPONENTS_DIR
741
- const answer = await prompter.ask('In which folder should the components be copied?', fallback)
742
- componentsDir = normalizeComponentsDir(answer ?? DEFAULT_COMPONENTS_DIR)
743
- if (!looksLikeFile(componentsDir) || attempts >= 2) break
744
- info(`"${componentsDir}" is a file, not a folder: give a folder, e.g. ${DEFAULT_COMPONENTS_DIR}.`)
745
- }
746
- }
747
935
  if (looksLikeFile(componentsDir)) {
748
936
  fail(`"${componentsDir}" is a file: give a folder (e.g. ${DEFAULT_COMPONENTS_DIR}).`)
749
937
  }
@@ -807,7 +995,16 @@ async function commandAdd({ positional, flags }) {
807
995
  exportNames: components.map((component) => component.exportName),
808
996
  })
809
997
  }
810
- if (pageTarget) writeUsage({ cwd, target: pageTarget, componentsDir, components, dryRun })
998
+ if (pageTarget) {
999
+ writeUsage({
1000
+ cwd,
1001
+ target: pageTarget,
1002
+ componentsDir,
1003
+ components,
1004
+ alias: targetAlias,
1005
+ dryRun,
1006
+ })
1007
+ }
811
1008
 
812
1009
  if (written.size > 0 || dryRun) {
813
1010
  console.log('\n Detected npm dependencies:')
@@ -847,11 +1044,13 @@ Options:
847
1044
  --force overwrite existing files
848
1045
  --dry-run simulate without writing
849
1046
 
850
- Without --dir or --page, the CLI asks in which folder to copy the components,
851
- then whether a file is already ready to receive them. The component is either
852
- added to an existing file, added to a new folder + import file, or installed
853
- only. Colors go through a Tailwind CSS 4 theme (uifolio-theme.css, @theme), so
854
- classes like bg-primary and text-muted-foreground stay in the code.
1047
+ Without --page, the CLI asks whether a file is already ready to receive the
1048
+ components: they are added to that file, to a new folder + file, or installed
1049
+ only. Files go to src/components/ui (override with --dir) and the page imports
1050
+ them through the alias (@/components/ui/...). The page also gets a working
1051
+ example, with the data and props each component needs, so it shows immediately.
1052
+ Colors go through a Tailwind CSS 4 theme (uifolio-theme.css, @theme), so classes
1053
+ like bg-primary and text-muted-foreground stay in the code.
855
1054
 
856
1055
  Examples:
857
1056
  node cli/uifolio.mjs add video-player calendar otp --cwd ../my-project
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uifolio",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "CLI that copies React components (calendar, video/audio players, OTP, editor, upload) and their helpers into your project, with Tailwind CSS 4 tokens.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,6 +29,7 @@
29
29
  "file-upload",
30
30
  "rich-text-editor",
31
31
  "command-menu",
32
+ "data-table",
32
33
  "resizable-panels",
33
34
  "time-picker",
34
35
  "cadence",
@@ -136,7 +136,7 @@ export function AudioPlayer({
136
136
  <button
137
137
  type="button"
138
138
  onClick={() => void togglePlay()}
139
- aria-label={playing ? 'Mettre en pause' : 'Lire'}
139
+ aria-label={playing ? 'Pause' : 'Play'}
140
140
  className="grid size-9 shrink-0 place-items-center rounded-full bg-[var(--ap-accent)] text-black shadow-sm transition hover:brightness-110"
141
141
  >
142
142
  {playing ? (
@@ -157,7 +157,7 @@ export function AudioPlayer({
157
157
  step={0.05}
158
158
  value={Math.min(currentTime, duration || 0)}
159
159
  onChange={(event) => seek(Number(event.target.value))}
160
- aria-label="Position de lecture"
160
+ aria-label="Playback position"
161
161
  className="h-1 w-full min-w-0 cursor-pointer appearance-none rounded-full"
162
162
  style={{
163
163
  accentColor: accent,
@@ -176,7 +176,7 @@ export function AudioPlayer({
176
176
  <button
177
177
  type="button"
178
178
  onClick={() => void togglePlay()}
179
- aria-label={playing ? 'Mettre en pause' : 'Lire'}
179
+ aria-label={playing ? 'Pause' : 'Play'}
180
180
  className="grid size-8 shrink-0 place-items-center rounded-full bg-[var(--ap-accent)] text-black transition hover:brightness-110"
181
181
  >
182
182
  {playing ? (
@@ -195,7 +195,7 @@ export function AudioPlayer({
195
195
  step={0.05}
196
196
  value={Math.min(currentTime, duration || 0)}
197
197
  onChange={(event) => seek(Number(event.target.value))}
198
- aria-label="Position de lecture"
198
+ aria-label="Playback position"
199
199
  className="mt-1.5 h-1 w-full cursor-pointer appearance-none rounded-full"
200
200
  style={{
201
201
  accentColor: accent,
@@ -214,7 +214,7 @@ export function AudioPlayer({
214
214
  <button
215
215
  type="button"
216
216
  onClick={() => setMuted((value) => !value)}
217
- aria-label={muted ? 'Activer le son' : 'Couper le son'}
217
+ aria-label={muted ? 'Unmute' : 'Mute'}
218
218
  className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground"
219
219
  >
220
220
  {effectiveVolume === 0 ? (
@@ -246,12 +246,12 @@ export function AudioPlayer({
246
246
  <button
247
247
  type="button"
248
248
  onClick={cycleRate}
249
- aria-label={`Vitesse ${rate}×`}
249
+ aria-label={`Speed ${rate}×`}
250
250
  className="ml-auto rounded-md px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground transition hover:bg-muted hover:text-foreground"
251
251
  >
252
252
  {rate}×
253
253
  </button>
254
- {error && <span className="text-[10px] text-rose-400">Source indisponible</span>}
254
+ {error && <span className="text-[10px] text-rose-400">Source unavailable</span>}
255
255
  </div>
256
256
  </div>
257
257
  )
@@ -313,7 +313,7 @@ export function ColorPicker({
313
313
 
314
314
  <div className="relative min-w-0 flex-1">
315
315
  <span className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 font-mono text-[9px] font-semibold uppercase tracking-wide text-muted-foreground">
316
- {displayFormat}
316
+ {label ?? 'Color'}
317
317
  </span>
318
318
  <input
319
319
  id={inputId}
@@ -382,7 +382,7 @@ export function ColorPicker({
382
382
  maxHeight: 'min(620px, calc(100dvh - 20px))',
383
383
  visibility: position ? 'visible' : 'hidden',
384
384
  }}
385
- className="z-[100] overflow-y-auto rounded-md border bg-card text-card-foreground shadow-2xl outline-none"
385
+ className="z-100 overflow-y-auto rounded-md border bg-card text-card-foreground shadow-2xl outline-none"
386
386
  >
387
387
  <div className="flex items-start justify-between gap-3 border-b px-3.5 py-3">
388
388
  <div className="min-w-0">
@@ -444,7 +444,7 @@ export function ColorPicker({
444
444
 
445
445
  <div className="space-y-2">
446
446
  <div className="flex items-center justify-between text-[9px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
447
- <span>Teinte</span>
447
+ <span>Hue</span>
448
448
  <span className="font-mono">{Math.round(hue)}°</span>
449
449
  </div>
450
450
  <input
@@ -458,7 +458,7 @@ export function ColorPicker({
458
458
  setDraft(null)
459
459
  emitHsv({ ...hsva, h: Number(event.target.value) })
460
460
  }}
461
- aria-label="Teinte"
461
+ aria-label="Hue"
462
462
  className="h-3 w-full cursor-pointer appearance-none rounded-full outline-none transition focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-transparent [&::-moz-range-thumb]:shadow-md [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-transparent [&::-webkit-slider-thumb]:shadow-md"
463
463
  style={{
464
464
  background:
@@ -484,7 +484,7 @@ export function ColorPicker({
484
484
  setDraft(null)
485
485
  emit({ ...current, a: Number(event.target.value) / 100 })
486
486
  }}
487
- aria-label="Transparence"
487
+ aria-label="Transparency"
488
488
  className="h-3 w-full cursor-pointer appearance-none rounded-full border border-border outline-none transition focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-transparent [&::-moz-range-thumb]:shadow-md [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-transparent [&::-webkit-slider-thumb]:shadow-md"
489
489
  style={{
490
490
  backgroundColor: 'transparent',
@@ -589,7 +589,7 @@ export function ColorPicker({
589
589
  emit(rgba)
590
590
  }}
591
591
  title={raw}
592
- aria-label={`Couleur ${raw}`}
592
+ aria-label={`Color ${raw}`}
593
593
  aria-pressed={selected}
594
594
  className={cn(
595
595
  'relative grid size-5 place-items-center overflow-hidden rounded-sm border transition hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed',
@@ -94,7 +94,7 @@ export function CommandMenu({
94
94
 
95
95
  return (
96
96
  <div
97
- className="fixed inset-0 z-[70] flex items-start justify-center bg-black/65 px-4 pt-[12vh] backdrop-blur-sm"
97
+ className="fixed inset-0 z-70 flex items-start justify-center bg-black/65 px-4 pt-[12vh] backdrop-blur-sm"
98
98
  onPointerDown={(event) => {
99
99
  if (event.target === event.currentTarget) setOpen(false)
100
100
  }}
@@ -102,7 +102,7 @@ export function CommandMenu({
102
102
  <div
103
103
  role="dialog"
104
104
  aria-modal="true"
105
- aria-label="Palette de commandes"
105
+ aria-label="Command palette"
106
106
  className={cn(
107
107
  'w-full max-w-lg overflow-hidden rounded-sm border bg-card shadow-2xl shadow-black/50',
108
108
  className,
@@ -211,8 +211,8 @@ export function CommandMenu({
211
211
 
212
212
  <div className="flex items-center justify-between border-t px-3.5 py-2 text-[10px] text-muted-foreground">
213
213
  <span className="flex items-center gap-3">
214
- <span>↑↓ naviguer</span>
215
- <span>↵ ouvrir</span>
214
+ <span>↑↓ to navigate</span>
215
+ <span>↵ to open</span>
216
216
  </span>
217
217
  <span className="font-mono">ctrl / ⌘ + k</span>
218
218
  </div>
@@ -243,7 +243,7 @@ export function FileUpload({
243
243
  <button
244
244
  type="button"
245
245
  onClick={() => setRejection(null)}
246
- aria-label="Masquer l'erreur"
246
+ aria-label="Hide error"
247
247
  className="text-rose-300/70 transition hover:text-rose-200"
248
248
  >
249
249
  <X className="size-3.5" />
@@ -322,7 +322,7 @@ export function FileUpload({
322
322
  <button
323
323
  type="button"
324
324
  onClick={() => remove(item.id)}
325
- aria-label={`Retirer ${item.file.name}`}
325
+ aria-label={`Remove ${item.file.name}`}
326
326
  className="grid size-7 place-items-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground"
327
327
  >
328
328
  <X className="size-3.5" />
@@ -245,7 +245,7 @@ export function IconPicker({
245
245
 
246
246
  return (
247
247
  <div className={cn('relative w-full max-w-md', className)}>
248
- {/* Bouton principal */}
248
+ {/* Main button */}
249
249
  <button
250
250
  type="button"
251
251
  onClick={() => setOpen((v) => !v)}
@@ -287,7 +287,7 @@ export function IconPicker({
287
287
  </button>
288
288
  </div>
289
289
 
290
- {/* Barre de recherche */}
290
+ {/* Search bar */}
291
291
  <div className="border-b border-border p-3">
292
292
  <div className="relative">
293
293
  <Search
@@ -298,7 +298,7 @@ export function IconPicker({
298
298
  type="text"
299
299
  value={search}
300
300
  onChange={(e) => setSearch(e.target.value)}
301
- placeholder="Rechercher : shirt, shopping, home..."
301
+ placeholder="Search: shirt, shopping, home..."
302
302
  autoFocus
303
303
  className="h-10 w-full rounded-lg border border-border bg-muted/50 pl-9 pr-3 text-sm text-foreground outline-none transition placeholder:text-muted-foreground focus:border-primary/60 focus:bg-card"
304
304
  />
@@ -352,7 +352,7 @@ export function IconPicker({
352
352
  No icon found
353
353
  </p>
354
354
  <p className="mt-1 text-xs text-muted-foreground/70">
355
- Essayez un autre terme
355
+ Try another term
356
356
  </p>
357
357
  </div>
358
358
  )}
@@ -38,7 +38,7 @@ export function RichTextEditor({
38
38
  value,
39
39
  defaultValue = '',
40
40
  onChange,
41
- placeholder = 'Écris quelque chose…',
41
+ placeholder = 'Write something…',
42
42
  readOnly = false,
43
43
  toolbar = true,
44
44
  minHeight = 220,
@@ -115,7 +115,7 @@ export function RichTextEditor({
115
115
  editor.focus()
116
116
  return
117
117
  }
118
- const input = window.prompt('URL du lien', 'https://')
118
+ const input = window.prompt('Enter link URL', 'https://')
119
119
  if (!input) return
120
120
  const url = /^(https?:|mailto:|tel:)/i.test(input) ? input : `https://${input}`
121
121
  exec('createLink', url)
@@ -136,10 +136,10 @@ export function RichTextEditor({
136
136
  >
137
137
  {toolbar && (
138
138
  <div className="flex flex-wrap items-center gap-0.5 border-b bg-muted/30 p-1.5">
139
- <ToolButton label="Gras" active={active.bold} onClick={() => exec('bold')}>
139
+ <ToolButton label="Bold" active={active.bold} onClick={() => exec('bold')}>
140
140
  <Bold className="size-3.5" />
141
141
  </ToolButton>
142
- <ToolButton label="Italique" active={active.italic} onClick={() => exec('italic')}>
142
+ <ToolButton label="Italic" active={active.italic} onClick={() => exec('italic')}>
143
143
  <Italic className="size-3.5" />
144
144
  </ToolButton>
145
145
  <ToolButton
@@ -159,17 +159,17 @@ export function RichTextEditor({
159
159
 
160
160
  <Divider />
161
161
 
162
- <ToolButton label="Titre 2" active={active.h2} onClick={() => toggleBlock('h2')}>
162
+ <ToolButton label="Heading 2" active={active.h2} onClick={() => toggleBlock('h2')}>
163
163
  <Heading2 className="size-3.5" />
164
164
  </ToolButton>
165
- <ToolButton label="Titre 3" active={active.h3} onClick={() => toggleBlock('h3')}>
165
+ <ToolButton label="Heading 3" active={active.h3} onClick={() => toggleBlock('h3')}>
166
166
  <Heading3 className="size-3.5" />
167
167
  </ToolButton>
168
- <ToolButton label="Paragraphe" onClick={() => toggleBlock('p')}>
168
+ <ToolButton label="Paragraph" onClick={() => toggleBlock('p')}>
169
169
  <Pilcrow className="size-3.5" />
170
170
  </ToolButton>
171
171
  <ToolButton
172
- label="Citation"
172
+ label="Quote"
173
173
  active={active.blockquote}
174
174
  onClick={() => toggleBlock('blockquote')}
175
175
  >
@@ -195,24 +195,23 @@ export function RichTextEditor({
195
195
  >
196
196
  <ListOrdered className="size-3.5" />
197
197
  </ToolButton>
198
- <ToolButton label="Lien" onClick={addLink}>
198
+ <ToolButton label="Link" onClick={addLink}>
199
199
  <Link2 className="size-3.5" />
200
200
  </ToolButton>
201
201
 
202
202
  <Divider />
203
203
 
204
- <ToolButton label="Annuler" onClick={() => exec('undo')}>
204
+ <ToolButton label="Undo" onClick={() => exec('undo')}>
205
205
  <Undo2 className="size-3.5" />
206
206
  </ToolButton>
207
207
  <ToolButton label="Redo" onClick={() => exec('redo')}>
208
208
  <Redo2 className="size-3.5" />
209
209
  </ToolButton>
210
- <ToolButton label="Effacer la mise en forme" onClick={() => exec('removeFormat')}>
210
+ <ToolButton label="Remove formatting" onClick={() => exec('removeFormat')}>
211
211
  <RemoveFormatting className="size-3.5" />
212
212
  </ToolButton>
213
213
 
214
- <span className="ml-auto px-1.5 text-[10px] tabular-nums text-muted-foreground">
215
- {characters} car.
214
+ <span className="ml-auto px-1.5 text-[10px] tabular-nums text-muted-foreground">{characters} chars
216
215
  </span>
217
216
  </div>
218
217
  )}
@@ -166,7 +166,7 @@ export function Stories({
166
166
  aria-modal="true"
167
167
  aria-label="Stories"
168
168
  className={cn(
169
- 'fixed inset-0 z-[80] flex items-center justify-center bg-black/95 p-0 backdrop-blur-sm sm:p-6',
169
+ 'fixed inset-0 z-80 flex items-center justify-center bg-black/95 p-0 backdrop-blur-sm sm:p-6',
170
170
  className,
171
171
  )}
172
172
  >
@@ -210,7 +210,7 @@ export function Stories({
210
210
  type="button"
211
211
  onClick={close}
212
212
  onPointerDown={(event) => event.stopPropagation()}
213
- aria-label="Fermer les stories"
213
+ aria-label="Close stories"
214
214
  className="grid size-8 place-items-center rounded-full bg-black/40 text-white/80 ring-1 ring-white/15 transition hover:bg-black/60 hover:text-white"
215
215
  >
216
216
  <X className="size-4" />
@@ -0,0 +1,190 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, type ReactNode } from 'react'
4
+ import { cn } from '@/lib/utils'
5
+ import { useControllableValue } from '@/lib/use-controllable'
6
+
7
+ export interface TableColumn<T> {
8
+ /** Stable identifier, also used as the React key of the column. */
9
+ key: string
10
+ header: ReactNode
11
+ cell: (row: T, index: number) => ReactNode
12
+ /** Applied to the header and body cells: width, nowrap… */
13
+ className?: string
14
+ align?: 'left' | 'center' | 'right'
15
+ }
16
+
17
+ export interface TableProps<T> {
18
+ columns: ReadonlyArray<TableColumn<T>>
19
+ rows: ReadonlyArray<T>
20
+ rowKey: (row: T) => string | number
21
+ selectable?: boolean
22
+ selectedKeys?: string[]
23
+ defaultSelectedKeys?: string[]
24
+ onSelectionChange?: (keys: string[]) => void
25
+ actions?: (row: T) => ReactNode
26
+ actionsHeader?: ReactNode
27
+ onRowClick?: (row: T) => void
28
+ emptyText?: string
29
+ className?: string
30
+ }
31
+
32
+ function alignClass(align: TableColumn<unknown>['align']): string {
33
+ if (align === 'center') return 'text-center'
34
+ if (align === 'right') return 'text-right'
35
+ return 'text-left'
36
+ }
37
+
38
+ /**
39
+ * Data table with a shadcn-like API: typed columns, an optional select-all
40
+ * checkbox, and a dedicated actions column. Every cell on the left shares the
41
+ * same `px-4` padding; the actions column keeps a wider `pl-10` gap with the
42
+ * last column.
43
+ */
44
+ export function Table<T>({
45
+ columns,
46
+ rows,
47
+ rowKey,
48
+ selectable = false,
49
+ selectedKeys,
50
+ defaultSelectedKeys = [],
51
+ onSelectionChange,
52
+ actions,
53
+ actionsHeader = 'Actions',
54
+ onRowClick,
55
+ emptyText = 'No data.',
56
+ className,
57
+ }: Readonly<TableProps<T>>) {
58
+ const [selection, setSelection] = useControllableValue<string[]>(
59
+ selectedKeys,
60
+ defaultSelectedKeys,
61
+ onSelectionChange,
62
+ )
63
+ const selectAllRef = useRef<HTMLInputElement>(null)
64
+
65
+ const keys = rows.map((row) => String(rowKey(row)))
66
+ const allSelected = keys.length > 0 && keys.every((key) => selection.includes(key))
67
+ const someSelected = keys.some((key) => selection.includes(key))
68
+
69
+ useEffect(() => {
70
+ if (selectAllRef.current) {
71
+ selectAllRef.current.indeterminate = someSelected && !allSelected
72
+ }
73
+ }, [someSelected, allSelected])
74
+
75
+ const toggle = (key: string, checked: boolean) => {
76
+ setSelection((current) =>
77
+ checked
78
+ ? current.includes(key)
79
+ ? current
80
+ : [...current, key]
81
+ : current.filter((entry) => entry !== key),
82
+ )
83
+ }
84
+
85
+ const toggleAll = (checked: boolean) => {
86
+ setSelection(checked ? keys : [])
87
+ }
88
+
89
+ const columnsCount = columns.length + (selectable ? 1 : 0) + (actions ? 1 : 0)
90
+
91
+ return (
92
+ <div className={cn('w-full overflow-x-auto rounded-sm border bg-card', className)}>
93
+ <table className="w-full min-w-[860px] border-collapse text-left text-xs">
94
+ <thead>
95
+ <tr className="border-b bg-muted/50">
96
+ {selectable && (
97
+ <th scope="col" className="w-10 px-4 py-3.5">
98
+ <input
99
+ ref={selectAllRef}
100
+ type="checkbox"
101
+ checked={allSelected}
102
+ onChange={(event) => toggleAll(event.target.checked)}
103
+ aria-label="Select all rows"
104
+ className="size-4 cursor-pointer rounded-sm border-border accent-primary"
105
+ />
106
+ </th>
107
+ )}
108
+ {columns.map((column) => (
109
+ <th
110
+ key={column.key}
111
+ scope="col"
112
+ className={cn(
113
+ 'whitespace-nowrap px-4 py-3.5 text-[11px] font-medium text-muted-foreground',
114
+ alignClass(column.align),
115
+ column.className,
116
+ )}
117
+ >
118
+ {column.header}
119
+ </th>
120
+ ))}
121
+ {actions && (
122
+ <th
123
+ scope="col"
124
+ className="whitespace-nowrap py-3.5 pl-10 pr-4 text-right text-[11px] font-medium text-muted-foreground"
125
+ >
126
+ {actionsHeader}
127
+ </th>
128
+ )}
129
+ </tr>
130
+ </thead>
131
+ <tbody>
132
+ {rows.length === 0 ? (
133
+ <tr>
134
+ <td colSpan={columnsCount} className="px-4 py-10 text-center text-muted-foreground">
135
+ {emptyText}
136
+ </td>
137
+ </tr>
138
+ ) : (
139
+ rows.map((row, rowIndex) => {
140
+ const key = String(rowKey(row))
141
+ const selected = selection.includes(key)
142
+
143
+ return (
144
+ <tr
145
+ key={key}
146
+ onClick={onRowClick ? () => onRowClick(row) : undefined}
147
+ className={cn(
148
+ 'border-b transition last:border-b-0',
149
+ onRowClick && 'cursor-pointer',
150
+ selected ? 'bg-primary/5' : 'hover:bg-muted/30',
151
+ )}
152
+ >
153
+ {selectable && (
154
+ <td className="w-10 px-4 py-3.5 align-middle">
155
+ <input
156
+ type="checkbox"
157
+ checked={selected}
158
+ onChange={(event) => toggle(key, event.target.checked)}
159
+ onClick={(event) => event.stopPropagation()}
160
+ aria-label={`Select row ${key}`}
161
+ className="size-4 cursor-pointer rounded-sm border-border accent-primary"
162
+ />
163
+ </td>
164
+ )}
165
+ {columns.map((column) => (
166
+ <td
167
+ key={column.key}
168
+ className={cn(
169
+ 'px-4 py-3.5 align-middle',
170
+ alignClass(column.align),
171
+ column.className,
172
+ )}
173
+ >
174
+ {column.cell(row, rowIndex)}
175
+ </td>
176
+ ))}
177
+ {actions && (
178
+ <td className="whitespace-nowrap py-3.5 pl-10 pr-4">
179
+ <div className="flex items-center justify-end gap-1.5">{actions(row)}</div>
180
+ </td>
181
+ )}
182
+ </tr>
183
+ )
184
+ })
185
+ )}
186
+ </tbody>
187
+ </table>
188
+ </div>
189
+ )
190
+ }