uifolio 0.5.4 → 0.6.0

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
@@ -54,15 +54,38 @@ Colors go through **Tailwind CSS 4 tokens**: the CLI writes
54
54
  into the project's global CSS (the one with `@import 'tailwindcss'`). The
55
55
  components therefore keep `bg-primary`, `border-primary/50`, `ring-primary/40`:
56
56
  no hard-coded colors. `--color` changes `--primary` in the theme and `--radius`
57
- adds `--radius` to it. When no global Tailwind CSS is found, the file is written
58
- at the root and the import to add is printed.
59
-
60
- At the end, the CLI prints the detected npm dependencies, for example:
57
+ adds `--radius` to it. The CSS that imports Tailwind is detected wherever it
58
+ lives (`src/index.css`, `src/styles/globals.css`, `app/globals.css`…).
59
+
60
+ Tailwind CSS 4 is required: without it, no utility class exists and the
61
+ components render unstyled. When the target project does not have it (or no CSS
62
+ imports it yet), `add` sets it up before wiring the theme:
63
+
64
+ 1. installs `tailwindcss` (+ `@tailwindcss/vite`) with the package manager of the
65
+ project's lockfile (`npm`, `pnpm`, `yarn` or `bun`);
66
+ 2. creates the CSS entry (`src/index.css`, or `app/globals.css`) with
67
+ `@import 'tailwindcss';`, or prepends the import to the existing one;
68
+ 3. imports that CSS in the app entry (`src/main.tsx`, `app/layout.tsx`…);
69
+ 4. adds the `tailwindcss()` plugin to `vite.config.*`.
70
+
71
+ The installation is skipped by `--dry-run` (the steps are printed instead).
72
+ If the install fails (offline, read-only…), the theme is still written and the
73
+ exact steps to run manually are printed.
74
+
75
+ `add` also installs the npm packages the copied files import (`clsx`,
76
+ `tailwind-merge`, `lucide-react`, `hls.js`…), with the same package manager and
77
+ as runtime dependencies (`dependencies`, not `devDependencies`). Only the ones
78
+ missing from the manifest are installed, `--dry-run` prints the command without
79
+ running it, and a project without `package.json` simply gets the command to run
80
+ itself:
61
81
 
62
82
  ```bash
63
83
  npm install clsx tailwind-merge lucide-react hls.js
64
84
  ```
65
85
 
86
+ Tailwind is handled the same way, as a dev dependency (see above), so it is not
87
+ part of that list.
88
+
66
89
  ### Preview parity
67
90
 
68
91
  The docs preview and the installed component render identically because the page
@@ -90,7 +113,7 @@ only marks the installation as complete when that is relevant.
90
113
  | ResizablePanels | `src/components/ui/resizable.tsx` | horizontal/vertical groups, handles, min-size, keyboard |
91
114
  | OtpInput | `src/components/ui/otp-input.tsx` | 4 fields, paste, rotate + success check |
