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.
- package/README.md +87 -1210
- package/dist/coi-serviceworker.LICENSE +21 -0
- package/dist/coi-serviceworker.js +118 -0
- package/dist/components/Countdown/Countdown.js +49 -0
- package/dist/components/Countdown/index.js +3 -0
- package/dist/components/HoverCard/HoverCard.js +63 -43
- package/dist/components/ImageCrop/ImageCrop.js +188 -0
- package/dist/components/ImageCrop/image-crop-geometry.js +81 -0
- package/dist/components/ImageCrop/index.js +3 -0
- package/dist/components/LocaleProvider/ar-sa.js +6 -0
- package/dist/components/LocaleProvider/de-de.js +6 -0
- package/dist/components/LocaleProvider/es-es.js +6 -0
- package/dist/components/LocaleProvider/fr-fr.js +6 -0
- package/dist/components/LocaleProvider/it-it.js +6 -0
- package/dist/components/LocaleProvider/ja-jp.js +6 -0
- package/dist/components/LocaleProvider/ko-kr.js +6 -0
- package/dist/components/LocaleProvider/locale.js +6 -0
- package/dist/components/LocaleProvider/pt-br.js +6 -0
- package/dist/components/LocaleProvider/ru-ru.js +6 -0
- package/dist/components/LocaleProvider/zh-cn.js +6 -0
- package/dist/components/Resizable/index.js +1 -1
- package/dist/components/Splitter/Splitter.js +199 -0
- package/dist/components/Splitter/index.js +3 -0
- package/dist/css/countdown.css +4 -0
- package/dist/css/image-crop.css +4 -0
- package/dist/css/splitter.css +4 -0
- package/dist/css-manifest.json +4 -3
- package/dist/haze-ui.css +12 -0
- package/dist/hooks/useHotkeys.js +78 -24
- package/dist/index.js +277 -274
- package/dist/mcp-docs.json +210 -9
- package/dist/registry.json +71 -5
- package/dist/robots.txt +4 -0
- package/dist/sitemap.xml +160 -0
- package/dist/types/components/ConfigProvider/ConfigContext.d.ts +2 -1
- package/dist/types/components/Countdown/Countdown.d.ts +30 -0
- package/dist/types/components/Countdown/index.d.ts +2 -0
- package/dist/types/components/HoverCard/HoverCard.d.ts +18 -1
- package/dist/types/components/ImageCrop/ImageCrop.d.ts +67 -0
- package/dist/types/components/ImageCrop/image-crop-geometry.d.ts +65 -0
- package/dist/types/components/ImageCrop/index.d.ts +3 -0
- package/dist/types/components/LocaleProvider/locale.d.ts +6 -0
- package/dist/types/components/Resizable/index.d.ts +0 -2
- package/dist/types/components/Splitter/Splitter.d.ts +33 -0
- package/dist/types/components/Splitter/index.d.ts +2 -0
- package/dist/types/headless/index.d.ts +7 -6
- package/dist/types/hooks/useHotkeys.d.ts +16 -4
- package/dist/types/index.d.ts +7 -2
- package/package.json +23 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Haze UI
|
|
2
2
|
|
|
3
|
-
> The React UI
|
|
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
|
[](https://www.npmjs.com/package/haze-ui)
|
|
9
9
|
[](https://github.com/wmzy/haze-ui/actions/workflows/ci.yml)
|
|
10
10
|
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
+
# or
|
|
61
27
|
pnpm add haze-ui
|
|
62
28
|
```
|
|
63
29
|
|
|
64
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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 {
|
|
852
|
-
import { FormItem, InputCore } from 'haze-ui';
|
|
48
|
+
import { lightTheme, Button } from 'haze-ui';
|
|
853
49
|
|
|
854
|
-
function
|
|
855
|
-
const form = useForm({ initialValues: { email: '' } });
|
|
50
|
+
export function App() {
|
|
856
51
|
return (
|
|
857
|
-
<
|
|
858
|
-
<
|
|
859
|
-
|
|
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
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
-
|
|
|
1133
|
-
|
|
|
1134
|
-
|
|
|
1135
|
-
|
|
|
1136
|
-
|
|
|
1137
|
-
|
|
|
1138
|
-
|
|
|
1139
|
-
|
|
|
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
|
-
|
|
1183
|
-
[
|
|
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
|
-
|
|
117
|
+
## Community
|
|
1192
118
|
|
|
1193
|
-
|
|
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
|
-
|
|
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
|
-
|