haze-ui 1.32.0 → 1.33.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.
Files changed (49) hide show
  1. package/README.md +87 -1210
  2. package/dist/coi-serviceworker.LICENSE +21 -0
  3. package/dist/coi-serviceworker.js +118 -0
  4. package/dist/components/Countdown/Countdown.js +49 -0
  5. package/dist/components/Countdown/index.js +3 -0
  6. package/dist/components/HoverCard/HoverCard.js +63 -43
  7. package/dist/components/ImageCrop/ImageCrop.js +188 -0
  8. package/dist/components/ImageCrop/image-crop-geometry.js +81 -0
  9. package/dist/components/ImageCrop/index.js +3 -0
  10. package/dist/components/LocaleProvider/ar-sa.js +6 -0
  11. package/dist/components/LocaleProvider/de-de.js +6 -0
  12. package/dist/components/LocaleProvider/es-es.js +6 -0
  13. package/dist/components/LocaleProvider/fr-fr.js +6 -0
  14. package/dist/components/LocaleProvider/it-it.js +6 -0
  15. package/dist/components/LocaleProvider/ja-jp.js +6 -0
  16. package/dist/components/LocaleProvider/ko-kr.js +6 -0
  17. package/dist/components/LocaleProvider/locale.js +6 -0
  18. package/dist/components/LocaleProvider/pt-br.js +6 -0
  19. package/dist/components/LocaleProvider/ru-ru.js +6 -0
  20. package/dist/components/LocaleProvider/zh-cn.js +6 -0
  21. package/dist/components/Resizable/index.js +1 -1
  22. package/dist/components/Splitter/Splitter.js +199 -0
  23. package/dist/components/Splitter/index.js +3 -0
  24. package/dist/css/countdown.css +4 -0
  25. package/dist/css/image-crop.css +4 -0
  26. package/dist/css/splitter.css +4 -0
  27. package/dist/css-manifest.json +4 -3
  28. package/dist/haze-ui.css +12 -0
  29. package/dist/hooks/useHotkeys.js +78 -24
  30. package/dist/index.js +277 -274
  31. package/dist/mcp-docs.json +210 -9
  32. package/dist/registry.json +71 -5
  33. package/dist/robots.txt +4 -0
  34. package/dist/sitemap.xml +160 -0
  35. package/dist/types/components/ConfigProvider/ConfigContext.d.ts +2 -1
  36. package/dist/types/components/Countdown/Countdown.d.ts +30 -0
  37. package/dist/types/components/Countdown/index.d.ts +2 -0
  38. package/dist/types/components/HoverCard/HoverCard.d.ts +18 -1
  39. package/dist/types/components/ImageCrop/ImageCrop.d.ts +67 -0
  40. package/dist/types/components/ImageCrop/image-crop-geometry.d.ts +65 -0
  41. package/dist/types/components/ImageCrop/index.d.ts +3 -0
  42. package/dist/types/components/LocaleProvider/locale.d.ts +6 -0
  43. package/dist/types/components/Resizable/index.d.ts +0 -2
  44. package/dist/types/components/Splitter/Splitter.d.ts +33 -0
  45. package/dist/types/components/Splitter/index.d.ts +2 -0
  46. package/dist/types/headless/index.d.ts +7 -6
  47. package/dist/types/hooks/useHotkeys.d.ts +16 -4
  48. package/dist/types/index.d.ts +7 -2
  49. package/package.json +23 -4
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Haze UI
2
2
 
3
- > The React UI Component Library with controllable states.
3
+ > The React UI component library with controllable states.
4
4
 
5
5
  English | [简体中文](./README-zh_CN.md)
6
6
 