92
115
  | 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` | typed data table, uniform `px-4` cells and a wider gap before Actions, selection and row actions |
116
+ | 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 |
94
117
 
95
118
  Copyable helpers (the CLI follows them automatically): `src/lib/utils.ts`,
96
119
  `src/lib/video.ts`, `src/lib/vtt.ts`, `src/lib/date.ts`, `src/lib/uploader.ts`, `src/lib/color.ts`,
package/cli/uifolio.mjs CHANGED
@@ -31,6 +31,7 @@
31
31
  * node cli/uifolio.mjs add video-player --alias "#lib" --dry-run
32
32
  */
33
33
 
34
+ import { execFileSync } from 'node:child_process'
34
35
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
35
36
  import { basename, dirname, join, relative, resolve } from 'node:path'
36
37
  import { createInterface } from 'node:readline/promises'
@@ -52,8 +53,32 @@ const GLOBAL_CSS_CANDIDATES = [
52
53
  'src/index.css',
53
54
  'src/app.css',
54
55
  'src/globals.css',
56
+ 'src/styles/globals.css',
57
+ 'src/styles/index.css',
58
+ 'src/main.css',
59
+ 'src/style.css',
60
+ 'app/globals.css',
55
61
  ]
56
62
 
63
+ /** Folders never scanned when looking for the project's stylesheets. */
64
+ const IGNORED_DIRS = new Set([
65
+ 'node_modules',
66
+ 'dist',
67
+ 'build',
68
+ 'out',
69
+ 'coverage',
70
+ 'public',
71
+ '.git',
72
+ '.next',
73
+ '.vite',
74
+ '.turbo',
75
+ ])
76
+
77
+ const MAX_SCAN_DEPTH = 6
78
+
79
+ /** A Tailwind CSS 4 entrypoint: `@import 'tailwindcss';` or `@tailwind base;`. */
80
+ const TAILWIND_ENTRY = /@import\s+['"]tailwindcss(?:[^'"]*)['"]|@tailwind\s+base/
81
+
57
82
  /* -------------------------------------------------------------------------- */
58
83
  /* Utilitaires */
59
84
  /* -------------------------------------------------------------------------- */
@@ -67,6 +92,10 @@ function ok(message) {
67
92
  console.log(`\x1b[32m✓\x1b[0m ${message}`)
68
93
  }
69
94
 
95
+ function warn(message) {
96
+ console.log(`\x1b[33m!\x1b[0m ${message}`)
97
+ }
98
+
70
99
  function info(message) {
71
100
  console.log(` ${message}`)
72
101
  }
@@ -389,19 +418,276 @@ ${darkTokens.join('\n')}
389
418
  `
390
419
  }
391
420
 
392
- /** CSS global du projet (celui qui importe Tailwind), sinon null. */
421
+ /** Every stylesheet of the project, outside the build/ignored folders. */
422
+ function listCssFiles(cwd) {
423
+ const found = []
424
+ const walk = (dir, depth) => {
425
+ if (depth > MAX_SCAN_DEPTH) return
426
+ let entries
427
+ try {
428
+ entries = readdirSync(dir, { withFileTypes: true })
429
+ } catch {
430
+ return
431
+ }
432
+ for (const entry of entries) {
433
+ const full = join(dir, entry.name)
434
+ if (entry.isDirectory()) {
435
+ if (entry.name.startsWith('.') || IGNORED_DIRS.has(entry.name)) continue
436
+ walk(full, depth + 1)
437
+ } else if (/\.css$/i.test(entry.name)) {
438
+ found.push(full)
439
+ }
440
+ }
441
+ }
442
+ walk(resolve(cwd), 0)
443
+ return found.sort()
444
+ }
445
+
446
+ /**
447
+ * CSS global du projet: celui qui importe Tailwind en priorité, quel que soit
448
+ * son chemin (src/index.css, src/styles/globals.css, app/globals.css…).
449
+ */
393
450
  function findGlobalCss(cwd) {
394
- for (const candidate of GLOBAL_CSS_CANDIDATES) {
395
- const file = join(cwd, candidate)
396
- if (!existsSync(file)) continue
397
- const source = readFileSync(file, 'utf8')
398
- if (/@import\s+['"]tailwindcss|@tailwind\b/.test(source)) return file
451
+ const known = GLOBAL_CSS_CANDIDATES.map((candidate) => join(resolve(cwd), candidate))
452
+ const candidates = [...new Set([...known, ...listCssFiles(cwd)])].filter((file) =>
453
+ existsSync(file),
454
+ )
455
+ const withTailwind = candidates.find((file) => TAILWIND_ENTRY.test(readFileSync(file, 'utf8')))
456
+ return withTailwind ?? candidates[0] ?? null
457
+ }
458
+
459
+ /** A dependency of the target project's manifest? */
460
+ function hasDependency(cwd, name) {
461
+ const file = join(cwd, 'package.json')
462
+ if (!existsSync(file)) return false
463
+ try {
464
+ const manifest = JSON.parse(readFileSync(file, 'utf8'))
465
+ return ['dependencies', 'devDependencies', 'peerDependencies'].some((key) =>
466
+ Boolean(manifest[key]?.[name]),
467
+ )
468
+ } catch {
469
+ return false
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Tailwind must be installed *and* imported by a CSS file: writing the tokens
475
+ * is not enough, without Tailwind the classes (bg-primary, fixed…) do not exist.
476
+ */
477
+ function tailwindStatus(cwd, globalCss) {
478
+ const hasTailwind = hasDependency(cwd, 'tailwindcss')
479
+ const hasEntry =
480
+ Boolean(globalCss) &&
481
+ existsSync(globalCss) &&
482
+ TAILWIND_ENTRY.test(readFileSync(globalCss, 'utf8'))
483
+ return { hasDependency: hasTailwind, hasEntry }
484
+ }
485
+
486
+ /** Package manager of the target project, from its lockfile. */
487
+ function packageManager(cwd, dev = true) {
488
+ const flag = dev ? ['-D'] : []
489
+ if (existsSync(join(cwd, 'pnpm-lock.yaml'))) return { command: 'pnpm', args: ['add', ...flag] }
490
+ if (existsSync(join(cwd, 'yarn.lock'))) return { command: 'yarn', args: ['add', ...flag] }
491
+ if (existsSync(join(cwd, 'bun.lockb')) || existsSync(join(cwd, 'bun.lock'))) {
492
+ return { command: 'bun', args: ['add', ...(dev ? ['-d'] : [])] }
399
493
  }
400
- for (const candidate of GLOBAL_CSS_CANDIDATES) {
401
- const file = join(cwd, candidate)
402
- if (existsSync(file)) return file
494
+ return { command: 'npm', args: ['install', ...flag] }
495
+ }
496
+
497
+ /**
498
+ * The components import npm packages (clsx, lucide-react, hls.js…): install the
499
+ * ones missing from the manifest so the copied files compile right away.
500
+ */
501
+ function installDependencies({ cwd, deps, dryRun }) {
502
+ const missing = [...deps].filter((dep) => !hasDependency(cwd, dep)).sort()
503
+ const manager = packageManager(cwd, false)
504
+ const command = [manager.command, ...manager.args, ...missing].join(' ')
505
+ if (missing.length === 0) return { missing, command }
506
+
507
+ if (dryRun) {
508
+ info(`[dry-run] ${command}`)
509
+ return { missing, command }
403
510
  }
404
- return null
511
+ // Not a JS project: nothing to install into, print the command instead.
512
+ if (!existsSync(join(cwd, 'package.json'))) return { missing, command }
513
+
514
+ info(`Installing the npm dependencies: ${command}`)
515
+ try {
516
+ execFileSync(manager.command, [...manager.args, ...missing], {
517
+ cwd,
518
+ stdio: 'inherit',
519
+ shell: process.platform === 'win32',
520
+ })
521
+ } catch {
522
+ warn('The automatic install failed: run the command above yourself.')
523
+ return { missing, command }
524
+ }
525
+ ok(`Installed: ${missing.join(', ')}`)
526
+ return { missing: [], command }
527
+ }
528
+
529
+ const VITE_CONFIGS = [
530
+ 'vite.config.ts',
531
+ 'vite.config.js',
532
+ 'vite.config.mjs',
533
+ 'vite.config.mts',
534
+ 'vite.config.cjs',
535
+ 'vite.config.cts',
536
+ ]
537
+
538
+ /** Modules that load the global CSS in a Vite/React project. */
539
+ const ENTRY_MODULES = [
540
+ 'src/main.tsx',
541
+ 'src/main.jsx',
542
+ 'src/main.ts',
543
+ 'src/main.js',
544
+ 'src/index.tsx',
545
+ 'src/index.jsx',
546
+ 'app/layout.tsx',
547
+ 'app/layout.jsx',
548
+ ]
549
+
550
+ function findFirst(cwd, names) {
551
+ return names.map((name) => join(cwd, name)).find((file) => existsSync(file)) ?? null
552
+ }
553
+
554
+ /** Adds `import tailwindcss from '@tailwindcss/vite'` and `tailwindcss()`. */
555
+ function wireViteConfig(source) {
556
+ if (source.includes('@tailwindcss/vite')) return source
557
+ const plugins = /plugins\s*:\s*\[/.exec(source)
558
+ if (!plugins) return null
559
+ const at = plugins.index + plugins[0].length
560
+ const next = `${source.slice(0, at)}tailwindcss(), ${source.slice(at)}`
561
+ const lines = next.split('\n')
562
+ const lastImport = lines.reduce((last, line, index) => (/^\s*import\s/.test(line) ? index : last), -1)
563
+ const importLine = "import tailwindcss from '@tailwindcss/vite'"
564
+ if (lastImport === -1) return `${importLine}\n${next}`
565
+ lines.splice(lastImport + 1, 0, importLine)
566
+ return lines.join('\n')
567
+ }
568
+
569
+ /** Creates the Tailwind entrypoint when the project has no stylesheet at all. */
570
+ function defaultCssPath(cwd) {
571
+ if (existsSync(join(cwd, 'src'))) return join(cwd, 'src', 'index.css')
572
+ if (existsSync(join(cwd, 'app'))) return join(cwd, 'app', 'globals.css')
573
+ return join(cwd, 'index.css')
574
+ }
575
+
576
+ /**
577
+ * Installs Tailwind CSS 4 and wires it (CSS entry, entry module, Vite plugin)
578
+ * when the target project does not have it. Without Tailwind no utility class
579
+ * exists, so the components would render unstyled.
580
+ */
581
+ function setupTailwind({ cwd, globalCss, dryRun }) {
582
+ // Not a JS project (or nothing to install into): keep the manual instructions.
583
+ if (!existsSync(join(cwd, 'package.json'))) {
584
+ return { globalCss, reason: 'no-package' }
585
+ }
586
+
587
+ const usesVite = Boolean(findFirst(cwd, VITE_CONFIGS)) || hasDependency(cwd, 'vite')
588
+ const manager = packageManager(cwd)
589
+ const packages = usesVite ? ['tailwindcss', '@tailwindcss/vite'] : ['tailwindcss']
590
+ const command = [manager.command, ...manager.args, ...packages].join(' ')
591
+
592
+ if (!hasDependency(cwd, 'tailwindcss')) {
593
+ if (dryRun) info(`[dry-run] ${command}`)
594
+ else info(`Tailwind CSS missing, installing it: ${command}`)
595
+ }
596
+ if (!dryRun && !hasDependency(cwd, 'tailwindcss')) {
597
+ try {
598
+ execFileSync(manager.command, [...manager.args, ...packages], {
599
+ cwd,
600
+ stdio: 'inherit',
601
+ shell: process.platform === 'win32',
602
+ })
603
+ ok('Tailwind CSS installed')
604
+ } catch {
605
+ warn('The automatic install failed.')
606
+ return { globalCss, reason: 'install-failed' }
607
+ }
608
+ }
609
+
610
+ // 1. A stylesheet that imports Tailwind.
611
+ let cssFile = globalCss
612
+ if (!cssFile) {
613
+ cssFile = defaultCssPath(cwd)
614
+ if (dryRun) {
615
+ info(`[dry-run] create ${relative(cwd, cssFile)} with "@import 'tailwindcss';"`)
616
+ } else {
617
+ mkdirSync(dirname(cssFile), { recursive: true })
618
+ writeFileSync(cssFile, "@import 'tailwindcss';\n", 'utf8')
619
+ ok(`Tailwind entry created: ${relative(cwd, cssFile)}`)
620
+ }
621
+ } else if (!TAILWIND_ENTRY.test(readFileSync(cssFile, 'utf8'))) {
622
+ if (dryRun) {
623
+ info(`[dry-run] add "@import 'tailwindcss';" to ${relative(cwd, cssFile)}`)
624
+ } else {
625
+ const source = readFileSync(cssFile, 'utf8')
626
+ writeFileSync(cssFile, `@import 'tailwindcss';\n\n${source}`, 'utf8')
627
+ ok(`Tailwind imported in ${relative(cwd, cssFile)}`)
628
+ }
629
+ }
630
+
631
+ // 2. That stylesheet must be loaded by the app entry.
632
+ const entryModule = findFirst(cwd, ENTRY_MODULES)
633
+ const loadsCss = Boolean(entryModule) && readFileSync(entryModule, 'utf8').includes(basename(cssFile))
634
+ if (entryModule && !loadsCss) {
635
+ if (dryRun) {
636
+ info(`[dry-run] import ${basename(cssFile)} in ${relative(cwd, entryModule)}`)
637
+ } else {
638
+ const specifier = relative(dirname(entryModule), cssFile).replace(/\\/g, '/')
639
+ const source = readFileSync(entryModule, 'utf8')
640
+ writeFileSync(
641
+ entryModule,
642
+ `import '${specifier.startsWith('.') ? '' : './'}${specifier}'\n${source}`,
643
+ 'utf8',
644
+ )
645
+ ok(`CSS imported in ${relative(cwd, entryModule)}`)
646
+ }
647
+ }
648
+
649
+ // 3. Vite only runs Tailwind through its plugin.
650
+ if (usesVite) {
651
+ const config = findFirst(cwd, VITE_CONFIGS)
652
+ if (!config) {
653
+ warn('No vite.config.* found: add tailwindcss() to your Vite plugins.')
654
+ } else {
655
+ const source = readFileSync(config, 'utf8')
656
+ const updated = wireViteConfig(source)
657
+ if (updated === null) {
658
+ warn(`Add the tailwindcss() plugin in ${relative(cwd, config)} (no "plugins: [" found).`)
659
+ } else if (updated !== source && dryRun) {
660
+ info(`[dry-run] add tailwindcss() to ${relative(cwd, config)}`)
661
+ } else if (updated !== source) {
662
+ writeFileSync(config, updated, 'utf8')
663
+ ok(`tailwindcss() plugin added to ${relative(cwd, config)}`)
664
+ }
665
+ }
666
+ } else {
667
+ info('Tailwind 4 also needs @tailwindcss/postcss in your PostCSS config.')
668
+ }
669
+
670
+ return { globalCss: cssFile, reason: dryRun ? 'dry-run' : 'ready' }
671
+ }
672
+
673
+ /** Actionable steps instead of a silent unstyled page. */
674
+ function reportMissingTailwind({ cwd, globalCss, themeFile, status }) {
675
+ const cssLabel = globalCss ? relative(cwd, globalCss) : 'your global CSS'
676
+ const specifier = globalCss
677
+ ? `./${relative(dirname(globalCss), themeFile).replace(/\\/g, '/')}`
678
+ : `./${THEME_FILE}`
679
+ console.log('')
680
+ if (!status.hasDependency) {
681
+ const manager = packageManager(cwd)
682
+ warn('Tailwind CSS is not installed in this project: the components will render unstyled.')
683
+ info([manager.command, ...manager.args, 'tailwindcss', '@tailwindcss/vite'].join(' '))
684
+ info(`Then add "@import 'tailwindcss';" at the top of ${cssLabel}`)
685
+ info('and tailwindcss() to the Vite plugins (plugins: [react(), tailwindcss()]).')
686
+ } else {
687
+ warn(`Tailwind CSS is installed but ${cssLabel} does not import it yet.`)
688
+ info(`Add "@import 'tailwindcss';" at the top of ${cssLabel}.`)
689
+ }
690
+ info(`Then "@import '${specifier}';", or re-run "uifolio add" to wire it automatically.`)
405
691
  }
406
692
 
407
693
  /** Adds `@import './uifolio-theme.css';` right after the Tailwind import. */
@@ -430,7 +716,16 @@ function existingRadius(file) {
430
716
  }
431
717
 
432
718
  function writeTheme({ cwd, accent, radius, dryRun }) {
433
- const globalCss = findGlobalCss(cwd)
719
+ let globalCss = findGlobalCss(cwd)
720
+
721
+ // Install + wire Tailwind first: the tokens alone style nothing.
722
+ if (!tailwindStatus(cwd, globalCss).hasEntry) {
723
+ console.log('')
724
+ info('Tailwind CSS is not set up in this project: configuring it.')
725
+ globalCss = setupTailwind({ cwd, globalCss, dryRun }).globalCss
726
+ }
727
+
728
+ const status = tailwindStatus(cwd, globalCss)
434
729
  const themeFile = globalCss
435
730
  ? join(dirname(globalCss), THEME_FILE)
436
731
  : join(cwd, THEME_FILE)
@@ -450,15 +745,19 @@ function writeTheme({ cwd, accent, radius, dryRun }) {
450
745
  ok(`Tailwind theme written to ${label}`)
451
746
  info(`--primary: ${finalAccent}${finalRadius === undefined ? '' : ` · --radius: ${finalRadius}px`}`)
452
747
 
453
- if (globalCss) {
454
- const source = readFileSync(globalCss, 'utf8')
455
- const specifier = `./${basename(themeFile)}`
456
- const updated = wireThemeImport(source, specifier)
457
- if (updated !== source) writeFileSync(globalCss, updated, 'utf8')
458
- info(`imported in ${relative(cwd, globalCss)} — no hard-coded colors in the components`)
459
- } else {
460
- info(`Add "@import './${THEME_FILE}';", at the top of your global CSS (the Tailwind one).`)
748
+ // The tokens only matter once Tailwind actually generates the utilities:
749
+ // without a `@import 'tailwindcss'`, wiring the theme would look installed
750
+ // and still render an unstyled page.
751
+ if (!status.hasEntry) {
752
+ reportMissingTailwind({ cwd, globalCss, themeFile, status })
753
+ return
461
754
  }
755
+
756
+ const source = readFileSync(globalCss, 'utf8')
757
+ const specifier = `./${basename(themeFile)}`
758
+ const updated = wireThemeImport(source, specifier)
759
+ if (updated !== source) writeFileSync(globalCss, updated, 'utf8')
760
+ info(`imported in ${relative(cwd, globalCss)} — no hard-coded colors in the components`)
462
761
  }
463
762
 
464
763
  /* -------------------------------------------------------------------------- */
@@ -982,7 +1281,10 @@ async function commandAdd({ positional, flags }) {
982
1281
  info(`${skipped.length} file(s) already present, skipped: ${skipped.join(', ')} (use --force to overwrite)`)
983
1282
  }
984
1283
 
985
- // 3. Tokens Tailwind : les classes bg-primary & co fonctionnent partout.
1284
+ // 3. npm dependencies imported by the copied files.
1285
+ const dependencies = installDependencies({ cwd, deps, dryRun })
1286
+
1287
+ // 4. Tokens Tailwind : les classes bg-primary & co fonctionnent partout.
986
1288
  writeTheme({ cwd, accent, radius, dryRun })
987
1289
 
988
1290
  // 4. Component usage, after installation.
@@ -1006,12 +1308,12 @@ async function commandAdd({ positional, flags }) {
1006
1308
  })
1007
1309
  }
1008
1310
 
1009
- if (written.size > 0 || dryRun) {
1311
+ if (targetAlias !== '@') {
1312
+ info(`Remember to align the ${targetAlias} alias in your tsconfig/vite (instead of @/).`)
1313
+ }
1314
+ if (dependencies.missing.length > 0 && !dryRun && written.size > 0) {
1010
1315
  console.log('\n Detected npm dependencies:')
1011
- info(`npm install ${[...deps].sort().join(' ')}`)
1012
- if (targetAlias !== '@') {
1013
- info(`Remember to align the ${targetAlias} alias in your tsconfig/vite (instead of @/).`)
1014
- }
1316
+ info(dependencies.command)
1015
1317
  console.log('')
1016
1318
  }
1017
1319
  } finally {
@@ -1050,7 +1352,12 @@ only. Files go to src/components/ui (override with --dir) and the page imports
1050
1352
  them through the alias (@/components/ui/...). The page also gets a working
1051
1353
  example, with the data and props each component needs, so it shows immediately.
1052
1354
  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.
1355
+ like bg-primary and text-muted-foreground stay in the code. When Tailwind is
1356
+ missing, "add" installs it (with your lockfile's package manager), creates the
1357
+ CSS entry, imports it in the app entry and adds the tailwindcss() Vite plugin;
1358
+ without Tailwind no utility class exists and the components render unstyled.
1359
+ The npm packages the components import (clsx, lucide-react, hls.js…) are
1360
+ installed too, as runtime dependencies, when the manifest lacks them.
1054
1361
 
1055
1362
  Examples:
1056
1363
  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.4",
3
+ "version": "0.6.0",
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",
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import type { ReactNode } from 'react'
3
+ import { useEffect, useRef, type ReactNode } from 'react'
4
4
  import { cn } from '@/lib/utils'
5
5
  import { useControllableValue } from '@/lib/use-controllable'
6
6
 
@@ -8,7 +8,7 @@ export interface TableColumn<T> {
8
8
  /** Stable identifier, also used as the React key of the column. */
9
9
  key: string
10
10
  header: ReactNode
11
- cell: (row: T) => ReactNode
11
+ cell: (row: T, index: number) => ReactNode
12
12
  /** Applied to the header and body cells: width, nowrap… */
13
13
  className?: string
14
14
  align?: 'left' | 'center' | 'right'
@@ -36,9 +36,10 @@ function alignClass(align: TableColumn<unknown>['align']): string {
36
36
  }
37
37
 
38
38
  /**
39
- * Data table with a shadcn-like API: typed columns, optional selection and a
40
- * dedicated actions column. Every cell on the left shares the same `px-4`
41
- * padding; the actions column keeps a wider `pl-10` gap with the last column.
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.
42
43
  */
43
44
  export function Table<T>({
44
45
  columns,
@@ -59,6 +60,17 @@ export function Table<T>({
59
60
  defaultSelectedKeys,
60
61
  onSelectionChange,
61
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])
62
74
 
63
75
  const toggle = (key: string, checked: boolean) => {
64
76
  setSelection((current) =>
@@ -70,13 +82,29 @@ export function Table<T>({
70
82
  )
71
83
  }
72
84
 
73
- const columnsCount = columns.length + (actions ? 1 : 0)
85
+ const toggleAll = (checked: boolean) => {
86
+ setSelection(checked ? keys : [])
87
+ }
88
+
89
+ const columnsCount = columns.length + (selectable ? 1 : 0) + (actions ? 1 : 0)
74
90
 
75
91
  return (
76
92
  <div className={cn('w-full overflow-x-auto rounded-sm border bg-card', className)}>
77
93
  <table className="w-full min-w-[860px] border-collapse text-left text-xs">
78
94
  <thead>
79
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
+ )}
80
108
  {columns.map((column) => (
81
109
  <th
82
110
  key={column.key}
@@ -108,7 +136,7 @@ export function Table<T>({
108
136
  </td>
109
137
  </tr>
110
138
  ) : (
111
- rows.map((row) => {
139
+ rows.map((row, rowIndex) => {
112
140
  const key = String(rowKey(row))
113
141
  const selected = selection.includes(key)
114
142
 
@@ -122,7 +150,19 @@ export function Table<T>({
122
150
  selected ? 'bg-primary/5' : 'hover:bg-muted/30',
123
151
  )}
124
152
  >
125
- {columns.map((column, index) => (
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) => (
126
166
  <td
127
167
  key={column.key}
128
168
  className={cn(
@@ -131,21 +171,7 @@ export function Table<T>({
131
171
  column.className,
132
172
  )}
133
173
  >
134
- {selectable && index === 0 ? (
135
- <div className="flex items-center gap-3">
136
- <input
137
- type="checkbox"
138
- checked={selected}
139
- onChange={(event) => toggle(key, event.target.checked)}
140
- onClick={(event) => event.stopPropagation()}
141
- aria-label={`Select row ${key}`}
142
- className="size-4 shrink-0 cursor-pointer rounded-sm border-border accent-primary"
143
- />
144
- {column.cell(row)}
145
- </div>
146
- ) : (
147
- column.cell(row)
148
- )}
174
+ {column.cell(row, rowIndex)}
149
175
  </td>
150
176
  ))}
151
177
  {actions && (