@carbonid1/design-system 4.0.1 → 4.2.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 +5 -5
- package/package.json +10 -4
- package/scripts/install-skill.mjs +23 -0
- package/skills/design-system/SKILL.md +65 -0
- package/skills/design-system/references/theming.md +91 -0
- package/src/ContextMenu/ContextMenu.tsx +159 -0
- package/src/ProgressRing/ProgressRing.test.tsx +51 -0
- package/src/Select/Select.test.tsx +129 -0
- package/src/Slider/Slider.test.tsx +29 -0
- package/src/Switch/Switch.test.tsx +31 -0
- package/src/helpers/getModKey/getModKey.test.ts +23 -0
- package/src/index.ts +2 -0
- package/themes/dashboard.css +12 -0
- package/themes/reader.css +12 -0
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# @carbonid1/design-system
|
|
2
2
|
|
|
3
|
-
Shared React UI primitives
|
|
3
|
+
Shared React UI primitives + Tailwind v4 theme presets (`reader`, `dashboard`) + postcss config.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
|
-
pnpm add @carbonid1/design-system
|
|
8
|
+
pnpm add @carbonid1/design-system react react-dom @base-ui/react class-variance-authority clsx tailwind-merge lucide-react sonner next-themes react-hotkeys-hook
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
And Tailwind CSS v4 (with `tw-animate-css` + `shadcn/tailwind.css` if you want matching utilities/base):
|
|
@@ -24,7 +24,7 @@ In your entry CSS (Next.js: `src/app/globals.css`):
|
|
|
24
24
|
@import 'tailwindcss';
|
|
25
25
|
@import 'tw-animate-css';
|
|
26
26
|
@import 'shadcn/tailwind.css';
|
|
27
|
-
@import '@carbonid1/
|
|
27
|
+
@import '@carbonid1/design-system/themes/reader';
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
### 2. Transpile the package in Next.js
|
|
@@ -84,8 +84,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|
|
84
84
|
|
|
85
85
|
## Theming
|
|
86
86
|
|
|
87
|
-
Components use semantic color utilities (`bg-primary`, `text-muted-foreground`, etc.) that resolve via
|
|
87
|
+
Components use semantic color utilities (`bg-primary`, `text-muted-foreground`, etc.) that resolve via the imported theme preset. Switching theme → swap the `@carbonid1/design-system/themes/*` import. Dark mode is toggled by adding `class="dark"` to a root ancestor (`ThemeProvider` handles this).
|
|
88
88
|
|
|
89
89
|
## Build
|
|
90
90
|
|
|
91
|
-
No build step — package ships raw `.tsx
|
|
91
|
+
No build step — package ships raw `.tsx`. Consumers transpile at build time. Stories (`*.stories.tsx`) and tests (`*.test.{ts,tsx}`) are excluded from the published tarball.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carbonid1/design-system",
|
|
3
|
-
"version": "4.0
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "Shared React UI primitives + design tokens (themes, postcss config)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"author": "Andrew Korin",
|
|
12
12
|
"type": "module",
|
|
13
|
+
"sideEffects": [
|
|
14
|
+
"**/*.css"
|
|
15
|
+
],
|
|
13
16
|
"exports": {
|
|
14
17
|
".": "./src/index.ts",
|
|
15
18
|
"./themes/reader": "./themes/reader.css",
|
|
@@ -19,7 +22,9 @@
|
|
|
19
22
|
"files": [
|
|
20
23
|
"src",
|
|
21
24
|
"themes",
|
|
22
|
-
"postcss.mjs"
|
|
25
|
+
"postcss.mjs",
|
|
26
|
+
"skills",
|
|
27
|
+
"scripts"
|
|
23
28
|
],
|
|
24
29
|
"dependencies": {
|
|
25
30
|
"@tailwindcss/postcss": "^4.2.0",
|
|
@@ -67,12 +72,13 @@
|
|
|
67
72
|
"tailwindcss": "^4.2.2",
|
|
68
73
|
"vite": "^8.0.8",
|
|
69
74
|
"vitest": "^4.1.4",
|
|
70
|
-
"@carbonid1/tsconfig": "0.2.
|
|
75
|
+
"@carbonid1/tsconfig": "0.2.1"
|
|
71
76
|
},
|
|
72
77
|
"scripts": {
|
|
73
78
|
"storybook": "storybook dev -p 6006",
|
|
74
79
|
"build-storybook": "storybook build",
|
|
75
80
|
"test": "vitest --run",
|
|
76
|
-
"test:watch": "vitest"
|
|
81
|
+
"test:watch": "vitest",
|
|
82
|
+
"postinstall": "node scripts/install-skill.mjs"
|
|
77
83
|
}
|
|
78
84
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, rm, symlink } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join, resolve } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
7
|
+
const SKILL_SRC = join(PKG_DIR, 'skills', 'design-system')
|
|
8
|
+
|
|
9
|
+
if (!PKG_DIR.includes(`${process.platform === 'win32' ? '\\' : '/'}node_modules${process.platform === 'win32' ? '\\' : '/'}`)) {
|
|
10
|
+
process.exit(0)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const consumerRoot = process.env.INIT_CWD || process.cwd()
|
|
14
|
+
const skillDest = join(consumerRoot, '.claude', 'skills', 'design-system')
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
await mkdir(dirname(skillDest), { recursive: true })
|
|
18
|
+
await rm(skillDest, { recursive: true, force: true })
|
|
19
|
+
await symlink(SKILL_SRC, skillDest, 'dir')
|
|
20
|
+
console.log(`[@carbonid1/design-system] linked skill → ${skillDest}`)
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.warn(`[@carbonid1/design-system] could not link skill: ${err.message}`)
|
|
23
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: design-system
|
|
3
|
+
description: 'Shared UI components and design patterns for carbonid1 apps built on @carbonid1/design-system. ALWAYS use when building UI, creating components, adding dropdowns, selects, tooltips, icons, toasts, context menus, or any interactive element. Use when choosing between native HTML elements and custom components. Also use when working with skeletons, loading states, layout shifts, or async-loaded UI elements. Triggers on: dropdown, select, tooltip, tag, badge, icon, component, UI, design system, shared component, skeleton, loading state, layout shift, context menu, right-click menu, button, theme, palette.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Design System
|
|
7
|
+
|
|
8
|
+
Primitives live in `@carbonid1/design-system`. **Check the package's exports and types before building anything new.** This file is direction and taste, not API reference — the types are the docs.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import {
|
|
12
|
+
Button, Kbd, ProgressRing, Slider, Switch,
|
|
13
|
+
ContextMenu, Select, Tooltip,
|
|
14
|
+
Toaster, toast,
|
|
15
|
+
ThemeProvider, ThemeCycler, useTheme,
|
|
16
|
+
cn, getModKey,
|
|
17
|
+
} from '@carbonid1/design-system'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Palettes are chosen by importing one theme CSS per app: `@carbonid1/design-system/themes/reader` (InkVoice) or `/themes/dashboard` (CoI Calculator).
|
|
21
|
+
|
|
22
|
+
If you're editing `@carbonid1/design-system` itself (adding a primitive, changing a token), read that package's `CLAUDE.md` — this file is for *using* the design system, not building it.
|
|
23
|
+
|
|
24
|
+
## Core rules
|
|
25
|
+
|
|
26
|
+
- Never use native `<select>` — use `Select`
|
|
27
|
+
- Never build a tooltip, context menu, or toast from scratch — use `Tooltip`, `ContextMenu`, `toast()`
|
|
28
|
+
- All icons from `lucide-react` — search `lucide.dev/icons` before creating anything custom. If nothing fits, build via Lucide's `Icon` + `iconNode`, don't reach for another library.
|
|
29
|
+
- All color through semantic tokens — no hardcoded Tailwind color classes. See [references/theming.md](references/theming.md).
|
|
30
|
+
- Tooltip: rely on defaults (`position="top"`, `delay={200}`). Only override when the default is wrong (e.g. header → `position="bottom"`, data-viz progressive disclosure → `delay={600}`). When a tooltip trigger also opens a popover, pass `disabled={open}`.
|
|
31
|
+
- Icon toggle state: `<Icon fill={active ? 'currentColor' : 'none'} />` — not two different components.
|
|
32
|
+
|
|
33
|
+
## Visual Language
|
|
34
|
+
|
|
35
|
+
- **Discrete elements** — rows/cards use `rounded-lg` with `space-y-1` gaps, never flat lists with hairline dividers
|
|
36
|
+
- **Selection** — `bg-primary/10` tint + `ring-1 ring-primary/20`, never border-left accents
|
|
37
|
+
- **Hover reveal** — action buttons use `opacity-0 group-hover:opacity-100 group-focus-within:opacity-100` with outer row `hover:bg-accent`. The `group-focus-within` is required — without it, keyboard users tab to an invisible button.
|
|
38
|
+
- **Metadata** — badge/chip components, never comma-separated text
|
|
39
|
+
- **Touch targets** — icon buttons `p-1.5` minimum, icons `w-4 h-4` minimum
|
|
40
|
+
- **Avatars** — only with real images, no letter/initial placeholders
|
|
41
|
+
|
|
42
|
+
## Destructive Actions
|
|
43
|
+
|
|
44
|
+
Two tiers. Pick by how hard the action is to redo, not how scary it feels.
|
|
45
|
+
|
|
46
|
+
| Severity | Pattern | Example |
|
|
47
|
+
| ---------------- | ----------------------------------- | ------------------------------------ |
|
|
48
|
+
| **Hard to redo** | Undo toast (5s + Cmd/Ctrl+Z) | Delete a record, remove history item |
|
|
49
|
+
| **Easy to redo** | No confirmation | Toggle flag, remove a tag |
|
|
50
|
+
|
|
51
|
+
**Undo toast:** action executes immediately → toast with description `${getModKey()}+Z to undo` + "Undo" action button (5s auto-dismiss) → store holds `lastDeleted` for restoration → global `mod+z` hotkey via `react-hotkeys-hook` → sequential deletes overwrite the buffer (only latest is undoable). Never add a modal confirmation for hard-to-redo actions — the undo toast is the confirmation.
|
|
52
|
+
|
|
53
|
+
## Layout Stability
|
|
54
|
+
|
|
55
|
+
Skeleton → real UI transitions must not shift layout. The skeleton is a dimensional contract.
|
|
56
|
+
|
|
57
|
+
- Skeletons match real UI structure, counts, heights, spacing. Update the skeleton when the real UI changes.
|
|
58
|
+
- Async elements that always render (selectors, user controls) show a same-sized placeholder while loading — never `null`.
|
|
59
|
+
- Doesn't apply to user-triggered UI (modals, drawers, toasts) or conditional banners — these are unpredictable by nature.
|
|
60
|
+
|
|
61
|
+
## Theming
|
|
62
|
+
|
|
63
|
+
`ThemeProvider` wraps the app (toggles `.dark` via `next-themes`). `ThemeCycler` mounts once to register `Shift+T` for cycling system → light → dark. `useTheme` is the next-themes re-export for programmatic access.
|
|
64
|
+
|
|
65
|
+
Both palettes define the same token names on `:root` + `.dark`, so primitive code and consumer code don't care which palette is active.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Color & Theming
|
|
2
|
+
|
|
3
|
+
## Philosophy
|
|
4
|
+
|
|
5
|
+
Tokens represent semantic roles, not fixed hues. Each theme picks the best hue for that role in its own context — light and dark themes may use entirely different hue families for the same token. The design system is app-agnostic: swap the token values, everything updates.
|
|
6
|
+
|
|
7
|
+
We use shadcn's token naming convention but own all color values. When pulling a new shadcn component, replace any generated color values with our hand-tuned tokens. This follows shadcn's own approach (~99% token-driven) as validation, not as a dependency.
|
|
8
|
+
|
|
9
|
+
## Palettes
|
|
10
|
+
|
|
11
|
+
`@carbonid1/design-system` ships multiple palettes — currently `reader` (InkVoice) and `dashboard` (CoI Calculator, admin-style UIs). Both define the same token names on `:root` + `.dark`; consumers choose one by importing the matching CSS:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import '@carbonid1/design-system/themes/reader'
|
|
15
|
+
// or
|
|
16
|
+
import '@carbonid1/design-system/themes/dashboard'
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Primitives must render correctly under every palette × light/dark combination — that's the contract. Because both palettes hit the same token names, primitive code stays palette-agnostic and consumer code doesn't change when switching.
|
|
20
|
+
|
|
21
|
+
## Rules
|
|
22
|
+
|
|
23
|
+
- **All color through tokens.** Every color that appears in themed UI must reference a CSS variable. No hardcoded Tailwind color classes (`blue-500`, `amber-400`, `gray-300`) for meaning-bearing colors.
|
|
24
|
+
- **Hardcoded only when physically fixed.** The only exception: values that must be constant regardless of theme (e.g. `bg-black/10` for modal backdrops). These are rare — 2-3 cases in the entire app.
|
|
25
|
+
- **Opacity modifiers work natively.** Tailwind v4 supports `bg-token/20` on any color format including oklch CSS variables. No `color-mix()` workarounds needed.
|
|
26
|
+
- **Use enough sub-variants for depth.** Don't squeeze a semantic color into just 2 tokens (base + foreground) when it serves distinct roles — text, background, and border often need different values to look intentional. Use 4-5 sub-variants (e.g. `foreground`, `muted`, `border`) when it gives the area more visual depth. Opacity modifiers on a single base color are a poor substitute for dedicated tokens tuned per role.
|
|
27
|
+
|
|
28
|
+
## Token Inventory
|
|
29
|
+
|
|
30
|
+
Tokens are defined in `globals.css` (`:root` + `.dark`), bridged in `@theme inline` block, consumed as Tailwind utilities.
|
|
31
|
+
|
|
32
|
+
### Base tokens (from shadcn naming)
|
|
33
|
+
|
|
34
|
+
| Token | Role | Sub-variants |
|
|
35
|
+
| --------------- | --------------------------------------------------------------- | ------------ |
|
|
36
|
+
| `--background` | Page background | — |
|
|
37
|
+
| `--foreground` | Default text | — |
|
|
38
|
+
| `--card` | Card surfaces | foreground |
|
|
39
|
+
| `--popover` | Popover surfaces | foreground |
|
|
40
|
+
| `--primary` | Interactive color (buttons, links, selections, focus, progress) | foreground |
|
|
41
|
+
| `--secondary` | Supporting surfaces | foreground |
|
|
42
|
+
| `--muted` | De-emphasized surfaces and text | foreground |
|
|
43
|
+
| `--accent` | Hover/expanded tints | foreground |
|
|
44
|
+
| `--destructive` | Errors, danger | — |
|
|
45
|
+
| `--border` | Default borders | — |
|
|
46
|
+
| `--input` | Input borders | — |
|
|
47
|
+
| `--ring` | Focus rings | — |
|
|
48
|
+
|
|
49
|
+
### Custom tokens
|
|
50
|
+
|
|
51
|
+
| Token | Role | Sub-variants |
|
|
52
|
+
| ------------- | --------------------------------------------- | ------------------------- |
|
|
53
|
+
| `--highlight` | Emphasis accent (e.g. live cursors, selection highlights) | foreground, muted |
|
|
54
|
+
| `--attention` | Warnings, notices, saved-for-later — "look at this" | foreground, muted, border |
|
|
55
|
+
| `--success` | Completion, positive outcomes | foreground |
|
|
56
|
+
|
|
57
|
+
## How to Add a New Semantic Token
|
|
58
|
+
|
|
59
|
+
1. **Define values** in `globals.css` — both `:root` (light) and `.dark`. Use oklch for perceptual uniformity. Light and dark values may use different hues if the role reads better that way.
|
|
60
|
+
|
|
61
|
+
```css
|
|
62
|
+
:root {
|
|
63
|
+
--my-token: oklch(0.65 0.15 45);
|
|
64
|
+
--my-token-foreground: oklch(0.25 0.05 40);
|
|
65
|
+
}
|
|
66
|
+
.dark {
|
|
67
|
+
--my-token: oklch(0.7 0.12 200);
|
|
68
|
+
--my-token-foreground: oklch(0.95 0.02 195);
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
2. **Bridge in `@theme inline`** in `globals.css` so Tailwind generates utilities:
|
|
73
|
+
|
|
74
|
+
```css
|
|
75
|
+
@theme inline {
|
|
76
|
+
--color-my-token: var(--my-token);
|
|
77
|
+
--color-my-token-foreground: var(--my-token-foreground);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
3. **Use in components** via Tailwind classes:
|
|
82
|
+
```tsx
|
|
83
|
+
<div className="bg-my-token text-my-token-foreground">
|
|
84
|
+
<span className="bg-my-token/10"> // opacity modifier for tints
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Notes
|
|
88
|
+
|
|
89
|
+
- **`::selection`** uses solid oklch values, not opacity modifiers — semi-transparent backgrounds create visible seams at line boundaries.
|
|
90
|
+
- **Tooltip** uses inverted tokens (`bg-foreground text-background`) since it's a dark-on-light / light-on-dark surface. Kbd badge: `bg-background/15`.
|
|
91
|
+
- **DebugPanel** is excluded from token migration — physically fixed terminal aesthetic.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu'
|
|
4
|
+
import { ChevronRight } from 'lucide-react'
|
|
5
|
+
import type { ComponentPropsWithoutRef, Ref } from 'react'
|
|
6
|
+
import { cn } from '../helpers/cn/cn'
|
|
7
|
+
|
|
8
|
+
type BasePopupProps = ComponentPropsWithoutRef<typeof BaseContextMenu.Popup>
|
|
9
|
+
type BasePositionerProps = ComponentPropsWithoutRef<typeof BaseContextMenu.Positioner>
|
|
10
|
+
type BaseItemProps = ComponentPropsWithoutRef<typeof BaseContextMenu.Item>
|
|
11
|
+
type BaseSeparatorProps = ComponentPropsWithoutRef<typeof BaseContextMenu.Separator>
|
|
12
|
+
type BaseGroupLabelProps = ComponentPropsWithoutRef<typeof BaseContextMenu.GroupLabel>
|
|
13
|
+
type BaseSubmenuTriggerProps = ComponentPropsWithoutRef<typeof BaseContextMenu.SubmenuTrigger>
|
|
14
|
+
type BaseCheckboxItemProps = ComponentPropsWithoutRef<typeof BaseContextMenu.CheckboxItem>
|
|
15
|
+
type BaseRadioItemProps = ComponentPropsWithoutRef<typeof BaseContextMenu.RadioItem>
|
|
16
|
+
|
|
17
|
+
type ItemVariant = 'default' | 'destructive'
|
|
18
|
+
|
|
19
|
+
type PositionerProps = BasePositionerProps & { ref?: Ref<HTMLDivElement> }
|
|
20
|
+
type PopupProps = BasePopupProps & { ref?: Ref<HTMLDivElement> }
|
|
21
|
+
type SeparatorProps = BaseSeparatorProps & { ref?: Ref<HTMLDivElement> }
|
|
22
|
+
type GroupLabelProps = BaseGroupLabelProps & { ref?: Ref<HTMLDivElement> }
|
|
23
|
+
type ItemProps = BaseItemProps & { ref?: Ref<HTMLDivElement>; variant?: ItemVariant }
|
|
24
|
+
type CheckboxItemProps = BaseCheckboxItemProps & { ref?: Ref<HTMLDivElement> }
|
|
25
|
+
type RadioItemProps = BaseRadioItemProps & { ref?: Ref<HTMLDivElement> }
|
|
26
|
+
type SubmenuTriggerProps = BaseSubmenuTriggerProps & { ref?: Ref<HTMLDivElement> }
|
|
27
|
+
|
|
28
|
+
const popupClasses =
|
|
29
|
+
'bg-popover text-popover-foreground border-border z-50 min-w-45 origin-[var(--transform-origin)] rounded-lg border py-1 shadow-lg outline-hidden transition-[transform,opacity] data-[ending-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:scale-95 data-[starting-style]:opacity-0'
|
|
30
|
+
|
|
31
|
+
const itemBaseClasses =
|
|
32
|
+
'relative flex h-7 cursor-default items-center gap-1.5 px-2.5 text-[0.8rem] outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4 data-[disabled]:pointer-events-none data-[disabled]:opacity-50'
|
|
33
|
+
|
|
34
|
+
const itemVariantClasses: Record<ItemVariant, string> = {
|
|
35
|
+
default:
|
|
36
|
+
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground',
|
|
37
|
+
destructive:
|
|
38
|
+
'text-destructive data-[highlighted]:bg-destructive/10 data-[highlighted]:text-destructive',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const Positioner = ({ className, sideOffset = 4, ...props }: PositionerProps) => (
|
|
42
|
+
<BaseContextMenu.Positioner
|
|
43
|
+
sideOffset={sideOffset}
|
|
44
|
+
className={cn('z-50 outline-hidden', className)}
|
|
45
|
+
{...props}
|
|
46
|
+
/>
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const Popup = ({ className, ...props }: PopupProps) => (
|
|
50
|
+
<BaseContextMenu.Popup className={cn(popupClasses, className)} {...props} />
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const Item = ({ className, variant = 'default', ...props }: ItemProps) => (
|
|
54
|
+
<BaseContextMenu.Item
|
|
55
|
+
className={cn(itemBaseClasses, itemVariantClasses[variant], className)}
|
|
56
|
+
{...props}
|
|
57
|
+
/>
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const Separator = ({ className, ...props }: SeparatorProps) => (
|
|
61
|
+
<BaseContextMenu.Separator
|
|
62
|
+
className={cn('bg-border my-1 h-px', className)}
|
|
63
|
+
{...props}
|
|
64
|
+
/>
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const GroupLabel = ({ className, ...props }: GroupLabelProps) => (
|
|
68
|
+
<BaseContextMenu.GroupLabel
|
|
69
|
+
className={cn('text-muted-foreground px-2.5 py-1 text-xs', className)}
|
|
70
|
+
{...props}
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
const CheckboxItem = ({ className, children, ...props }: CheckboxItemProps) => (
|
|
75
|
+
<BaseContextMenu.CheckboxItem
|
|
76
|
+
className={cn(itemBaseClasses, itemVariantClasses.default, 'pl-7', className)}
|
|
77
|
+
{...props}
|
|
78
|
+
>
|
|
79
|
+
<span
|
|
80
|
+
aria-hidden
|
|
81
|
+
className="absolute left-2 flex size-3.5 items-center justify-center"
|
|
82
|
+
>
|
|
83
|
+
<BaseContextMenu.CheckboxItemIndicator className="size-3.5">
|
|
84
|
+
<CheckIcon />
|
|
85
|
+
</BaseContextMenu.CheckboxItemIndicator>
|
|
86
|
+
</span>
|
|
87
|
+
{children}
|
|
88
|
+
</BaseContextMenu.CheckboxItem>
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const RadioItem = ({ className, children, ...props }: RadioItemProps) => (
|
|
92
|
+
<BaseContextMenu.RadioItem
|
|
93
|
+
className={cn(itemBaseClasses, itemVariantClasses.default, 'pl-7', className)}
|
|
94
|
+
{...props}
|
|
95
|
+
>
|
|
96
|
+
<span
|
|
97
|
+
aria-hidden
|
|
98
|
+
className="absolute left-2 flex size-3.5 items-center justify-center"
|
|
99
|
+
>
|
|
100
|
+
<BaseContextMenu.RadioItemIndicator className="size-3.5">
|
|
101
|
+
<DotIcon />
|
|
102
|
+
</BaseContextMenu.RadioItemIndicator>
|
|
103
|
+
</span>
|
|
104
|
+
{children}
|
|
105
|
+
</BaseContextMenu.RadioItem>
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const SubmenuTrigger = ({ className, children, ...props }: SubmenuTriggerProps) => (
|
|
109
|
+
<BaseContextMenu.SubmenuTrigger
|
|
110
|
+
className={cn(
|
|
111
|
+
itemBaseClasses,
|
|
112
|
+
itemVariantClasses.default,
|
|
113
|
+
'data-popup-open:bg-accent data-popup-open:text-accent-foreground pr-2',
|
|
114
|
+
className,
|
|
115
|
+
)}
|
|
116
|
+
{...props}
|
|
117
|
+
>
|
|
118
|
+
{children}
|
|
119
|
+
<ChevronRight className="ml-auto size-4 opacity-60" aria-hidden />
|
|
120
|
+
</BaseContextMenu.SubmenuTrigger>
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const CheckIcon = () => (
|
|
124
|
+
<svg viewBox="0 0 14 14" fill="none" className="size-3.5">
|
|
125
|
+
<path
|
|
126
|
+
d="M3 7.5 6 10.5 11 4.5"
|
|
127
|
+
stroke="currentColor"
|
|
128
|
+
strokeWidth="1.75"
|
|
129
|
+
strokeLinecap="round"
|
|
130
|
+
strokeLinejoin="round"
|
|
131
|
+
/>
|
|
132
|
+
</svg>
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const DotIcon = () => (
|
|
136
|
+
<svg viewBox="0 0 14 14" className="size-3.5">
|
|
137
|
+
<circle cx="7" cy="7" r="3" fill="currentColor" />
|
|
138
|
+
</svg>
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
export const ContextMenu = {
|
|
142
|
+
Root: BaseContextMenu.Root,
|
|
143
|
+
Trigger: BaseContextMenu.Trigger,
|
|
144
|
+
Portal: BaseContextMenu.Portal,
|
|
145
|
+
Backdrop: BaseContextMenu.Backdrop,
|
|
146
|
+
Positioner,
|
|
147
|
+
Popup,
|
|
148
|
+
Arrow: BaseContextMenu.Arrow,
|
|
149
|
+
Group: BaseContextMenu.Group,
|
|
150
|
+
GroupLabel,
|
|
151
|
+
Separator,
|
|
152
|
+
Item,
|
|
153
|
+
LinkItem: BaseContextMenu.LinkItem,
|
|
154
|
+
CheckboxItem,
|
|
155
|
+
RadioGroup: BaseContextMenu.RadioGroup,
|
|
156
|
+
RadioItem,
|
|
157
|
+
SubmenuRoot: BaseContextMenu.SubmenuRoot,
|
|
158
|
+
SubmenuTrigger,
|
|
159
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { ProgressRing } from './ProgressRing'
|
|
4
|
+
import { CIRCUMFERENCE } from './ProgressRing.consts'
|
|
5
|
+
|
|
6
|
+
describe('ProgressRing', () => {
|
|
7
|
+
it('renders zero dashoffset at 0% progress', () => {
|
|
8
|
+
render(<ProgressRing progress={0} colorClass="text-primary" label="Empty" testId="ring" />)
|
|
9
|
+
const circle = screen.getByTestId('ring')
|
|
10
|
+
const offset = Number(circle.getAttribute('stroke-dashoffset'))
|
|
11
|
+
expect(offset).toBeCloseTo(CIRCUMFERENCE, 1)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('renders correct dashoffset at 50% progress', () => {
|
|
15
|
+
render(<ProgressRing progress={0.5} colorClass="text-primary" label="Half" testId="ring" />)
|
|
16
|
+
const circle = screen.getByTestId('ring')
|
|
17
|
+
const offset = Number(circle.getAttribute('stroke-dashoffset'))
|
|
18
|
+
expect(offset).toBeCloseTo(CIRCUMFERENCE * 0.5, 1)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('renders zero dashoffset at 100% progress', () => {
|
|
22
|
+
render(<ProgressRing progress={1} colorClass="text-success" label="Full" testId="ring" />)
|
|
23
|
+
const circle = screen.getByTestId('ring')
|
|
24
|
+
const offset = Number(circle.getAttribute('stroke-dashoffset'))
|
|
25
|
+
expect(offset).toBeCloseTo(0, 1)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('applies colorClass to fill circle', () => {
|
|
29
|
+
render(<ProgressRing progress={0.5} colorClass="text-success" label="Test" testId="ring" />)
|
|
30
|
+
const circle = screen.getByTestId('ring')
|
|
31
|
+
expect(circle.classList.toString()).toContain('text-success')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('applies pulse animation when animate is true', () => {
|
|
35
|
+
render(<ProgressRing progress={0.5} colorClass="text-primary" label="Pulsing" animate />)
|
|
36
|
+
const svg = screen.getByRole('img', { hidden: true })
|
|
37
|
+
expect(svg.classList.toString()).toContain('animate-buffer-pulse')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('does not apply pulse animation when animate is false', () => {
|
|
41
|
+
render(<ProgressRing progress={0.5} colorClass="text-primary" label="Idle" />)
|
|
42
|
+
const svg = screen.getByRole('img', { hidden: true })
|
|
43
|
+
expect(svg.classList.toString()).not.toContain('animate-buffer-pulse')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('sets aria-label from label prop', () => {
|
|
47
|
+
render(<ProgressRing progress={0.5} colorClass="text-primary" label="42 paragraphs ready" />)
|
|
48
|
+
const svg = screen.getByRole('img', { hidden: true })
|
|
49
|
+
expect(svg.getAttribute('aria-label')).toBe('42 paragraphs ready')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
2
|
+
import { StrictMode } from 'react'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { Select } from './Select'
|
|
5
|
+
import type { SelectGroup } from './Select.types'
|
|
6
|
+
|
|
7
|
+
const options = [
|
|
8
|
+
{ value: 'a', label: 'Alpha' },
|
|
9
|
+
{ value: 'b', label: 'Bravo' },
|
|
10
|
+
{ value: 'c', label: 'Charlie' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const groups: SelectGroup[] = [
|
|
14
|
+
{
|
|
15
|
+
label: 'Letters',
|
|
16
|
+
options: [
|
|
17
|
+
{ value: 'a', label: 'Alpha' },
|
|
18
|
+
{ value: 'b', label: 'Bravo' },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
label: 'Numbers',
|
|
23
|
+
options: [
|
|
24
|
+
{ value: '1', label: 'One' },
|
|
25
|
+
{ value: '2', label: 'Two' },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const renderWith = (ui: React.ReactElement) => render(<StrictMode>{ui}</StrictMode>)
|
|
31
|
+
|
|
32
|
+
describe('Select', () => {
|
|
33
|
+
it('renders selected label and chevron in button', () => {
|
|
34
|
+
renderWith(<Select options={options} value="b" onChange={vi.fn()} />)
|
|
35
|
+
|
|
36
|
+
const button = screen.getByRole('button', { name: /bravo/i })
|
|
37
|
+
expect(button).toBeInTheDocument()
|
|
38
|
+
expect(button.querySelector('svg')).toBeInTheDocument()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('opens menu on click showing all options', () => {
|
|
42
|
+
renderWith(<Select options={options} value="a" onChange={vi.fn()} />)
|
|
43
|
+
|
|
44
|
+
fireEvent.click(screen.getByRole('button', { name: /alpha/i }))
|
|
45
|
+
|
|
46
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
|
47
|
+
expect(screen.getByRole('option', { name: /alpha/i })).toBeInTheDocument()
|
|
48
|
+
expect(screen.getByRole('option', { name: /bravo/i })).toBeInTheDocument()
|
|
49
|
+
expect(screen.getByRole('option', { name: /charlie/i })).toBeInTheDocument()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('calls onChange and closes menu when an option is selected', () => {
|
|
53
|
+
const onChange = vi.fn()
|
|
54
|
+
renderWith(<Select options={options} value="a" onChange={onChange} />)
|
|
55
|
+
|
|
56
|
+
fireEvent.click(screen.getByRole('button', { name: /alpha/i }))
|
|
57
|
+
fireEvent.click(screen.getByRole('option', { name: /charlie/i }))
|
|
58
|
+
|
|
59
|
+
expect(onChange).toHaveBeenCalledWith('c')
|
|
60
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('closes on Escape key', () => {
|
|
64
|
+
renderWith(<Select options={options} value="a" onChange={vi.fn()} />)
|
|
65
|
+
|
|
66
|
+
const button = screen.getByRole('button', { name: /alpha/i })
|
|
67
|
+
fireEvent.click(button)
|
|
68
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
|
69
|
+
|
|
70
|
+
fireEvent.keyDown(button, { key: 'Escape' })
|
|
71
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('closes on click outside', () => {
|
|
75
|
+
renderWith(<Select options={options} value="a" onChange={vi.fn()} />)
|
|
76
|
+
|
|
77
|
+
fireEvent.click(screen.getByRole('button', { name: /alpha/i }))
|
|
78
|
+
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
|
79
|
+
|
|
80
|
+
fireEvent.mouseDown(document.body)
|
|
81
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('navigates with arrow keys and selects with Enter', () => {
|
|
85
|
+
const onChange = vi.fn()
|
|
86
|
+
renderWith(<Select options={options} value="a" onChange={onChange} />)
|
|
87
|
+
|
|
88
|
+
const button = screen.getByRole('button', { name: /alpha/i })
|
|
89
|
+
fireEvent.click(button)
|
|
90
|
+
|
|
91
|
+
fireEvent.keyDown(button, { key: 'ArrowDown' })
|
|
92
|
+
fireEvent.keyDown(button, { key: 'ArrowDown' })
|
|
93
|
+
fireEvent.keyDown(button, { key: 'Enter' })
|
|
94
|
+
|
|
95
|
+
expect(onChange).toHaveBeenCalledWith('b')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('renders groups with headers', () => {
|
|
99
|
+
renderWith(<Select groups={groups} value="a" onChange={vi.fn()} />)
|
|
100
|
+
|
|
101
|
+
fireEvent.click(screen.getByRole('button', { name: /alpha/i }))
|
|
102
|
+
|
|
103
|
+
expect(screen.getByText('Letters')).toBeInTheDocument()
|
|
104
|
+
expect(screen.getByText('Numbers')).toBeInTheDocument()
|
|
105
|
+
expect(screen.getByRole('option', { name: /one/i })).toBeInTheDocument()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('uses renderOption for custom item rendering', () => {
|
|
109
|
+
renderWith(
|
|
110
|
+
<Select
|
|
111
|
+
options={options}
|
|
112
|
+
value="a"
|
|
113
|
+
onChange={vi.fn()}
|
|
114
|
+
renderOption={option => <span data-testid="custom">{option.label.toUpperCase()}</span>}
|
|
115
|
+
/>,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
fireEvent.click(screen.getByRole('button', { name: /alpha/i }))
|
|
119
|
+
|
|
120
|
+
expect(screen.getAllByTestId('custom')).toHaveLength(3)
|
|
121
|
+
expect(screen.getByText('BRAVO')).toBeInTheDocument()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('shows placeholder when value does not match any option', () => {
|
|
125
|
+
renderWith(<Select options={options} value="x" onChange={vi.fn()} placeholder="Pick one" />)
|
|
126
|
+
|
|
127
|
+
expect(screen.getByRole('button', { name: /pick one/i })).toBeInTheDocument()
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { Slider } from './Slider'
|
|
4
|
+
|
|
5
|
+
afterEach(cleanup)
|
|
6
|
+
|
|
7
|
+
describe('Slider', () => {
|
|
8
|
+
it('renders a slider with the correct value', () => {
|
|
9
|
+
render(<Slider value={50} onChange={() => {}} min={0} max={100} aria-label="Volume" />)
|
|
10
|
+
const slider = screen.getByRole('slider')
|
|
11
|
+
expect(slider).toHaveAttribute('aria-valuenow', '50')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('calls onCommit when interaction ends', () => {
|
|
15
|
+
const onCommit = vi.fn()
|
|
16
|
+
render(
|
|
17
|
+
<Slider
|
|
18
|
+
value={50}
|
|
19
|
+
onChange={() => {}}
|
|
20
|
+
onCommit={onCommit}
|
|
21
|
+
min={0}
|
|
22
|
+
max={100}
|
|
23
|
+
aria-label="Volume"
|
|
24
|
+
/>,
|
|
25
|
+
)
|
|
26
|
+
// Just verify it renders without error — slider drag testing is unreliable in jsdom
|
|
27
|
+
expect(screen.getByRole('slider')).toBeInTheDocument()
|
|
28
|
+
})
|
|
29
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { Switch } from './Switch'
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup)
|
|
7
|
+
|
|
8
|
+
describe('Switch', () => {
|
|
9
|
+
it('renders as a switch with the correct checked state', () => {
|
|
10
|
+
render(<Switch checked={true} onChange={() => {}} aria-label="Toggle" />)
|
|
11
|
+
expect(screen.getByRole('switch')).toBeChecked()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('calls onChange when clicked', async () => {
|
|
15
|
+
const user = userEvent.setup()
|
|
16
|
+
const onChange = vi.fn()
|
|
17
|
+
render(<Switch checked={false} onChange={onChange} aria-label="Toggle" />)
|
|
18
|
+
|
|
19
|
+
await user.click(screen.getByRole('switch'))
|
|
20
|
+
expect(onChange).toHaveBeenCalledWith(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('does not call onChange when disabled', async () => {
|
|
24
|
+
const user = userEvent.setup()
|
|
25
|
+
const onChange = vi.fn()
|
|
26
|
+
render(<Switch checked={false} onChange={onChange} disabled aria-label="Toggle" />)
|
|
27
|
+
|
|
28
|
+
await user.click(screen.getByRole('switch'))
|
|
29
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { getModKey } from './getModKey'
|
|
3
|
+
|
|
4
|
+
describe('getModKey', () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.unstubAllGlobals()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('returns "Cmd" on Mac', () => {
|
|
10
|
+
vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' })
|
|
11
|
+
expect(getModKey()).toBe('Cmd')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('returns "Ctrl" on Windows', () => {
|
|
15
|
+
vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' })
|
|
16
|
+
expect(getModKey()).toBe('Ctrl')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('returns "Ctrl" when navigator is undefined', () => {
|
|
20
|
+
vi.stubGlobal('navigator', undefined)
|
|
21
|
+
expect(getModKey()).toBe('Ctrl')
|
|
22
|
+
})
|
|
23
|
+
})
|
package/src/index.ts
CHANGED
package/themes/dashboard.css
CHANGED
|
@@ -121,6 +121,18 @@
|
|
|
121
121
|
--success-foreground: oklch(0.82 0.1 149);
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/* Global focus-visible ring for raw interactive elements. Primitives (Button,
|
|
125
|
+
* etc.) override this with their own tuned focus styling. */
|
|
126
|
+
@layer base {
|
|
127
|
+
button:focus-visible,
|
|
128
|
+
[role='button']:focus-visible,
|
|
129
|
+
a:focus-visible {
|
|
130
|
+
outline: 2px solid var(--primary);
|
|
131
|
+
outline-offset: 2px;
|
|
132
|
+
border-radius: 0.375rem;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
124
136
|
/* Keyframes referenced by the theme's --animate-* tokens */
|
|
125
137
|
@keyframes ring-spin {
|
|
126
138
|
to {
|
package/themes/reader.css
CHANGED
|
@@ -123,6 +123,18 @@
|
|
|
123
123
|
--success-foreground: oklch(0.82 0.1 149);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/* Global focus-visible ring for raw interactive elements. Primitives (Button,
|
|
127
|
+
* etc.) override this with their own tuned focus styling. */
|
|
128
|
+
@layer base {
|
|
129
|
+
button:focus-visible,
|
|
130
|
+
[role='button']:focus-visible,
|
|
131
|
+
a:focus-visible {
|
|
132
|
+
outline: 2px solid var(--primary);
|
|
133
|
+
outline-offset: 2px;
|
|
134
|
+
border-radius: 0.375rem;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
126
138
|
/* Keyframes referenced by the theme's --animate-* tokens */
|
|
127
139
|
@keyframes ring-spin {
|
|
128
140
|
to {
|