@@ -8,1246 +8,123 @@ English | [简体中文](./README-zh_CN.md)
8
8
  [![downloads](https://img.shields.io/npm/dm/haze-ui.svg)](https://www.npmjs.com/package/haze-ui)
9
9
  [![CI](https://github.com/wmzy/haze-ui/actions/workflows/ci.yml/badge.svg)](https://github.com/wmzy/haze-ui/actions/workflows/ci.yml)
10
10
 
11
- ## Features
11
+ haze-ui is a React 19 component library styled with Linaria (zero-runtime
12
+ CSS-in-JS) against `--haze-*` design tokens: 140 components in 9 groups,
13
+ ESM-only with a tree-shakeable `preserveModules` dist, RTL by construction,
14
+ and an RSC-aware split (`'use client'` is pre-injected only on interactive
15
+ modules, so the static subset renders as React Server Components). Every
16
+ stateful component speaks the same `ControlOrValue<T>` protocol — one prop,
17
+ `checked?: Control<T> | T`, covers controlled *and* uncontrolled usage.
18
+ Only `react` and `react-dom` (v19) are peers; every engine (react-f0rm,
19
+ TanStack Table, recharts, dnd-kit, qrcode) is a regular dependency that
20
+ tree-shakes away when your imports never reach it.
12
21
 
13
- - Integrated [react use control](https://github.com/wmzy/react-use-control) provides component internal states
14
- - Built for React 19+ (`ref` as a prop, `ControlOrValue<T>` state protocol) — no React 18 compatibility layer
15
- - Keep strict, provide lightweight, composable, and easily extendable components
16
- - Support themes customization
17
- - Support Tree-shaking
18
- - RTL by construction: CSS logical properties, mirrored horizontal-key
19
- semantics (Tabs, Rating, Slider, Carousel, Calendar, menus, …) and
20
- mirrored floating placements under `dir="rtl"`
21
- - RSC-aware dist: interactive modules carry `'use client'`; 36 static,
22
- hook-free modules (tokens, Badge, Card, Typography, …) ship without it
23
- so they render as React Server Components
24
- - Tested like a product: 120+ unit test files, axe accessibility cases in
25
- every component test suite, ~50 Playwright e2e scenarios across
26
- Chromium/Firefox/WebKit, and pixel-locked visual baselines
27
-
28
- ## Why npm-distributed (not copy-paste)
29
-
30
- **Interaction states are formulas, not values.** Every hover/active/subtle/focus-ring color is a CSS relative-color expression — `oklch(from var(--haze-color-primary) calc(l - 0.045) c h)` — baked into `tokens.css`. Override `--haze-color-primary` on a theme class and the entire interaction-state family re-derives at runtime in the browser, no rebuild, no codegen. In a copy-paste setup every pasted file owns its own copy of that formula, and each theme tweak means re-applying it by hand, file by file.
31
-
32
- **`haze-ui/css-manifest.json` is machine-readable data.** The authoritative export → CSS-file mapping (family absorption included) is regenerated on every build, so bundler plugins and codemods read one source of truth instead of re-deriving kebab-case file names. A directory of pasted components has no equivalent — the mapping exists only in your head or your fork, and it drifts.
33
-
34
- **The `ControlOrValue<T>` protocol needs a real runtime.** One prop — `checked?: Control<T> | T` — covers controlled *and* uncontrolled usage because `react-use-control` carries the wiring; a published package is the contract that keeps that behavior identical across every component and version. None of this is hostile to the shadcn / Base UI ecosystem: haze-ui tokens are plain CSS custom properties, and haze components compose alongside copy-pasted primitives wherever each fits.
35
-
36
- ## Getting Started
37
-
38
- ### Quickstart via create-haze-ui
39
-
40
- Scaffold a ready-to-run app with the official templates — the CLI has zero
41
- runtime dependencies and needs no network access beyond dependency install:
42
-
43
- ```sh
44
- pnpm create haze-ui my-app # vite template (default)
45
- pnpm create haze-ui my-app --template nextjs # or: npm create haze-ui@latest my-app --
46
- ```
47
-
48
- The **vite** template is a React 19 + Vite + TypeScript single-page demo
49
- (`Button` / `Input` / `Dialog` / `Toast`) with the theme classes mounted on
50
- the page root; the **nextjs** template is a Next.js 15 App Router starter
51
- whose home page renders the RSC-safe static subset (`Badge` / `Card` /
52
- `Typography`) as a server component, with a client island for the
53
- interactive pieces. Prefer wiring an existing project by hand? Continue
54
- with Installation below.
55
-
56
- ### Installation
22
+ ## Quickstart
57
23
 
58
24
  ```sh
59
25
  npm i haze-ui
60
- // or
26
+ # or
61
27
  pnpm add haze-ui
62
28
  ```
63
29
 
64
- ### Install via shadcn CLI (GitHub registry)
65
-
66
- The repository is also a [shadcn registry](https://ui.shadcn.com/docs/registry/github),
67
- so the shadcn CLI installs any component directly from GitHub — no manual
68
- npm setup:
69
-
70
- ```sh
71
- pnpm dlx shadcn@latest add wmzy/haze-ui/button
72
- pnpm dlx shadcn@latest add wmzy/haze-ui/dialog wmzy/haze-ui/tabs
73
- pnpm dlx shadcn@latest add wmzy/haze-ui/base # everything at once
74
- pnpm dlx shadcn@latest list wmzy/haze-ui # browse all items
75
- ```
76
-
77
- The `base` item (type `registry:base`) is the one-command path: it
78
- installs the theme-token activation plus a wrapper for every component in
79
- a single `shadcn add` — the 107 component items are pulled in through
80
- `registryDependencies` (full `wmzy/haze-ui/<item>` addresses, since plain
81
- names would resolve against the official shadcn registry).
82
-
83
- Every **styled** export is covered (107 component items — one per CSS
84
- family, so compound components like `tabs` arrive as `Tabs`/`TabList`/
85
- `Tab`/`TabPanel` together). What lands in your project is a thin
86
- **wrapper** file under `components/ui/haze/` that re-exports the
87
- component from `haze-ui` and imports its stylesheet — not vendored
88
- source. The wrapper is your customization layer (fork it, wrap it,
89
- restyle it), while `haze-ui` itself is installed as a normal npm
90
- dependency and keeps updating through your package manager. Common items:
91
- `button`, `input`, `select`, `dialog`, `tabs`, `toast`, `sidebar`,
92
- `form`, `data-table`, `chart`, `chat-message`, plus `tokens` (the theme
93
- classes) and `haze-tokens` (a token onboarding guide doc). Each item also
94
- carries `docs` (markdown usage — description, install command, theme
95
- activation — printed by `shadcn view`) and `categories` (kebab-case tags
96
- — `form`, `overlay`, `data-display`, `agent`, … — used by registry
97
- search/filter tooling). See [registry/README.md](./registry/README.md)
98
- for the full picture.
99
-
100
- ### React 19+ by design
101
-
102
- The peer range is `react: ^19.0.0` on purpose — haze-ui is built on
103
- modern React rather than carrying a compatibility layer for 18:
104
-
105
- - **`ref` as a prop.** React 19 passes `ref` to function components
106
- directly, so components that expose refs accept one prop instead of a
107
- `forwardRef` wrapper layer.
108
- - **A modern-platform baseline.** Floating panels (Popover, DropdownMenu,
109
- Tooltip, ContextMenu, Combobox, Datepicker) choose between three tiers
110
- by feature detection — native `popover` + CSS anchor positioning,
111
- `popover` alone, or a JS fallback — with the newest platform features
112
- as the primary path, not an enhancement layered on top.
113
- - **`ControlOrValue<T>`** — the `Control<T> | T` state protocol — is
114
- designed for modern React semantics throughout.
115
-
116
- On React 18? Upgrade first. haze-ui ships no React 18 compatibility
117
- layer, and none is planned.
118
-
119
- #### Migrating from React 18
120
-
121
- Once the app itself is on React 19, run through the haze-ui-relevant
122
- checklist:
123
-
124
- - **`ref` is a prop.** React 19 passes `ref` to function components
125
- directly — drop any `forwardRef` wrapper you had around haze components
126
- and pass `ref` like any other prop.
127
- - **No `defaultValue` dual track.** Stateful components speak
128
- `ControlOrValue<T>` (`checked?: Control<T> | boolean`): one prop covers
129
- controlled *and* uncontrolled usage, so there is no
130
- `defaultChecked`/`defaultValue` pair to migrate. Where you used to pass
131
- `defaultValue`, pass the plain value (uncontrolled) or switch to
132
- controlled with `onChange`.
133
- - **Floating panels assume the modern-platform baseline.** Popover,
134
- DropdownMenu, Tooltip, ContextMenu, Combobox and Datepicker
135
- feature-detect between native `popover` + CSS anchor positioning,
136
- `popover` alone, or a JS fallback — no polyfills to carry over from
137
- your React 18 setup, but the floor from [Browser support](#browser-support)
138
- applies.
139
- - **Colors are OKLCH with runtime-derived interaction states.** Requires
140
- Chrome/Edge 119+, Safari 16.4+ or Firefox 128+; no HSL/hex fallbacks
141
- are shipped.
142
- - **`'use client'` is pre-injected on interactive modules.** Every
143
- stateful module in `dist/` starts with the directive; the static,
144
- hook-free set (tokens and presentational components) ships without it
145
- so those render as React Server Components (see
146
- [Server Components](#server-components-nextjs-app-router)).
147
-
148
- ### Dependencies
149
-
150
- haze-ui declares only the host environment as peers: `react` and
151
- `react-dom` (^19). Everything else is a regular dependency — one
152
- `npm i haze-ui` and every component works:
153
-
154
- - `react-use-control` — the engine behind `ControlOrValue<T>`
155
- - `react-f0rm` (^1.3) — the form engine behind `FormItem`/`FormList`
156
- (the same author's headless form store)
157
- - `@tanstack/react-table` — DataTable
158
- - `recharts` — Chart
159
- - `@dnd-kit/core` / `@dnd-kit/sortable` / `@dnd-kit/utilities` — the
160
- sortable TagInput/TagGroup variants
161
- - `qrcode` — QRCode
162
-
163
- Swapping any of these engines for another library is invisible to your
164
- code (the one deliberate exception: DataTable's column API exposes
165
- TanStack's `ColumnDef` types). The dist is ESM with `preserveModules`
166
- and side-effect-free JS, and engines are imported only by the components
167
- that need them, so bundlers (Next.js, Vite, webpack, Turbopack, Rollup)
168
- tree-shake the unused re-export chains: `import { Button } from
169
- 'haze-ui'` never pulls `@tanstack/react-table` into the bundle. Engines
170
- install as regular dependencies, so even bundler-less consumers (bare
171
- Node ESM importing the barrel, which links the module graph eagerly)
172
- work with zero manual installs; the `haze-ui/components/DataTable` and
173
- `haze-ui/components/Chart` subpaths bypass the barrel entirely.
174
-
175
- ### Browser support
176
-
177
- Since v1.13, haze-ui colors are generated in **OKLCH**, with interaction
178
- states (hover/active/subtle/focus-ring) derived at runtime via CSS relative
179
- color syntax. This requires Chrome/Edge 119+, Safari 16.4+, or Firefox 128+;
180
- no HSL/hex fallbacks are shipped.
181
-
182
- ### Usage
183
-
184
- Import the component and its CSS. Two CSS loading modes:
30
+ Load the CSS — pick one of two modes:
185
31
 
186
32
  ```jsx
187
- // Full stylesheet (simplest, ~12kB gzipped)
33
+ // A) one full stylesheet (simplest)
188
34
  import 'haze-ui/styles.css';
189
- import { lightTheme, spacing, typography, Button } from 'haze-ui';
190
-
191
- // …or per-component CSS (pay only for what you render).
192
- // Always load tokens.css once, then each component you use:
193
- import 'haze-ui/css/tokens.css';
194
- import 'haze-ui/css/button.css';
195
- import { Button } from 'haze-ui';
196
-
197
- // …or zero CSS imports at all — the official Vite plugin injects the
198
- // right per-component CSS for you (see "Automatic CSS with haze-ui-vite"):
199
- // vite.config.ts → hazeCss() from 'haze-ui-vite'
200
- import { Button } from 'haze-ui';
201
-
202
- export default function MyComponent() {
203
- return <Button>Start</Button>;
204
- }
205
35
  ```
206
36
 
207
- #### Automatic CSS with haze-ui-vite
208
-
209
- [`haze-ui-vite`](https://www.npmjs.com/package/haze-ui-vite) is the
210
- official companion Vite plugin: it scans each module's named imports from
211
- `haze-ui` (`import { Button, Select } from 'haze-ui'`), maps them through
212
- `haze-ui/css-manifest.json`, and prepends the matching
213
- `haze-ui/css/*.css` side-effect imports — `tokens.css` always first. CSS
214
- enters the module graph exactly where it's consumed, so code-split views
215
- carry only their own component CSS and HMR works through Vite's native
216
- pipeline.
217
-
218
37
  ```ts
219
- // vite.config.ts
38
+ // B) the official Vite plugin injects per-component CSS automatically
39
+ // vite.config.ts
220
40
  import hazeCss from 'haze-ui-vite';
221
41
 
222
- export default {
223
- plugins: [react(), hazeCss()],
224
- };
225
- ```
226
-
227
- It reads the manifest shipped by your installed haze-ui version (no
228
- hardcoded name mapping), validates every injected file exists in the
229
- actual package, and warns on namespace imports it cannot collect. Works
230
- with Vite 5–8, zero runtime dependencies. Not on Vite? Keep the explicit
231
- imports above, or read `haze-ui/css-manifest.json` from your own bundler
232
- tooling — it's the same data the plugin uses.
233
-
234
- Component CSS files are kebab-case versions of the component name
235
- (`OTPInput` → `haze-ui/css/otp-input.css`). Per-component files only
236
- cover that component's rules — tokens (themes, spacing, typography)
237
- always come from `haze-ui/css/tokens.css`.
238
-
239
- Don't hardcode that kebab-case rule in tooling: sub-components and cores
240
- share their directory's family file (`InputCore` → `input.css`,
241
- `ButtonLink` → `button.css`, `Title`/`Text` → `typography.css`). The
242
- authoritative export → css-file mapping ships as data:
243
- `haze-ui/css-manifest.json`, generated at build time by
244
- `scripts/split-css.mjs` from the actual CSS output (never hand-edited):
245
-
246
- ```json
247
- {
248
- "families": { "Button": "button", "ButtonLink": "button", "InputCore": "input", "useToast": "toast" },
249
- "noCss": ["COMPONENT_TOKENS", "TOKEN_REGISTRY", "useControl", "useTitle"]
250
- }
251
- ```
252
-
253
- `families` covers every named export that has styles (family absorption
254
- included); `noCss` lists pure-logic exports with no css of their own.
255
- Bundler plugins and codemods should read this manifest instead of
256
- re-deriving file names — the mapping changes in lockstep with the build.
257
- The official [`haze-ui-vite`](https://www.npmjs.com/package/haze-ui-vite)
258
- plugin (above) is the reference consumer of this data.
259
-
260
- #### Styling & overriding (the `@layer haze` contract)
261
-
262
- All shipped CSS lives inside **`@layer haze`**, so your own *unlayered*
263
- styles beat every library rule regardless of source order or
264
- specificity — no `!important`, no selector wrestling:
265
-
266
- ```css
267
- /* your stylesheet, plain CSS — always wins over the library */
268
- [data-slot='button'] {
269
- letter-spacing: 0.02em;
270
- }
271
- ```
272
-
273
- Every element a component renders carries a stable **`data-slot`**
274
- attribute (`data-slot='button'`, `'label'`, `'panel'`, `'menu-item'`,
275
- …). It is the official override surface: the attribute set is enforced
276
- by a coverage test, so it cannot silently drift between releases. The
277
- cascade is intentional — prefer, in order:
278
-
279
- 1. **tokens** — `var(--haze-color-primary)` and friends re-derive whole
280
- interaction-state families at runtime; per-component
281
- `--haze-button-*` variables retheme one family;
282
- 2. **`ConfigProvider` defaults** — app-wide per-component prop defaults
283
- (and `classNames` where the component exposes slots);
284
- 3. **`data-slot` CSS targeting** — structural overrides (the escape
285
- hatch above);
286
- 4. **`className` / `classNames`** — per-instance overrides, reserved
287
- for one-offs.
288
-
289
- If your own framework ships layered styles too, declare order explicitly
290
- (e.g. `@layer haze, app;`) to keep the last word.
291
-
292
- ### Server Components (Next.js App Router)
293
-
294
- Interactive modules in `dist/` carry the `'use client'` directive,
295
- injected at build time — the same convention Radix, Base UI and React
296
- Aria ship. In an App Router project you import haze-ui straight from
297
- your client components; there is no need for a wrapper module that
298
- re-exports the library under its own `'use client'` banner:
299
-
300
- ```jsx
301
- // any client component — import straight from the package
302
- import { Button } from 'haze-ui';
303
-
304
- export function Actions() {
305
- return <Button>Start</Button>;
306
- }
307
- ```
308
-
309
- **A static subset renders as React Server Components.** Modules with no
310
- hooks and no DOM access ship *without* the directive, so they can be
311
- imported directly into server components and cost zero client JS:
312
-
313
- - tokens: everything on the `haze-ui/tokens` subpath (`lightTheme`,
314
- `darkTheme`, `spacing`, `typography`, `TOKEN_REGISTRY`, OKLCH utils)
315
- - components: `AspectRatio`, `Badge`, `Card`, `CodeBlock`, `Container`,
316
- `Divider`, `Flex`, `Grid`/`GridItem`, `Icon`, `Kbd`, `Skeleton`,
317
- `Stat`/`StatGroup`, `Typography` (`Title`/`Text`/`Paragraph`)
318
-
319
- Import them from the subpath (`import { Badge } from
320
- 'haze-ui/components/Badge'`) or the barrel — in an RSC module the
321
- bundler resolves the module itself, not the client-marked barrel shell.
322
- The emitted markup carries the usual Linaria class names, so CSS loading
323
- is unchanged (`haze-ui/styles.css` or the `css/*` subpaths). The safe
324
- list lives in `scripts/rsc-safe.mjs` and is enforced by
325
- `src/lib/dist-esm-contract.test.ts` (including a transitive-closure
326
- check: every static import inside a safe module must itself be safe) —
327
- new components only join the list when they stay hook-free.
328
-
329
- CSS loading is unchanged from the two modes above — `haze-ui/styles.css`
330
- in the root layout, or the `haze-ui/css/*` subpaths. A runnable Next.js
331
- 15 project lives in [`examples/nextjs`](./examples/nextjs).
332
-
333
- ## ButtonLink: a real anchor with the Button skin
334
-
335
- Navigation that must look like a button should still *be* a link —
336
- rendering `as={Button}` drops `href` onto a `<button>` (an invalid
337
- attribute: no ⌘/middle-click new tab, nothing for crawlers or no-JS).
338
- `ButtonLink` renders a native `<a>` wearing Button's full appearance —
339
- same `variant`/`size`/`square` props, same hover/active/focus and
340
- disabled visual states:
341
-
342
- ```jsx
343
- import { ButtonLink } from 'haze-ui';
344
-
345
- <ButtonLink href='/page/2' variant='outline'>Next page</ButtonLink>
346
-
347
- // anchors have no `disabled` attribute — report the state with
348
- // aria-disabled (+ tabIndex={-1} to leave the focus order); ButtonLink
349
- // styles it exactly like Button's :disabled
350
- <ButtonLink href='/prev' aria-disabled tabIndex={-1}>← Previous</ButtonLink>
351
- ```
352
-
353
- Everything else extends the native `<a>` attributes and is spread onto
354
- the anchor (`target`, `rel`, `download`, `aria-*`, …), with the ref
355
- forwarded — the same composition shape `NavLink` uses, so routers can
356
- swap their own Link element through an `as` prop:
357
-
358
- ```jsx
359
- // with a typed router Link (href + SPA onClick injected by the router):
360
- <TypedLink to='/articles' search={{offset: 20}} as={ButtonLink}>
361
- Next page
362
- </TypedLink>
363
- ```
364
-
365
- Both components share one skin (a styles module), so a theme tweak to
366
- `Button` re-skins `ButtonLink` in lockstep. CSS: `haze-ui/css/button.css`
367
- covers both.
368
-
369
- ## AsyncSection: loading / error / content in one place
370
-
371
- `AsyncSection` collapses the three states every async view hand-rolls into
372
- one component: `loading` renders the spinner placeholder, a non-null
373
- `error` renders an alert box with an optional `Retry` button (an `Error`
374
- instance contributes its `message`; `errorText` overrides), and otherwise
375
- the children render. `loading` wins when both are set — the retry path
376
- (loading again before the old error clears) shows the placeholder, not
377
- the stale error. All copy is configurable; the retry button only renders
378
- when `onRetry` is provided.
379
-
380
- ```jsx
381
- import 'haze-ui/css/tokens.css';
382
- import 'haze-ui/css/async-section.css';
383
- import { AsyncSection } from 'haze-ui';
384
-
385
- <AsyncSection loading={loading} error={error} onRetry={refetch}>
386
- {data}
387
- </AsyncSection>
388
- ```
389
-
390
- ## useTitle: view-level document.title
391
-
392
- `useTitle(title)` sets `document.title` while the view is mounted and
393
- restores the pre-entry title (the static `<title>` from the host page) on
394
- unmount. Two timing pitfalls are baked into the implementation: the write
395
- and the restore are two separate effects (a single `[title]` effect would
396
- restore the *previous round's* title, not the entry default, on every prop
397
- change), and the entry snapshot is taken at effect time, not render time —
398
- a route swap is one commit, so at render time the old view's cleanup has
399
- not run yet and `document.title` still holds the previous page.
400
-
401
- ```jsx
402
- import { useTitle } from 'haze-ui';
403
-
404
- function SettingsView() {
405
- useTitle('Settings');
406
- // ...
407
- }
408
- ```
409
-
410
- ## Anchor: scroll-spy navigation
411
-
412
- Sticky anchor navigation with an IntersectionObserver scroll-spy (a
413
- scroll-listener fallback covers engines without IO). Pass `items`, and
414
- the highlighted section is a controllable state like everything else —
415
- `activeId?: ControlOrValue<string>`. Clicks scroll the container
416
- (`getContainer` defaults to the window) with an `offsetTop` adjustment,
417
- and the active link carries `aria-current="true"`.
418
-
419
- ```jsx
420
- import 'haze-ui/css/tokens.css';
421
- import 'haze-ui/css/anchor.css';
422
- import { Anchor } from 'haze-ui';
423
-
424
- <Anchor items={[{ id: 'intro', label: 'Intro' }, { id: 'api', label: 'API' }]} />;
425
- ```
426
-
427
- ## Watermark: tiled canvas watermark
428
-
429
- A canvas-tiled watermark layer: text rows (`content: string | string[]`)
430
- rendered onto an off-screen tile (rotated, `gap`-spaced,
431
- device-pixel-ratio aware) and repeated as a background over the
432
- container — or the whole viewport with `fullscreen`. Default color
433
- follows the theme's `--haze-color-text-muted`; the layer is
434
- `pointer-events: none` and `aria-hidden`, and engines without a canvas
435
- 2d context degrade to no watermark rather than crashing.
436
-
437
- ```jsx
438
- <Watermark content="CONFIDENTIAL" rotate={-22}>
439
- <Report />
440
- </Watermark>
441
- ```
442
-
443
- ## Fullscreen: wrap-mode fullscreen binding
444
-
445
- `useFullscreen(target?)` returns `[isFullscreen, toggle, handle]` — the
446
- Fullscreen API wired to `ControlOrValue` semantics: `Fullscreen`
447
- wraps a single trigger child (its `onClick` is preserved and composed)
448
- and exposes `fullscreen?: ControlOrValue<boolean>`, so browser-side
449
- exits (Esc, OS gestures) write back through the control and fire
450
- `onChange` exactly once.
451
-
452
- ```jsx
453
- const [fs, setFs, fsCtrl] = useControl(undefined, false);
454
- <Fullscreen fullscreen={fsCtrl} onChange={setFs}>
455
- <Button>Enter focus mode</Button>
456
- </Fullscreen>
457
- ```
458
-
459
- ## Utility hooks
460
-
461
- All hooks ship on the main barrel (no separate import path) and follow
462
- the same conventions as the components: `ControlOrValue` where state is
463
- user-controllable, SSR/jsdom-safe when the underlying API is missing.
464
-
465
- | Hook | Returns | Notes |
466
- | --- | --- | --- |
467
- | `useClipboard(resetMs?)` | `{ copied, copy }` | `copied` resets after `resetMs` |
468
- | `useDebouncedCallback(fn, delayMs)` | debounced `fn` | Stable identity across renders |
469
- | `useDebouncedValue(value, delayMs)` | `T` | Trails the input by `delayMs` |
470
- | `useFullscreen(target?)` | `[isFullscreen, toggle, handle]` | See section above |
471
- | `useHotkeys(map, options?)` | — | `'mod+s'`-style specs (`mod` normalizes to ⌘ on macOS, Ctrl elsewhere); skips events from editable targets unless `allowInInput`; `hotkey(...keys)` builds specs type-safely |
472
- | `useInView(options?)` | `[refCallback, inView]` | IntersectionObserver; `once` freezes after the first hit; `false` forever when the API is missing (SSR) |
473
- | `useLocalStorage(key, control?, initial?)` | `[value, setValue]` | `ControlOrValue` semantics, cross-tab `storage` sync, corrupt JSON self-heals to `initial` |
474
- | `useSessionStorage(key, control?, initial?)` | `[value, setValue]` | Same kernel, `sessionStorage` scope |
475
- | `useMediaQuery(query)` | `boolean` | Reactive `matchMedia`; `false` when missing |
476
- | `usePrefersReducedMotion()` | `boolean` | Token-aware animation gating |
477
- | `useTitle(title)` | — | Document title while mounted |
478
- | `useToast()` | toast API | Requires a `ToastContainer` ancestor |
479
-
480
- ## Sortable tags and chips
481
-
482
- Drag reordering is an opt-in variant: `SortableTagInput` /
483
- `SortableTagInputCore` and `SortableTagGroup` statically import
484
- `@dnd-kit/core` / `@dnd-kit/sortable` / `@dnd-kit/utilities` (regular
485
- dependencies — the plain `TagInput`/`TagGroup` never touch them, so they
486
- stay out of every bundle that doesn't import a Sortable variant).
487
- `SortableTagInput` writes the new array through `onChange`;
488
- `SortableTagGroup` reports `onReorder(nextOrder)` (the new arrangement of
489
- original child indexes) and leaves re-rendering to the parent. Sorting is
490
- keyboard-complete: focus a tag, `Space` to lift, arrows to move, `Space`
491
- to drop, `Escape` to cancel.
492
-
493
- ## Imperative overlay handles
494
-
495
- `Dialog`, `Drawer`, `BottomSheet`, `Popover` and `DropdownMenu` accept a
496
- `ref` (React 19 style — no `forwardRef`) exposing
497
- `{ open(), close(), focusTrigger() }`. The methods drive the same state
498
- outset as user interaction — `Dialog`'s `close()` goes through the
499
- native `close` event path so `onClose` and focus restoration fire
500
- exactly once — and they honor controlled mode, writing through the
501
- control.
502
-
503
- ```tsx
504
- const dialog = useRef<DialogHandle>(null);
505
- <Dialog ref={dialog} …>…</Dialog>;
506
- <button onClick={() => dialog.current?.open()}>Show</button>;
507
- ```
508
-
509
- ## View Transitions on modals (opt-in)
510
-
511
- `Dialog`, `Drawer` and `BottomSheet` accept `viewTransition?: boolean`
512
- (default `false`). When enabled, their open/close state flips are wrapped
513
- in `document.startViewTransition(() => flushSync(...))` — the synchronous
514
- DOM-update pattern React requires — so modals animate through the native
515
- View Transitions API alongside their regular enter/exit animations. The
516
- transition itself degrades to a plain state flip when the engine lacks
517
- `startViewTransition` or the user prefers reduced motion; transition
518
- appearance is yours to style via `::view-transition-*`. Controlled
519
- consumers' own flips happen outside the component and are intentionally
520
- not wrapped.
521
-
522
- ## Design tokens export (Figma)
523
-
524
- `toDesignTokens()` converts the token registry into a [W3C Design Tokens](https://tr.designtokens.org/) JSON file — grouped by category (`color` / `font` / `spacing` / `dimension` / `shadow`), each token carrying a W3C `$type` and the source CSS variable in `$extensions['haze-ui.css-var']`, with `$value` being the resolved value of the chosen theme (`light` by default). The Theme Editor ships a one-click entry: the **Export W3C tokens (.json)** button in the toolbar downloads the file for the mode currently being edited, live edits included. The exported file can be imported directly into Figma Tokens / Tokens Studio.
525
-
526
- ```json
527
- {
528
- "color": {
529
- "color-primary": {
530
- "$value": "oklch(0.563 0.241 260.8)",
531
- "$type": "color",
532
- "$extensions": { "haze-ui.css-var": "--haze-color-primary" }
533
- }
534
- },
535
- "spacing": {
536
- "space-2": {
537
- "$value": "8px",
538
- "$type": "dimension",
539
- "$extensions": { "haze-ui.css-var": "--haze-space-2" }
540
- }
541
- }
542
- }
543
- ```
544
-
545
- ## Brand themes: presets, runtime generation, CLI
546
-
547
- Five preset brand themes ship as complete theme classes — `violetTheme`,
548
- `tealTheme`, `cyanTheme`, `orangeTheme`, `roseTheme` — each a drop-in
549
- replacement for `lightTheme`/`darkTheme` with primary/info/focus-ring
550
- rerouted to the brand's scale.
551
-
552
- **Runtime custom brands.** `createBrandTheme` (from `haze-ui` or
553
- `haze-ui/tokens`) runs the exact preset pipeline from your own seed hex:
554
-
555
- ```ts
556
- import { createBrandTheme } from 'haze-ui/tokens';
557
-
558
- const brand = createBrandTheme({
559
- name: 'acme', // → --haze-acme-1…12 (lowercase kebab; not gray/green/amber/red)
560
- light: '#0066ff', // light-mode primary seed (scale step 9)
561
- dark: '#0a3d99', // optional — derived from light (oklch l +0.2) when omitted
562
- overrides: { light: { 10: '#0052cc' } }, // optional extra anchors
563
- });
564
- // brand.light / brand.dark: bare declaration blocks — wrap in classes once:
565
- // styleEl.textContent = `.acme-light { ${brand.light} }\n.acme-dark { ${brand.dark} }`;
566
- ```
567
-
568
- The blocks are theme *replacements* (never stack on `lightTheme`), and you
569
- own contrast: pick a light seed dark enough for white text — the presets'
570
- step-9 anchors sit near Tailwind's 600/700 range for that reason.
571
-
572
- **CLI.** Zero Node dependencies, identical output:
573
-
574
- ```bash
575
- npx haze-ui-theme --primary '#0066ff' [--dark '#0a3d99'] [--name brand] [--out brand.css]
576
- ```
577
-
578
- **W3C design-token archives.** Every preset is also exported per mode as
579
- W3C DTF JSON:
580
-
581
- ```js
582
- import violetLight from 'haze-ui/design-tokens/violet/light.json';
583
- ```
584
-
585
- ## Component-level tokens
586
-
587
- Global `--haze-*` tokens theme the whole system; **component-level tokens**
588
- theme one component family without touching anything else. They are plain
589
- CSS custom properties consumed through a fallback chain baked into the
590
- component's own stylesheet:
591
-
592
- ```css
593
- /* inside haze-ui/css/button.css */
594
- height: var(--haze-button-height-md, auto);
595
- ```
596
-
597
- The library never defines these variables — nothing to register, no
598
- runtime. When you set one on `:root` or any ancestor of the component, your
599
- value wins; when you don't, the built-in fallback applies and the component
600
- looks exactly as shipped. Scoping is ordinary CSS inheritance: set the
601
- variable on a wrapper element to retheme only that subtree.
602
-
603
- `Button`/`ButtonLink` (pilot — they share one skin, so both change
604
- together; `Toggle` and the Toolbar items wearing the same skin follow
605
- along):
606
-
607
- | Token | Fallback (default look) |
608
- | ---------------------------------- | ----------------------- |
609
- | `--haze-button-height-sm/md/lg` | `auto` (content-driven) |
610
- | `--haze-button-font-size-sm/md` | `var(--haze-text-sm)` |
611
- | `--haze-button-font-size-lg` | `var(--haze-text-base)` |
612
- | `--haze-button-radius` | `var(--haze-radius-md)` |
613
-
614
- `Dialog`:
615
-
616
- | Token | Fallback (default look) |
617
- | -------------------------------- | ------------------------ |
618
- | `--haze-dialog-width` | `480px` (on `max-width`) |
619
- | `--haze-dialog-radius` | `var(--haze-radius-xl)` |
620
- | `--haze-dialog-padding` | `var(--haze-space-6)` |
621
- | `--haze-dialog-title-gap` | `var(--haze-space-4)` |
622
- | `--haze-dialog-title-font-size` | `var(--haze-text-lg)` |
623
-
624
- ```css
625
- /* app-wide denser buttons; every other component keeps its sizing */
626
- :root {
627
- --haze-button-height-md: 32px;
628
- }
629
-
630
- /* only dialogs inside this subtree get wider */
631
- .wide-dialogs {
632
- --haze-dialog-width: 720px;
633
- }
634
- ```
635
-
636
- `COMPONENT_TOKENS` (exported from `haze-ui` and `haze-ui/tokens`) lists
637
- each component's component-level tokens alongside the global tokens it
638
- consumes.
639
-
640
- ## AI-friendly distribution
641
-
642
- **llms.txt** — a markdown overview of the whole library (the `ControlOrValue<T>`
643
- state protocol, both CSS loading modes, all 107 components grouped with one-line
644
- purposes, the token system, the floating-overlay tiers, form integration) written
645
- for AI coding tools and crawlers. It lives at the repo root
646
- ([llms.txt](./llms.txt)) and, on the docs site, at
647
- <https://wmzy.github.io/haze-ui/llms.txt> (`build:demo` copies it into `dist/`).
648
-
649
- **registry.json** — an agent-installable registry shipped as the
650
- `haze-ui/registry.json` npm artifact (regenerated by
651
- `scripts/generate-registry.mjs` on every build), also reachable at
652
- <https://unpkg.com/haze-ui/registry.json>. Point any registry-aware
653
- tooling at it — the shadcn CLI works:
654
-
655
- ```sh
656
- pnpm dlx shadcn@latest add https://unpkg.com/haze-ui/registry.json
657
- ```
658
-
659
- Every item is a **wrapper file, not vendored source**: it re-exports the
660
- component from the published haze-ui npm package and imports its
661
- stylesheet, so `haze-ui` stays a normal npm dependency that keeps
662
- updating underneath — the shadcn CLI is only the distribution channel.
663
- That is the point: AI coding agents (and anyone who wants a fast setup
664
- while keeping the npm update path) get working imports in one step,
665
- without the copy-paste expectation of owning pasted source. When you
666
- want different styles or behavior, fork the generated wrapper file — it
667
- is your customization layer, not a source drop. Coverage: **every styled
668
- export** (107 css families / 185 components), generated from the build's
669
- own css manifest. The same items are also installable straight from this
670
- GitHub repository — see
671
- [Install via shadcn CLI (GitHub registry)](#install-via-shadcn-cli-github-registry).
672
-
673
- ## AI / MCP
674
-
675
- **`haze-ui-mcp`** — an [MCP server](https://modelcontextprotocol.io) that puts the
676
- whole component reference in your AI coding agent's tool belt. It serves the
677
- same docs the site renders (props tables with types/defaults/descriptions,
678
- import statements, CSS paths, peer deps, the full token registry), so the
679
- agent never guesses a prop name or a stylesheet path again:
680
-
681
- | Tool | What it returns |
682
- | --- | --- |
683
- | `haze_list_components` | Every component with its css family |
684
- | `haze_get_component` | One component's full reference — props tables, import statement, CSS imports. Name matching is case-insensitive and accepts kebab-case (`data-table`) or PascalCase (`DataTable`) |
685
- | `haze_get_tokens` | The `--haze-*` token registry with light and dark values, optionally filtered by category (`color`, `typography`, `spacing`, `radius`, `shadow`) |
686
- | `haze_search_docs` | Top-10 ranked matches across component names, css families, prop names and descriptions |
687
-
688
- **Claude Code**:
689
-
690
- ```sh
691
- claude mcp add haze-ui -- npx haze-ui-mcp
692
- ```
693
-
694
- **Cursor** (`.cursor/mcp.json`):
695
-
696
- ```json
697
- {
698
- "mcpServers": {
699
- "haze-ui": {
700
- "command": "npx",
701
- "args": ["haze-ui-mcp"]
702
- }
703
- }
704
- }
705
- ```
706
-
707
- The server speaks stdio JSON-RPC and ships its own docs snapshot
708
- (`dist/mcp-docs.json`, regenerated on every build by
709
- `scripts/generate-mcp-docs.mjs`) — no project access, no indexing on startup.
710
- In this repository you can run it directly against the local snapshot:
711
-
712
- ```sh
713
- node scripts/generate-mcp-docs.mjs
714
- node mcp/index.mjs
715
- ```
716
-
717
- Point it at a custom snapshot with `HAZE_UI_MCP_DOCS=/path/to/mcp-docs.json`.
718
-
719
- ## AI runtime integration (Vercel AI SDK `useChat`)
720
-
721
- haze-ui ships the agent chat surface as **presentational** components —
722
- `ChatContainer` (scrolling), `ChatMessage` (bubbles), `StreamingText`
723
- (typewriter reveal), `ToolCallCard` (tool input/output), `ThinkingIndicator`
724
- (waiting dots), `ChatInput` (composer) — with no opinion about where
725
- messages come from. The Vercel AI SDK's `useChat` is the stateful half: it
726
- owns `messages` (a `UIMessage[]` of typed `parts`), `status`
727
- (`submitted | streaming | ready | error`) and the `sendMessage`/`stop`
728
- pair. One small adapter bridges them — pure functions over duck-typed
729
- shapes, so the `ai` package never enters your dependency tree through
730
- haze-ui:
731
-
732
- ```tsx
733
- import { useChat } from '@ai-sdk/react';
734
- import { DefaultChatTransport } from 'ai';
735
- import { Button, ChatContainer, ChatInput } from 'haze-ui';
736
-
737
- import { useHazeChat } from './ai-chat-adapter'; // the recipe module
738
-
739
- function SupportChat() {
740
- const chat = useChat({ transport: new DefaultChatTransport({ api: '/api/chat' }) });
741
- const { messages, busy, send, stop } = useHazeChat(chat);
742
-
743
- return (
744
- <div>
745
- <ChatContainer>{messages}</ChatContainer>
746
- <ChatInput disabled={busy} onSend={send} />
747
- {busy && <Button variant='outline' onClick={stop}>Stop</Button>}
748
- </div>
749
- );
750
- }
751
- ```
752
-
753
- The mapping rules: text parts → `ChatMessage` bubbles (`StreamingText`
754
- typewriter while a part streams), reasoning → `ThinkingIndicator` then a
755
- collapsible transcript, `tool-*` parts → `ToolCallCard` driven by v5
756
- `states` (with v4 `tool-call`/`tool-result` pairs folded into one card by
757
- `toolCallId`), sources → links; `status: submitted` → thinking dots before
758
- the first token, `error` → a system bubble with the transport error. The
759
- whole thing is one small, unit-tested module that never throws on odd
760
- payloads — unknown part types are skipped, so future SDK parts degrade
761
- instead of crashing.
762
-
763
- The full recipe — live runnable demo (same adapter over a mock streaming
764
- source, no network), the complete adapter source, the `useHazeChat` hook,
765
- per-part mapping table and known limits (attachments, branching, voice out
766
- of scope; `StreamingText` restart-on-grow caveat for char-by-char
767
- transports) — lives on the docs site:
768
- **<https://wmzy.github.io/haze-ui/recipes>**.
769
-
770
- ## Headless primitives
771
-
772
- The behavior layer the styled components are built on ships as its own
773
- subpath: `haze-ui/headless`. Component authors assembling their own panels
774
- get the same primitives Popover, DropdownMenu and Tooltip use internally —
775
- with the same version, no re-implementation:
776
-
777
- - `useFloating` — the three-tier floating engine (native `popover` + CSS
778
- anchor positioning → `popover` alone → JS fallback), plus
779
- `placeFloatingPanel`, `useFloatingPosition` and the placement/panel types
780
- - `Presence` — mount/unmount with exit animations
781
- - `useFocusScope` / `isTabbable` / `getTabbables` — focus trapping and
782
- tab-order queries
783
- - `computeFloatingPosition` — the flip/shift collision math as a pure
784
- function
785
-
786
- ```jsx
787
- import { useFloating, Presence } from 'haze-ui/headless';
788
- ```
789
-
790
- Stable public API — same semver commitment as the component library:
791
- breaking changes land only on a major version.
792
-
793
- ## react-f0rm Integration
794
-
795
- react-f0rm owns form field state, and its headless `useField` hook is
796
- the single binding layer — the same channel its built-in
797
- `Field`/`Checkbox`/`Select` components use. haze-ui contributes the
798
- views: controlled cores (`InputCore`, `SelectCore`, `SwitchCore`, `TextareaCore`,
799
- `TagInputCore`, `TransferCore`, `UploadCore`, ...) take the plain `{value, onChange}`
800
- pair with zero adapters, and `FormItem` wraps the hook's state in label,
801
- error and aria wiring. The sugar components (`Input`, `Select`, ...)
802
- keep their `ControlOrValue<T>` (`Control<T> | T`) API for standalone use outside forms.
803
-
804
- Beyond `FormItem`, the `haze-ui` barrel re-exports the whole form layer
805
- so consumers never import react-f0rm directly: `FormList` (array fields
806
- over `useFieldArray` — append/prepend/insert/remove/swap/move with
807
- stable row keys), `FormProvider`/`useFormContext` (`FormItem`/`FormList`
808
- fall back to context when the `form` prop is omitted), the imperative
809
- APIs (`setValue`, `getValue`, `reset`, `trigger`, `setFocus`,
810
- `setServerErrors`, `useWatch`, ...), and schema resolvers:
811
-
812
- ```jsx
813
- import { FormItem, FormList, zodResolver, standardSchemaResolver } from 'haze-ui';
814
- ```
815
-
816
- `zodResolver` adapts any zod schema (v4) as a field validator;
817
- `standardSchemaResolver` adapts any Standard Schema v1 implementation
818
- (zod, valibot, arktype, ...) the same way. Field controls forward refs
819
- to their focusable element and `FormItem` wires f0rm's `focusRef`
820
- channel, so `setFocus(form, 'name')` and a failed submit's
821
- first-error auto-focus land on the real input.
822
-
823
- ### useField: field → {value, onChange}
824
-
825
- ```jsx
826
- import { useForm, useField } from 'react-f0rm';
827
- import { InputCore } from 'haze-ui';
828
-
829
- function NameField({ form }) {
830
- const { value, onChange } = useField({ form, name: 'name' });
831
- return <InputCore value={value} onChange={onChange} />; // two-way bound
832
- }
42
+ export default { plugins: [react(), hazeCss()] };
833
43
  ```
834
44
 
835
- The hook subscribes to the field (sibling fields stay isolated), and
836
- `onChange` writes through react-f0rm's user-change channel — a write
837
- fires exactly the validation a user typing into the field would fire:
838
- the field's effective `mode` (a `FormItem`/`useField` per-field override
839
- included) and the form's `reValidateMode`. With the default
840
- `mode: 'onSubmit'` + `reValidateMode: 'onChange'`, typing through a bound
841
- core after a failed submit re-validates per keystroke and clears the
842
- error as soon as the value is valid — no blur, no resubmit. `onChange`
843
- accepts plain values only (controlled cores emit the next value, never
844
- functional updaters; read the previous value from `value` on the next
845
- render). `reset(form, newValues)` re-seeds every binding with no
846
- remounting.
847
-
848
- ### FormItem: label, errors and aria wiring
45
+ Then render your first component:
849
46
 
850
47
  ```jsx
851
- import { Form, useForm } from 'react-f0rm';
852
- import { FormItem, InputCore } from 'haze-ui';
48
+ import { lightTheme, Button } from 'haze-ui';
853
49
 
854
- function ProfileForm() {
855
- const form = useForm({ initialValues: { email: '' } });
50
+ export function App() {
856
51
  return (
857
- <Form form={form} onSubmit={...}>
858
- <FormItem
859
- form={form}
860
- name="email"
861
- label="Email"
862
- validate={(v) => (v.includes('@') ? undefined : 'must be an email')}
863
- >
864
- {({ id, errorId, invalid, value, onChange }) => (
865
- <InputCore id={id} value={value} onChange={onChange} aria-invalid={invalid} aria-describedby={errorId} />
866
- )}
867
- </FormItem>
868
- </Form>
52
+ <main className={lightTheme}>
53
+ <Button onClick={() => console.log('haze!')}>Start</Button>
54
+ </main>
869
55
  );
870
56
  }
871
57
  ```
872
58
 
873
- `FormItem` generates the field/error ids, renders `<label htmlFor>`, and
874
- surfaces the first error in a `role="alert"` element — no manual
875
- `FieldError` wiring.
876
-
877
- #### `as`: declarative binding (react-f0rm Field-style)
878
-
879
- Skip the render-prop: pass any component as `as` and `FormItem` wires the
880
- id, aria attributes, `onBlur` and `onChange` itself — the same props shape
881
- as react-f0rm's `Field`, not Radix's `asChild`. `as` and the children
882
- render-prop are mutually exclusive.
883
-
884
- ```jsx
885
- // text field: nothing else to wire
886
- <FormItem form={form} name="email" label="Email" as={InputCore} />
887
-
888
- // checkbox-style control: value lives in `checked`
889
- <FormItem
890
- form={form}
891
- name="subscribed"
892
- label="Subscribe"
893
- as={CheckboxCore}
894
- valueToProps={(checked) => ({ checked: !!checked })}
895
- />
896
-
897
- // DOM-element-shaped control: adapt event and value in one line each
898
- <FormItem
899
- form={form}
900
- name="email"
901
- as={NativeInput}
902
- eventToValue={(e) => e.target.value}
903
- renderError={(error, id) => <em id={id}>{error}</em>}
904
- />
905
- ```
906
-
907
- - `eventToValue` defaults to identity — haze cores' `onChange` emits the
908
- next plain value; pass `(e) => e.target.value` when `as` is a raw DOM
909
- element component.
910
- - `asProps` spreads extra props onto the control before the value props,
911
- so `value`/`valueToProps` win conflicts (Field.tsx precedence).
912
- - `renderError(error, id)` replaces the built-in error span's content;
913
- the span itself (`id`, `role="alert"`, styling) stays FormItem's.
914
- - With a typed form, `validate`'s value argument is the field's actual
915
- type (`PathValueOf<TValues, P>`), not `any`.
916
-
917
- #### `input`: declarative binding for cores and raw DOM controls (typed prop forwarding)
918
-
919
- The ergonomic form for the controlled cores — pass the component and the
920
- rest of the JSX goes straight to it, type-checked against its own props:
921
-
922
- ```jsx
923
- <FormItem
924
- form={form}
925
- name="email"
926
- label="Email"
927
- input={InputCore}
928
- placeholder="you@x.dev"
929
- mode="onBlur"
930
- validate={(v) => (v.includes('@') ? undefined : 'must be an email')}
931
- />
932
-
933
- // JSX children forward too — a SelectCore's options:
934
- <FormItem form={form} name="role" label="Role" input={SelectCore}>
935
- <option value="admin">Admin</option>
936
- <option value="viewer">Viewer</option>
937
- </FormItem>
938
-
939
- // checkbox-style controls keep the valueToProps adapter:
940
- <FormItem
941
- form={form}
942
- name="subscribed"
943
- label="Subscribe"
944
- input={CheckboxCore}
945
- valueToProps={(checked) => ({ checked })}
946
- />
947
- ```
948
-
949
- `input` wires the same id/aria/`onBlur`/`onChange`/value contract as `as`
950
- — every haze core (`InputCore`, `TextareaCore`, `SelectCore`,
951
- `TagInputCore`, `TransferCore`, `UploadCore`, `CheckboxCore`, `SwitchCore`, …)
952
- speaks the plain `{value, onChange}` pair, so the default adapters need
953
- nothing (`TagInputCore`'s `onChange` already emits the next `string[]`;
954
- `TransferCore` emits the next `string[]` plus move metadata, `UploadCore`
955
- the next `File[]`; a checkbox-style core pairs with `valueToProps`). The
956
- differences from `as`:
957
-
958
- - Forwarded props are **type-checked against the core's own props** —
959
- `input={InputCore} size="xl"` is a compile error, while `asProps` is an
960
- untyped bag.
961
- - JSX **children** forward to the core (a `SelectCore`'s `<option>`s);
962
- the render-prop children and `input` are mutually exclusive (a
963
- render-prop next to `input` throws — it's a migration leftover).
964
- - The wiring (`id`, `aria-invalid`, `aria-describedby`, `onBlur`,
965
- `onChange`, `value`/`checked`) and FormItem's own prop names are
966
- **reserved**: they are excluded from the forwarded type and always win
967
- at runtime. A control prop that collides with one (e.g. CheckboxCore's
968
- own `label`) is unreachable through `input` — use the render-prop or
969
- `as`/`asProps` for it.
970
-
971
- `input` also takes raw DOM bindings — no core required. The two raw
972
- forms are explicit about their `eventToValue` adapter, so the value
973
- channel is never guessed:
974
-
975
- ```jsx
976
- // a native form element: the binding pairs the tag with its adapter,
977
- // and the rest of the JSX is type-checked against that element's own
978
- // HTML attributes (rows on a textarea, options as a select's children)
979
- <FormItem
980
- form={form}
981
- name="bio"
982
- label="Bio"
983
- input={{element: 'textarea', eventToValue: (e) => e.target.value}}
984
- rows={4}
985
- />
986
-
987
- // a DOM-element-shaped component: the top-level eventToValue is the
988
- // explicit opt-in from plain-value (core) to event-emitting (raw)
989
- <FormItem
990
- form={form}
991
- name="email"
992
- label="Email"
993
- input={NativeInput}
994
- eventToValue={(e) => e.target.value}
995
- />
996
- ```
997
-
998
- - The element binding accepts `'input' | 'textarea' | 'select'` and
999
- **requires** its `eventToValue` — `input={{element: 'input'}}` without
1000
- the adapter is a compile error (at runtime an untyped caller that
1001
- skips it still gets `e.target.value`, the DOM contract, never an Event
1002
- in the store).
1003
- - The top-level `eventToValue` next to a component `input` switches that
1004
- binding to raw semantics, mirroring the `as` channel; forwarded props
1005
- still check against the component's own props.
1006
- - The same reserved-prop rule applies: `id`, `onBlur`, `onChange`,
1007
- `value`/`checked`, aria-*, and FormItem's own names are never
1008
- forwarded on the raw channel either.
1009
-
1010
- #### `mode`: per-field validation timing (react-f0rm ≥ 0.6)
1011
-
1012
- Pass `mode` to validate one field on its own schedule instead of the
1013
- form-wide validation mode — other fields are unaffected. It accepts
1014
- react-f0rm's `ValidationMode` values: `'onSubmit'` (default form
1015
- behavior), `'onBlur'`, `'onChange'`, `'onTouched'` or `'all'`. Omit it to
1016
- keep the form's mode.
1017
-
1018
- ```jsx
1019
- <FormItem
1020
- form={form}
1021
- name="email"
1022
- label="Email"
1023
- mode="onBlur"
1024
- validate={(v) => (v.includes('@') ? undefined : 'must be an email')}
1025
- >
1026
- {({ id, errorId, invalid, onBlur, value, onChange }) => (
1027
- <InputCore
1028
- id={id}
1029
- value={value}
1030
- onChange={onChange}
1031
- aria-invalid={invalid}
1032
- aria-describedby={errorId}
1033
- onBlur={onBlur}
1034
- />
1035
- )}
1036
- </FormItem>
1037
- ```
1038
-
1039
- With `mode="onBlur"` (and the binding's `onBlur` passed to the core, as
1040
- above) the email field is validated the moment it loses focus — no
1041
- submit needed. `mode` accepts `'onSubmit'`, `'onBlur'`, `'onChange'`,
1042
- `'onTouched'` or `'all'`; only this field's schedule changes, the rest
1043
- of the form keeps its own `mode`.
1044
-
1045
- #### `validateDebounce` / `delayError` / `rules` (react-f0rm ≥ 0.6)
1046
-
1047
- `FormItem` passes these field-level options straight through to
1048
- react-f0rm's `useField`:
1049
-
1050
- - `validateDebounce={300}` — debounce this field's validation kicks:
1051
- only the last kick inside the window runs the validator (e.g. keeps a
1052
- per-keystroke async validator from firing while the user types fast).
1053
- While the timer is pending the field counts as validating, so
1054
- `trigger`/submit wait it out.
1055
- - `delayError={500}` — delay *showing* a newly appearing error in the
1056
- rendered error span (and the binding's `invalid`/`errors`). The form's
1057
- error state stays immediate — submit and `getError` still gate on it.
1058
- An error that clears inside the window never shows.
1059
- - `rules={{ required: 'Email is required', minLength: 4, pattern: { value: /@/, message: 'Must be an email' } }}`
1060
- — declarative constraints (a subset of react-hook-form's `register`
1061
- rules) compiled into a validator that runs *before* `validate`; both
1062
- sources' errors merge into the field's error list, rules errors ahead.
1063
-
1064
- ```jsx
1065
- <FormItem
1066
- form={form}
1067
- name="email"
1068
- label="Email"
1069
- validateDebounce={300}
1070
- delayError={500}
1071
- rules={{ required: 'Email is required' }}
1072
- validate={(v) => (v.includes('@') ? undefined : 'must be an email')}
1073
- >
1074
- {({ id, errorId, invalid, value, onChange }) => (
1075
- <InputCore id={id} value={value} onChange={onChange} aria-invalid={invalid} aria-describedby={errorId} />
1076
- )}
1077
- </FormItem>
1078
- ```
1079
-
1080
- All three are optional; omit them and the field behaves exactly as
1081
- before (immediate validation per the form's `mode`, immediate error
1082
- display, `validate`-only).
1083
-
1084
- ## Accessibility & RTL
1085
-
1086
- Accessibility is a baseline, not an opt-in: every component's test suite
1087
- includes axe checks, and the token sheet ships a global
1088
- `@media (prefers-reduced-motion: reduce)` block that collapses every
1089
- animation duration token to `0ms` — motion-sensitive users get instant
1090
- state changes with no per-component wiring.
1091
-
1092
- haze-ui supports RTL via CSS logical properties wherever a side is
1093
- semantic (the *start/end* of reading flow), not just decorative. Set
1094
- `dir="rtl"` (or `direction: rtl`) on an ancestor and those sides mirror
1095
- automatically — no component props change. The same goes for keyboard
1096
- semantics: horizontal arrow keys invert their meaning in RTL (Tabs,
1097
- Rating, Slider, Carousel, Calendar, Toolbar, Cascader, Tour and
1098
- horizontal menus), and floating placements are logical — a panel with
1099
- `placement="bottom"` in an RTL document lands on the mirrored inline
1100
- side without passing a mirrored placement. `src/lib/rtl.test.tsx` is a
1101
- four-layer contract suite (rendering + axe, keyboard mirroring, floating
1102
- mirroring, and a CSS-codemod guard with an explicit exemption list),
1103
- backed by `e2e/rtl.spec.ts` cross-engine checks.
1104
-
1105
- `LocaleProvider` takes an optional `direction` prop (`'ltr' | 'rtl'`,
1106
- explicit wins over derivation from the locale — Arabic, Hebrew, Farsi
1107
- and friends are derived automatically) and feeds `useDirection()`, the
1108
- hook components use internally. `getDirection(el)` (DOM resolution with
1109
- `[dir]` fallback) and `localeDirection(locale)` are exported from the
1110
- barrel alongside it, plus `createStrings(base, overrides)` — a
1111
- `DeepPartial<HazeStrings>` deep-merge for deriving locale packs without
1112
- forking a whole string table (see the *Adding a locale* recipe on the
1113
- docs site).
1114
-
1115
- ### Direction-responsive by construction (no physical CSS)
1116
-
1117
- - **Progress (bar)** — the fill is a normal-flow block with a percentage
1118
- `width`, so it grows from the inline-start edge (right in RTL) with no
1119
- `left`/`right` in its CSS.
1120
- - **Slider** — a native `<input type="range">`; the browser mirrors fill
1121
- and thumb under `dir="rtl"`.
1122
- - **Flex gap / `flex-direction: row`** — gap and row order follow the
1123
- writing mode, so icon-to-label spacing in Tag, ChatMessage, Alert, etc.
1124
- mirrors for free.
1125
- - **Carousel track scrolling** — uses `scrollIntoView({ inline: 'start' })`,
1126
- a logical scroll position.
1127
-
1128
- ### Fixed: physical → logical conversions
1129
-
1130
- | File | Change |
59
+ The theme class activates the token set — that is the whole setup. Prefer a
60
+ scaffolded start? `pnpm create haze-ui my-app` ships runnable Vite and
61
+ Next.js App Router templates.
62
+
63
+ Colors are generated in OKLCH with interaction states derived at runtime via
64
+ CSS relative colors, so the browser floor is Chrome/Edge 119+, Safari 16.4+
65
+ or Firefox 128+ (no HSL/hex fallbacks).
66
+
67
+ ## Highlights
68
+
69
+ - **Accessibility as a release gate** — an axe case in every component test
70
+ suite, Playwright e2e across Chromium/Firefox/WebKit plus a phone-viewport
71
+ project, pixel-locked visual baselines, and a real VoiceOver smoke suite on
72
+ macOS CI.
73
+ → [Accessibility guide](https://wmzy.github.io/haze-ui/guides/a11y) ·
74
+ [A11y contracts](https://wmzy.github.io/haze-ui/guides/a11y-contracts)
75
+ - **Per-component CSS** — the JS entry imports no stylesheet. Load the full
76
+ `haze-ui/styles.css`, cherry-pick `haze-ui/css/*.css` subpaths, or let
77
+ [haze-ui-vite](https://www.npmjs.com/package/haze-ui-vite) inject the right
78
+ files per import through `haze-ui/css-manifest.json`.
79
+ → [Getting started](https://wmzy.github.io/haze-ui/getting-started)
80
+ - **`Control<T> | T` controllable state** — one state prop per component,
81
+ powered by [react-use-control](https://github.com/wmzy/react-use-control);
82
+ no `defaultXxx`/`xxx` dual API to keep in sync.
83
+ → [Getting started](https://wmzy.github.io/haze-ui/getting-started)
84
+ - **AI / chat component family** — `ChatContainer`, `ChatMessage`,
85
+ `StreamingText`, `ToolCallCard`, `ThinkingIndicator` and `ChatInput` cover
86
+ the agent chat surface, with a unit-tested adapter recipe for the Vercel AI
87
+ SDK's `useChat`.
88
+ → [AI showcase](https://wmzy.github.io/haze-ui/ai-showcase) ·
89
+ [Recipes](https://wmzy.github.io/haze-ui/recipes)
90
+ - **Multi-calendar date layer** — Gregorian plus six built-in adapters
91
+ (Hijri Umm al-Qura, Persian, Hebrew, Japanese era, Buddhist, Ethiopic):
92
+ integer-arithmetic implementations cross-verified against full-ICU `Intl`
93
+ oracles, exported from the barrel via `getAdapter()`.
94
+ → [Components](https://wmzy.github.io/haze-ui/components)
95
+ - **OKLCH tokens with W3C DTF export** — hover/active/focus-ring colors
96
+ re-derive at runtime from CSS relative-color formulas, and the whole token
97
+ registry exports to the W3C Design Tokens format for Figma / Tokens Studio.
98
+ → [Tokens](https://wmzy.github.io/haze-ui/tokens) ·
99
+ [Theme editor](https://wmzy.github.io/haze-ui/theme-editor)
100
+
101
+ ## Ecosystem
102
+
103
+ | Resource | Link |
1131
104
  | --- | --- |
1132
- | `Carousel.tsx` | prev/next buttons `left`/`right` → `inset-inline-start`/`inset-inline-end`, plus a `[dir='rtl']` `scale: -1 1` mirror so the `‹`/`›` glyphs point along the reading direction |
1133
- | `ChatMessage.tsx` | bubble tail corners `border-bottom-right-radius` / `border-bottom-left-radius` → `border-end-end-radius` / `border-end-start-radius` (tail follows the bubble's anchored side) |
1134
- | `Chip.tsx` | close-button `margin-left` → `margin-inline-start` |
1135
- | `Container.tsx` | `margin-left/right: auto` → `margin-inline: auto`; `padding-left/right` → `padding-inline` |
1136
- | `ContextMenuItem.tsx`, `DropdownMenuItem.tsx`, `MenuItem.tsx`, `ConversationItem.tsx` | `text-align: left` → `text-align: start` |
1137
- | `DiffViewer.tsx` | line-number gutter `text-align: right` → `end`, `border-right` → `border-inline-end` (gutter stays on the leading side) |
1138
- | `List.tsx` | `padding-left` → `padding-inline-start` (list indent), both variants |
1139
- | `NavigationBar.tsx` | end-slot `margin-left: auto` → `margin-inline-start: auto` |
1140
- | `StepTimeline.tsx` | connector line `left` → `inset-inline-start` (stays under the inline-start marker column) |
1141
- | `Stepper/Step.tsx` | connector `left: 50%` → `inset-inline-start: 50%` (extends toward the next step) |
1142
- | `TableHead.tsx` | `th { text-align: left }` → `start` |
1143
- | `TreeItem.tsx` | checkbox/icon `margin-right` → `margin-inline-end`; indent guide `border-left` → `border-inline-start` |
1144
-
1145
- Kept physical on purpose — glyph geometry or symmetric layout, not
1146
- reading-flow sides: the rotated border chevrons in Accordion/Disclosure,
1147
- Checkbox's rotated checkmark, Radio's centered dot, Affix's symmetric
1148
- `left: 0; right: 0` stretch.
1149
-
1150
- ### Partial support: known gaps
1151
-
1152
- - **Floating panels (Popover, DropdownMenu, Tooltip, ContextMenu,
1153
- Combobox, Datepicker)** — *fixed*: `FloatingPlacement` is logical now.
1154
- Tier 1 uses logical `position-area` values (`span-inline-end
1155
- block-end`, …) where engines accept them and falls back to
1156
- dir-switched physical classes; the tier-2 JS math
1157
- (`utils/collision.ts` → `computeFloatingPosition(..., { dir })`)
1158
- mirrors the x-axis. In RTL the panels open on the mirrored side
1159
- automatically.
1160
- - **Input adornments** — `SelectCore` / `ModelPicker` chevrons
1161
- (`background-position: right …` + `padding-right`) and
1162
- `PasswordInputCore`'s absolute reveal button sit on the physical right
1163
- with matching padding. Consistent under RTL, but not mirrored.
1164
- - **Progress (circle)** — SVG `stroke-dashoffset` fill runs clockwise
1165
- regardless of direction (SVG has no inline axis).
1166
- - **Physical-by-design placement APIs** — `Drawer` `placement`
1167
- (`'left'`/`'right'`), `Toast` placement (`'top-left'`, …),
1168
- `SwipeAction` left/right action edges, `BackToTop`'s bottom-right
1169
- corner, `CodeBlock`'s top-right language badge: the side is the API,
1170
- so it stays physical.
1171
-
1172
- ### Workaround for remaining gaps
1173
-
1174
- Wrap with `dir="rtl"` for everything above; for the physical-by-design
1175
- cases, override with logical insets through the `className` every
1176
- component accepts:
1177
-
1178
- ```jsx
1179
- <Drawer placement="right" className="rtl-drawer" />
1180
- ```
105
+ | Documentation | <https://wmzy.github.io/haze-ui/> |
106
+ | Getting started | <https://wmzy.github.io/haze-ui/getting-started> |
107
+ | Migration guide (AntD / shadcn) | <https://wmzy.github.io/haze-ui/guides/migration> |
108
+ | llms.txt / full API for AI readers | <https://wmzy.github.io/haze-ui/llms.txt> |
109
+ | MCP server (`haze-ui-mcp`) | <https://www.npmjs.com/package/haze-ui-mcp> |
110
+ | shadcn registry | [registry/README.md](./registry/README.md) — `pnpm dlx shadcn@latest add wmzy/haze-ui/button` |
111
+ | create-haze-ui (CLI scaffolding) | <https://www.npmjs.com/package/create-haze-ui> |
112
+ | haze-ui-vite (CSS auto-injection) | <https://www.npmjs.com/package/haze-ui-vite> |
1181
113
 
1182
- ```css
1183
- [dir='rtl'] .rtl-drawer {
1184
- /* nudge a physical placement back to the reading-flow side */
1185
- inset-inline-end: 0;
1186
- }
1187
- ```
1188
-
1189
- ## Related Projects
114
+ Every styled export is also installable through the shadcn CLI as a thin
115
+ wrapper over the npm package — see [registry/README.md](./registry/README.md).
1190
116
 
1191
- - [react-use-control](https://github.com/wmzy/react-use-control)
117
+ ## Community
1192
118
 
1193
- ## How to Contribute
119
+ - 🐛 [Report a bug](https://github.com/wmzy/haze-ui/issues/new?template=bug_report.yml)
120
+ - ✨ [Request a feature](https://github.com/wmzy/haze-ui/issues/new?template=feature_request.yml)
121
+ - 📖 [Contribute to the project](./CONTRIBUTING.md)
122
+ - 🔒 [Security policy](./SECURITY.md)
1194
123
 
1195
- Anyone and everyone is welcome to contribute.
124
+ **Building with haze-ui?** We would love to feature you. Open an issue
125
+ titled `used by: <your project>` with the project name, a link and (optionally)
126
+ a logo, and it will be added to this README.
1196
127
 
1197
128
  ## License
1198
129
 
1199
130
  [MIT](https://choosealicense.com/licenses/mit/)
1200
-
1201
- ## FAQ
1202
-
1203
- ### Components render without any styles
1204
-
1205
- haze-ui ships styles as separate CSS subpaths — the JS entry does not
1206
- import any stylesheet. Import the full bundle once:
1207
-
1208
- ```js
1209
- import 'haze-ui/styles.css';
1210
- ```
1211
-
1212
- or import the tokens plus each component's own rules:
1213
-
1214
- ```js
1215
- import 'haze-ui/css/tokens.css';
1216
- import 'haze-ui/css/button.css';
1217
- ```
1218
-
1219
- ### Is there a CommonJS build?
1220
-
1221
- No — haze-ui is ESM-only (`"type": "module"`). Use a bundler or runtime
1222
- with ESM support (Vite, webpack 5, Next.js, Node ≥ 18, …).
1223
-
1224
- ### Does haze-ui support React 18?
1225
-
1226
- No — the peer range is `react@^19.0.0` by design (see
1227
- [React 19+ by design](#react-19-by-design)). Upgrade to React 19 first;
1228
- haze-ui ships no React 18 compatibility layer.
1229
-
1230
- ### How do I detect (or degrade) the relative-color syntax?
1231
-
1232
- Since v1.13, theme interaction states are derived with CSS relative
1233
- colors, so the browser baseline from [Browser support](#browser-support)
1234
- applies — Chrome/Edge 119+, Safari 16.4+, Firefox 128+, with no HSL/hex
1235
- fallbacks shipped. To gate your own fallback styling on support, probe
1236
- the syntax in CSS:
1237
-
1238
- ```css
1239
- @supports (color: oklch(from red calc(l + 0.05) 0 h)) {
1240
- /* relative colors available: derive custom states from tokens */
1241
- }
1242
- ```
1243
-
1244
- or from JS before deciding which stylesheet to load:
1245
-
1246
- ```js
1247
- if (CSS.supports('color: oklch(from red calc(l + 0.05) 0 h)')) {
1248
- // relative colors available — load the v1.13+ token sheet
1249
- } else {
1250
- // below baseline: pin a pre-OKLCH version or ship your own fallbacks
1251
- }
1252
- ```
1253
-