hazo_ui 2.9.0 → 2.16.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/CHANGE_LOG.md +500 -0
- package/README.md +539 -0
- package/SETUP_CHECKLIST.md +418 -0
- package/dist/index.cjs +2522 -499
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +534 -1
- package/dist/index.d.ts +534 -1
- package/dist/index.js +2461 -468
- package/dist/index.js.map +1 -1
- package/dist/styles.css +40 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -202,6 +202,24 @@ The following components support both global config and prop-level color overrid
|
|
|
202
202
|
|
|
203
203
|
- **[HazoUiConfirmDialog](#hazouiconfirmdialog)** - A compact, opinionated confirmation dialog with accent top border, variant system (destructive, warning, info, success), async loading support, and configurable buttons. Perfect for delete confirmations, unsaved changes warnings, and simple acknowledgments.
|
|
204
204
|
|
|
205
|
+
- **[HazoUiTable](#hazouitable--column-config-driven-data-table-v2140)** - A column-config-driven data table built on a shadcn `Table` primitive family. Sortable headers, debounced search, multi-column filter / sort dialogs, pagination, row click (mouse + keyboard), loading / empty / no-results states, and a card-per-row mobile fallback. Optional server-side `onLoad`.
|
|
206
|
+
|
|
207
|
+
- **[Drawer](#drawer)** - A `vaul`-backed bottom sheet primitive for mobile UIs. Pair with `useMediaQuery` to swap between `Dialog` and `Drawer` based on viewport width.
|
|
208
|
+
|
|
209
|
+
### State Primitives (v2.10.0)
|
|
210
|
+
|
|
211
|
+
Lightweight, opinionated components for the four ubiquitous async states: **loading**, **empty**, **error**, and **success**.
|
|
212
|
+
|
|
213
|
+
- **[Skeleton](#skeleton)** family (`Skeleton`, `SkeletonCircle`, `SkeletonBar`, `SkeletonRect`, `SkeletonGroup`) - Shimmer placeholders. Respects `prefers-reduced-motion`.
|
|
214
|
+
- **[EmptyState](#emptystate)** - Icon + title + description + CTA for empty lists, search misses, no-data screens.
|
|
215
|
+
- **[ErrorBanner](#errorbanner)** - Inline `warning` or `error` strip with optional title, action button, and dismiss.
|
|
216
|
+
- **[ErrorPage](#errorpage)** - Full-page error fallback with title, description, error code tag, correlation id, and CTAs.
|
|
217
|
+
- **[LoadingTimeout](#loadingtimeout)** - Wraps a loading region and escalates messaging at 5s / 15s / 30s thresholds before showing a retry banner.
|
|
218
|
+
- **[ProgressiveImage](#progressiveimage)** - Three-stage image render: grey placeholder → blurred LQIP → sharp final image.
|
|
219
|
+
- **[HazoUiToaster + toast helpers](#toasts-hazouitoaster--successtoast--errortoast)** - `sonner`-backed toaster with `successToast()` and `errorToast()` imperative helpers.
|
|
220
|
+
- **[useLoadingState](#useloadingstate)** hook - `{ isLoading, setLoading, withLoading }` with an async wrapper.
|
|
221
|
+
- **[useErrorDisplay](#useerrordisplay)** hook - Passive `{ error, setError, clearError }` that coerces `Error` instances to strings.
|
|
222
|
+
|
|
205
223
|
### shadcn/ui Primitive Re-exports
|
|
206
224
|
|
|
207
225
|
All shadcn/ui base components are re-exported from hazo_ui, so sibling hazo_* packages (and consumers) can import UI primitives from a single source without installing shadcn/ui separately:
|
|
@@ -3022,6 +3040,527 @@ function Example() {
|
|
|
3022
3040
|
|
|
3023
3041
|
---
|
|
3024
3042
|
|
|
3043
|
+
## State Primitives
|
|
3044
|
+
|
|
3045
|
+
Components and hooks for the four ubiquitous async states. All are exported flat (unprefixed) from the package root — `import { Skeleton, EmptyState, ErrorBanner, ErrorPage, LoadingTimeout, ProgressiveImage, HazoUiToaster, successToast, errorToast, useLoadingState, useErrorDisplay } from "hazo_ui"`.
|
|
3046
|
+
|
|
3047
|
+
### Skeleton
|
|
3048
|
+
|
|
3049
|
+
Shimmer placeholders for loading content. The shimmer animation respects `prefers-reduced-motion` (renders as a static grey block instead).
|
|
3050
|
+
|
|
3051
|
+
```tsx
|
|
3052
|
+
import { Skeleton, SkeletonCircle, SkeletonBar, SkeletonRect, SkeletonGroup } from "hazo_ui";
|
|
3053
|
+
|
|
3054
|
+
<SkeletonGroup label="Loading user profile">
|
|
3055
|
+
<div className="flex items-center gap-3">
|
|
3056
|
+
<SkeletonCircle size={40} />
|
|
3057
|
+
<div className="flex-1 space-y-2">
|
|
3058
|
+
<SkeletonBar width="60%" height={14} />
|
|
3059
|
+
<SkeletonBar width="40%" height={10} />
|
|
3060
|
+
</div>
|
|
3061
|
+
</div>
|
|
3062
|
+
<SkeletonRect height={120} radius={8} />
|
|
3063
|
+
</SkeletonGroup>
|
|
3064
|
+
```
|
|
3065
|
+
|
|
3066
|
+
| Variant | Props |
|
|
3067
|
+
|---|---|
|
|
3068
|
+
| `Skeleton` | All standard `<div>` props. Base shimmer block. |
|
|
3069
|
+
| `SkeletonCircle` | `size?: number` (default 40), `className?: string` |
|
|
3070
|
+
| `SkeletonBar` | `width?: number \| string` (default "100%"), `height?: number` (default 12), `className?: string` |
|
|
3071
|
+
| `SkeletonRect` | `width?`, `height?`, `radius?: number \| string` (default 6), `className?: string` |
|
|
3072
|
+
| `SkeletonGroup` | `label?: string` (default "Loading content"), `children: ReactNode` — wraps a region with `role="status"` + `aria-busy` + visually-hidden label. |
|
|
3073
|
+
|
|
3074
|
+
### EmptyState
|
|
3075
|
+
|
|
3076
|
+
Standardized empty-list / no-data / no-results display.
|
|
3077
|
+
|
|
3078
|
+
```tsx
|
|
3079
|
+
import { EmptyState, Button } from "hazo_ui";
|
|
3080
|
+
import { InboxIcon } from "lucide-react";
|
|
3081
|
+
|
|
3082
|
+
<EmptyState
|
|
3083
|
+
icon={<InboxIcon />}
|
|
3084
|
+
title="No messages yet"
|
|
3085
|
+
description="When you receive a message, it'll show up here."
|
|
3086
|
+
action={<Button onClick={onCompose}>Send your first message</Button>}
|
|
3087
|
+
size="md"
|
|
3088
|
+
/>
|
|
3089
|
+
```
|
|
3090
|
+
|
|
3091
|
+
| Prop | Type | Default | Description |
|
|
3092
|
+
|---|---|---|---|
|
|
3093
|
+
| `title` | `string` | **required** | Main heading |
|
|
3094
|
+
| `icon` | `ReactNode` | — | Icon element (recommended 48×48) |
|
|
3095
|
+
| `description` | `ReactNode` | — | Secondary description |
|
|
3096
|
+
| `action` | `ReactNode` | — | CTA region (typically a `<Button>`) |
|
|
3097
|
+
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Visual size for inline cards (`sm`) vs full pages (`lg`) |
|
|
3098
|
+
| `className` | `string` | — | Additional classes |
|
|
3099
|
+
|
|
3100
|
+
### ErrorBanner
|
|
3101
|
+
|
|
3102
|
+
Inline error or warning strip. `role="alert"`, with `aria-live="assertive"` for errors / `"polite"` for warnings.
|
|
3103
|
+
|
|
3104
|
+
```tsx
|
|
3105
|
+
import { ErrorBanner, Button } from "hazo_ui";
|
|
3106
|
+
|
|
3107
|
+
<ErrorBanner
|
|
3108
|
+
severity="error"
|
|
3109
|
+
title="Couldn't save your changes"
|
|
3110
|
+
message="We hit a network error. Your draft is safe locally."
|
|
3111
|
+
action={<Button size="sm" onClick={onRetry}>Retry</Button>}
|
|
3112
|
+
onDismiss={() => setBannerVisible(false)}
|
|
3113
|
+
/>
|
|
3114
|
+
|
|
3115
|
+
<ErrorBanner severity="warning" message="Your session expires in 2 minutes." />
|
|
3116
|
+
```
|
|
3117
|
+
|
|
3118
|
+
| Prop | Type | Default | Description |
|
|
3119
|
+
|---|---|---|---|
|
|
3120
|
+
| `message` | `ReactNode` | **required** | Body text |
|
|
3121
|
+
| `severity` | `"warning" \| "error"` | `"error"` | Drives colour & icon |
|
|
3122
|
+
| `title` | `string` | — | Bold heading above the message |
|
|
3123
|
+
| `icon` | `ReactNode` | auto | Override the auto-selected `AlertTriangle` / `OctagonAlert` |
|
|
3124
|
+
| `action` | `ReactNode` | — | CTA region (typically a `<Button>`) |
|
|
3125
|
+
| `onDismiss` | `() => void` | — | When provided, renders a dismiss X button |
|
|
3126
|
+
| `className` | `string` | — | Additional classes |
|
|
3127
|
+
|
|
3128
|
+
### ErrorPage
|
|
3129
|
+
|
|
3130
|
+
Full-page error fallback, ideal for route-level error boundaries.
|
|
3131
|
+
|
|
3132
|
+
```tsx
|
|
3133
|
+
import { ErrorPage, Button } from "hazo_ui";
|
|
3134
|
+
|
|
3135
|
+
<ErrorPage
|
|
3136
|
+
title="Something went wrong"
|
|
3137
|
+
description="We couldn't load this page. The issue has been reported."
|
|
3138
|
+
errorCode="500"
|
|
3139
|
+
correlationId="req_a8f3c12e4d"
|
|
3140
|
+
actions={
|
|
3141
|
+
<>
|
|
3142
|
+
<Button onClick={onRetry}>Try again</Button>
|
|
3143
|
+
<Button variant="outline" onClick={onGoHome}>Go home</Button>
|
|
3144
|
+
</>
|
|
3145
|
+
}
|
|
3146
|
+
/>
|
|
3147
|
+
```
|
|
3148
|
+
|
|
3149
|
+
| Prop | Type | Default | Description |
|
|
3150
|
+
|---|---|---|---|
|
|
3151
|
+
| `title` | `string` | `"Something went wrong"` | Main heading |
|
|
3152
|
+
| `description` | `ReactNode` | — | Explanation paragraph(s) |
|
|
3153
|
+
| `errorCode` | `string` | — | Short symbolic code rendered as a tag (`"500"`, `"NOT_FOUND"`) |
|
|
3154
|
+
| `correlationId` | `string` | — | Correlation id (typically from `hazo_logs`) — rendered in a copyable mono block |
|
|
3155
|
+
| `actions` | `ReactNode` | — | CTA region |
|
|
3156
|
+
| `illustration` | `ReactNode` | `<OctagonAlert />` | Override the default icon |
|
|
3157
|
+
| `className` | `string` | — | Additional classes |
|
|
3158
|
+
|
|
3159
|
+
### LoadingTimeout
|
|
3160
|
+
|
|
3161
|
+
Wraps a loading region and escalates messaging if the load takes too long. Four phases:
|
|
3162
|
+
|
|
3163
|
+
| Phase | When | What renders |
|
|
3164
|
+
|---|---|---|
|
|
3165
|
+
| `silent` | 0 – 5s | The provided `skeleton` (no message) |
|
|
3166
|
+
| `gentle` | 5s – 15s | Skeleton + "Loading {label}…" |
|
|
3167
|
+
| `firm` | 15s – 30s | Skeleton + "Still working on it — almost there." |
|
|
3168
|
+
| `expired` | 30s+ | `<ErrorBanner severity="error">` with a "Try again" button |
|
|
3169
|
+
|
|
3170
|
+
```tsx
|
|
3171
|
+
import { LoadingTimeout, SkeletonGroup, SkeletonBar } from "hazo_ui";
|
|
3172
|
+
|
|
3173
|
+
<LoadingTimeout
|
|
3174
|
+
active={isLoading}
|
|
3175
|
+
label="dashboard"
|
|
3176
|
+
onRetry={refetch}
|
|
3177
|
+
skeleton={
|
|
3178
|
+
<SkeletonGroup>
|
|
3179
|
+
<SkeletonBar width="80%" />
|
|
3180
|
+
<SkeletonBar width="60%" />
|
|
3181
|
+
</SkeletonGroup>
|
|
3182
|
+
}
|
|
3183
|
+
>
|
|
3184
|
+
<Dashboard data={data} />
|
|
3185
|
+
</LoadingTimeout>
|
|
3186
|
+
```
|
|
3187
|
+
|
|
3188
|
+
| Prop | Type | Default | Description |
|
|
3189
|
+
|---|---|---|---|
|
|
3190
|
+
| `active` | `boolean` | **required** | When `true`, runs the timeout escalation; when `false`, renders `children` |
|
|
3191
|
+
| `children` | `ReactNode` | — | Content shown when `active` is `false` |
|
|
3192
|
+
| `skeleton` | `ReactNode` | — | Placeholder rendered during the `silent`/`gentle`/`firm` phases |
|
|
3193
|
+
| `onRetry` | `() => void` | — | Called when the user clicks Retry in the `expired` phase |
|
|
3194
|
+
| `thresholds` | `{ gentle?, firm?, expired? }` | `{5000, 15000, 30000}` | Override timeout thresholds (ms) |
|
|
3195
|
+
| `label` | `string` | `"content"` | Used in gentle/firm/expired messages |
|
|
3196
|
+
| `className` | `string` | — | Additional classes |
|
|
3197
|
+
|
|
3198
|
+
### ProgressiveImage
|
|
3199
|
+
|
|
3200
|
+
Three-stage image render: grey placeholder → blurred low-quality image (`lqip`) → sharp final image. Sets a stable container box so layout doesn't shift.
|
|
3201
|
+
|
|
3202
|
+
```tsx
|
|
3203
|
+
import { ProgressiveImage } from "hazo_ui";
|
|
3204
|
+
|
|
3205
|
+
<ProgressiveImage
|
|
3206
|
+
src="/photos/large.jpg"
|
|
3207
|
+
lqip="data:image/jpeg;base64,/9j/4AAQ…"
|
|
3208
|
+
alt="Sunset over the lake"
|
|
3209
|
+
width={400}
|
|
3210
|
+
height={300}
|
|
3211
|
+
fit="cover"
|
|
3212
|
+
loading="lazy"
|
|
3213
|
+
/>
|
|
3214
|
+
```
|
|
3215
|
+
|
|
3216
|
+
| Prop | Type | Default | Description |
|
|
3217
|
+
|---|---|---|---|
|
|
3218
|
+
| `src` | `string` | **required** | Final image src |
|
|
3219
|
+
| `alt` | `string` | **required** | Alt text (empty string allowed for decorative) |
|
|
3220
|
+
| `width` | `number \| string` | **required** | Container width (px or any CSS length) |
|
|
3221
|
+
| `height` | `number \| string` | **required** | Container height |
|
|
3222
|
+
| `lqip` | `string` | — | Low-quality placeholder (data URL or tiny image URL) |
|
|
3223
|
+
| `loading` | `"eager" \| "lazy"` | `"lazy"` | Native loading attribute |
|
|
3224
|
+
| `fit` | `"cover" \| "contain" \| "fill" \| "none" \| "scale-down"` | `"cover"` | Object-fit for the final image |
|
|
3225
|
+
| `onLoad` | `() => void` | — | Called when the final image loads |
|
|
3226
|
+
| `onError` | `() => void` | — | Called if the final image errors |
|
|
3227
|
+
| `className` | `string` | — | Additional classes |
|
|
3228
|
+
|
|
3229
|
+
### Toasts (HazoUiToaster + successToast / errorToast)
|
|
3230
|
+
|
|
3231
|
+
A `sonner`-backed toaster plus two opinionated imperative helpers.
|
|
3232
|
+
|
|
3233
|
+
**Mount the toaster once** near the root of your app:
|
|
3234
|
+
|
|
3235
|
+
```tsx
|
|
3236
|
+
import { HazoUiToaster } from "hazo_ui";
|
|
3237
|
+
|
|
3238
|
+
export default function RootLayout({ children }) {
|
|
3239
|
+
return (
|
|
3240
|
+
<>
|
|
3241
|
+
{children}
|
|
3242
|
+
<HazoUiToaster position="bottom-right" />
|
|
3243
|
+
</>
|
|
3244
|
+
);
|
|
3245
|
+
}
|
|
3246
|
+
```
|
|
3247
|
+
|
|
3248
|
+
**Fire toasts imperatively from anywhere:**
|
|
3249
|
+
|
|
3250
|
+
```tsx
|
|
3251
|
+
import { successToast, errorToast } from "hazo_ui";
|
|
3252
|
+
|
|
3253
|
+
await save();
|
|
3254
|
+
successToast({ title: "Saved", description: "Your changes have been published." });
|
|
3255
|
+
|
|
3256
|
+
try { await sync(); } catch (e) {
|
|
3257
|
+
errorToast({
|
|
3258
|
+
title: "Sync failed",
|
|
3259
|
+
description: "Check your connection and try again.",
|
|
3260
|
+
action: { label: "Retry", onClick: () => sync() },
|
|
3261
|
+
});
|
|
3262
|
+
}
|
|
3263
|
+
```
|
|
3264
|
+
|
|
3265
|
+
`HazoUiToaster` props:
|
|
3266
|
+
|
|
3267
|
+
| Prop | Type | Default |
|
|
3268
|
+
|---|---|---|
|
|
3269
|
+
| `position` | `"top-left" \| "top-right" \| "bottom-left" \| "bottom-right" \| "top-center" \| "bottom-center"` | `"bottom-right"` |
|
|
3270
|
+
| `closeButton` | `boolean` | `true` |
|
|
3271
|
+
| `visibleToasts` | `number` | `5` |
|
|
3272
|
+
|
|
3273
|
+
`ToastOptions` (for `successToast` / `errorToast`):
|
|
3274
|
+
|
|
3275
|
+
| Prop | Type | Default |
|
|
3276
|
+
|---|---|---|
|
|
3277
|
+
| `title` | `string` | **required** |
|
|
3278
|
+
| `description` | `string` | — |
|
|
3279
|
+
| `duration` | `number` (ms) | `3000` success / `5000` error |
|
|
3280
|
+
| `action` | `{ label: string; onClick: () => void }` | — |
|
|
3281
|
+
|
|
3282
|
+
Also exports `rawToast` (re-export of sonner's `toast`) for advanced use cases.
|
|
3283
|
+
|
|
3284
|
+
### useLoadingState
|
|
3285
|
+
|
|
3286
|
+
Hook that returns a controlled loading flag plus an async wrapper.
|
|
3287
|
+
|
|
3288
|
+
```tsx
|
|
3289
|
+
import { useLoadingState } from "hazo_ui";
|
|
3290
|
+
|
|
3291
|
+
const { isLoading, setLoading, withLoading } = useLoadingState(false);
|
|
3292
|
+
|
|
3293
|
+
async function onSubmit() {
|
|
3294
|
+
await withLoading(async () => {
|
|
3295
|
+
await api.save(data);
|
|
3296
|
+
});
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
return <Button disabled={isLoading} onClick={onSubmit}>{isLoading ? "Saving…" : "Save"}</Button>;
|
|
3300
|
+
```
|
|
3301
|
+
|
|
3302
|
+
Returns `{ isLoading: boolean; setLoading: (v: boolean) => void; withLoading: <T>(fn: () => Promise<T>) => Promise<T> }`. `withLoading` sets the flag to `true` before the call and clears it in a `finally` block — even on errors.
|
|
3303
|
+
|
|
3304
|
+
### useErrorDisplay
|
|
3305
|
+
|
|
3306
|
+
Passive error state. Coerces `Error` instances to their `.message`, strings pass through, anything else is `String()`-cast.
|
|
3307
|
+
|
|
3308
|
+
```tsx
|
|
3309
|
+
import { useErrorDisplay, ErrorBanner } from "hazo_ui";
|
|
3310
|
+
|
|
3311
|
+
const { error, setError, clearError } = useErrorDisplay();
|
|
3312
|
+
|
|
3313
|
+
try { await save(); } catch (e) { setError(e); }
|
|
3314
|
+
|
|
3315
|
+
return error ? <ErrorBanner message={error} onDismiss={clearError} /> : null;
|
|
3316
|
+
```
|
|
3317
|
+
|
|
3318
|
+
Returns `{ error: string | null; setError: (v: unknown) => void; clearError: () => void }`. Pass `null` (or any nullish value) to `setError` to clear.
|
|
3319
|
+
|
|
3320
|
+
---
|
|
3321
|
+
|
|
3322
|
+
## HazoUiKanban — Drag-Drop Kanban (v2.13.0+)
|
|
3323
|
+
|
|
3324
|
+
Drag-drop board with mobile tab-strip / desktop column-grid layouts,
|
|
3325
|
+
optimistic updates with revert, theme-able priority borders, and
|
|
3326
|
+
keyboard-driven DnD. Wraps `@dnd-kit` internally.
|
|
3327
|
+
|
|
3328
|
+
### Minimal usage
|
|
3329
|
+
|
|
3330
|
+
```tsx
|
|
3331
|
+
import {
|
|
3332
|
+
HazoUiKanban,
|
|
3333
|
+
HazoUiKanbanFilter,
|
|
3334
|
+
applyKanbanFilter,
|
|
3335
|
+
type KanbanColumn,
|
|
3336
|
+
type KanbanFilterValue,
|
|
3337
|
+
} from 'hazo_ui';
|
|
3338
|
+
|
|
3339
|
+
const COLUMNS: KanbanColumn[] = [
|
|
3340
|
+
{ key: 'todo', title: 'To Do' },
|
|
3341
|
+
{ key: 'in_progress', title: 'In Progress' },
|
|
3342
|
+
{ key: 'blocked', title: 'Blocked' },
|
|
3343
|
+
{ key: 'done', title: 'Done' },
|
|
3344
|
+
];
|
|
3345
|
+
|
|
3346
|
+
function Actions({ initial }) {
|
|
3347
|
+
const [items, setItems] = useState(initial);
|
|
3348
|
+
const [filter, setFilter] = useState<KanbanFilterValue>({
|
|
3349
|
+
search: '', categories: [], priority: null,
|
|
3350
|
+
});
|
|
3351
|
+
const visible = useMemo(() => applyKanbanFilter(items, filter), [items, filter]);
|
|
3352
|
+
|
|
3353
|
+
return (
|
|
3354
|
+
<>
|
|
3355
|
+
<HazoUiKanbanFilter
|
|
3356
|
+
categories={['On-page SEO', 'Technical SEO', 'Content']}
|
|
3357
|
+
priorities={['P0', 'P1', 'P2', 'P3']}
|
|
3358
|
+
value={filter}
|
|
3359
|
+
onChange={setFilter}
|
|
3360
|
+
/>
|
|
3361
|
+
<HazoUiKanban
|
|
3362
|
+
columns={COLUMNS}
|
|
3363
|
+
items={visible}
|
|
3364
|
+
renderCard={(action) => <ActionCard action={action} />}
|
|
3365
|
+
itemLabel={(action) => `action ${action.id}`}
|
|
3366
|
+
onMove={async (event) => {
|
|
3367
|
+
try {
|
|
3368
|
+
const res = await fetch(`/api/v1/actions/${event.itemId}`, {
|
|
3369
|
+
method: 'PATCH',
|
|
3370
|
+
body: JSON.stringify({ status: event.toColumn }),
|
|
3371
|
+
});
|
|
3372
|
+
if (!res.ok) throw new Error('PATCH failed');
|
|
3373
|
+
setItems(prev => prev.map(it =>
|
|
3374
|
+
it.id === event.itemId ? { ...it, columnKey: event.toColumn } : it,
|
|
3375
|
+
));
|
|
3376
|
+
} catch {
|
|
3377
|
+
event.revert();
|
|
3378
|
+
}
|
|
3379
|
+
}}
|
|
3380
|
+
/>
|
|
3381
|
+
</>
|
|
3382
|
+
);
|
|
3383
|
+
}
|
|
3384
|
+
```
|
|
3385
|
+
|
|
3386
|
+
### Optimistic update + revert
|
|
3387
|
+
|
|
3388
|
+
`onMove` fires after the library has already moved the card visually. The
|
|
3389
|
+
event carries `revert()` — call it when your API request fails and the
|
|
3390
|
+
overlay snaps back to whatever `items` says.
|
|
3391
|
+
|
|
3392
|
+
### Theming (CSS custom properties)
|
|
3393
|
+
|
|
3394
|
+
| Variable | Default | Purpose |
|
|
3395
|
+
|---|---|---|
|
|
3396
|
+
| `--hazo-kanban-priority-p0` | `0 84% 60%` (red) | Left border for `P0` cards |
|
|
3397
|
+
| `--hazo-kanban-priority-p1` | `45 93% 55%` (yellow) | Left border for `P1` cards |
|
|
3398
|
+
| `--hazo-kanban-priority-p2` | `217 91% 60%` (blue) | Left border for `P2` cards |
|
|
3399
|
+
| `--hazo-kanban-priority-p3` | `220 9% 64%` (grey) | Left border for `P3` cards |
|
|
3400
|
+
| `--hazo-kanban-card-bg` | `var(--card)` | Card background |
|
|
3401
|
+
| `--hazo-kanban-card-border` | `var(--border)` | Card border |
|
|
3402
|
+
| `--hazo-kanban-column-gap` | `0.75rem` | Gap between columns |
|
|
3403
|
+
|
|
3404
|
+
Values are HSL channels (no `hsl()` wrapper) to match the shadcn theme
|
|
3405
|
+
convention. Custom priorities like `'critical'` work — define
|
|
3406
|
+
`--hazo-kanban-priority-critical` and pass `priority: 'critical'`.
|
|
3407
|
+
|
|
3408
|
+
### Visual reference
|
|
3409
|
+
|
|
3410
|
+
Run `npm run dev:test-app` and visit `/board` for five demo scenarios
|
|
3411
|
+
(default kanban, controlled / uncontrolled filter, keyboard-only flow,
|
|
3412
|
+
and optimistic + revert).
|
|
3413
|
+
|
|
3414
|
+
### Out-of-the-box card editor
|
|
3415
|
+
|
|
3416
|
+
Each card carries a pencil icon (top-right, opacity-0 idle, opacity-100
|
|
3417
|
+
on hover/focus). Clicking it opens a `HazoUiDialog` editor:
|
|
3418
|
+
|
|
3419
|
+
```tsx
|
|
3420
|
+
<HazoUiKanban
|
|
3421
|
+
columns={COLUMNS}
|
|
3422
|
+
items={items}
|
|
3423
|
+
renderCard={(action) => <ActionCard action={action} />}
|
|
3424
|
+
// Declarative field config — library renders one row per declaration.
|
|
3425
|
+
editorFields={[
|
|
3426
|
+
{ key: 'title', type: 'textarea', required: true },
|
|
3427
|
+
{ key: 'category', type: 'select', options: CATEGORIES },
|
|
3428
|
+
{ key: 'priority', type: 'priority' },
|
|
3429
|
+
]}
|
|
3430
|
+
onCardSave={async (event) => {
|
|
3431
|
+
const res = await fetch(`/api/v1/actions/${event.itemId}`, {
|
|
3432
|
+
method: 'PATCH',
|
|
3433
|
+
body: JSON.stringify(event.next),
|
|
3434
|
+
});
|
|
3435
|
+
if (!res.ok) throw new Error('PATCH failed');
|
|
3436
|
+
setItems(prev => prev.map(it =>
|
|
3437
|
+
it.id === event.itemId ? event.next : it,
|
|
3438
|
+
));
|
|
3439
|
+
}}
|
|
3440
|
+
/>
|
|
3441
|
+
```
|
|
3442
|
+
|
|
3443
|
+
Field types: `text`, `textarea`, `select`, `number`, `checkbox`,
|
|
3444
|
+
`priority` (a select pre-populated with `editorPriorities` —
|
|
3445
|
+
defaults to `["P0","P1","P2","P3"]`).
|
|
3446
|
+
|
|
3447
|
+
If you don't pass `editorFields`, the library auto-detects all
|
|
3448
|
+
string-valued fields on the item (except `id`, `columnKey`, `priority`)
|
|
3449
|
+
and renders each as a text input with a humanized label.
|
|
3450
|
+
|
|
3451
|
+
#### Custom form via renderCardEditor
|
|
3452
|
+
|
|
3453
|
+
When the declarative config isn't enough, replace the dialog body
|
|
3454
|
+
with your own form:
|
|
3455
|
+
|
|
3456
|
+
```tsx
|
|
3457
|
+
<HazoUiKanban
|
|
3458
|
+
// ...
|
|
3459
|
+
renderCardEditor={(item, ctx) => (
|
|
3460
|
+
<MyComplexForm
|
|
3461
|
+
value={ctx.draft}
|
|
3462
|
+
onChange={(next) => ctx.setDraft(next)}
|
|
3463
|
+
saving={ctx.saving}
|
|
3464
|
+
error={ctx.error}
|
|
3465
|
+
/>
|
|
3466
|
+
)}
|
|
3467
|
+
onCardSave={async (event) => { /* ... */ }}
|
|
3468
|
+
/>
|
|
3469
|
+
```
|
|
3470
|
+
|
|
3471
|
+
`ctx` provides `draft`, `setDraft`, `save()`, `close()`, `saving`, `error`,
|
|
3472
|
+
and `isDirty`. The library still renders the dialog shell, title, and
|
|
3473
|
+
the default Save/Cancel footer. To render your own buttons too, pass
|
|
3474
|
+
`hideEditorFooter={true}` and call `ctx.save()`/`ctx.close()` from
|
|
3475
|
+
within your form.
|
|
3476
|
+
|
|
3477
|
+
#### Save lifecycle
|
|
3478
|
+
|
|
3479
|
+
`onCardSave` returns `void | Promise<void>`. A returned Promise gates
|
|
3480
|
+
the dialog close and shows a `Saving…` spinner; a rejected Promise
|
|
3481
|
+
keeps the dialog open with `ctx.error` populated. Consumer is
|
|
3482
|
+
responsible for updating `items` on success — symmetric with `onMove`.
|
|
3483
|
+
|
|
3484
|
+
If `onCardSave` is not provided, the pencil is hidden entirely
|
|
3485
|
+
(read-only kanban). To explicitly hide editing without removing
|
|
3486
|
+
`onCardSave`, pass `disableEdit={true}`.
|
|
3487
|
+
|
|
3488
|
+
---
|
|
3489
|
+
|
|
3490
|
+
## HazoUiTable — Column-config-driven Data Table (v2.14.0+, v2.15 additions)
|
|
3491
|
+
|
|
3492
|
+
A higher-level data table that composes the shadcn `Table` primitive
|
|
3493
|
+
family with the existing `HazoUiMultiSortDialog` /
|
|
3494
|
+
`HazoUiMultiFilterDialog` and an in-memory filter / sort / paginate
|
|
3495
|
+
pipeline.
|
|
3496
|
+
|
|
3497
|
+
```tsx
|
|
3498
|
+
import { HazoUiTable, type TableColumn } from 'hazo_ui';
|
|
3499
|
+
|
|
3500
|
+
interface Run {
|
|
3501
|
+
id: string;
|
|
3502
|
+
date: Date;
|
|
3503
|
+
status: 'ok' | 'fail';
|
|
3504
|
+
count: number;
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3507
|
+
const columns: TableColumn<Run>[] = [
|
|
3508
|
+
{ key: 'date', label: 'Date', sortable: true, formatter: 'date', filterType: 'date' },
|
|
3509
|
+
{
|
|
3510
|
+
key: 'status',
|
|
3511
|
+
label: 'Status',
|
|
3512
|
+
sortable: true,
|
|
3513
|
+
filterType: 'combobox',
|
|
3514
|
+
filterConfig: {
|
|
3515
|
+
comboboxOptions: [
|
|
3516
|
+
{ label: 'OK', value: 'ok' },
|
|
3517
|
+
{ label: 'Fail', value: 'fail' },
|
|
3518
|
+
],
|
|
3519
|
+
},
|
|
3520
|
+
},
|
|
3521
|
+
{ key: 'count', label: 'Count', sortable: true, formatter: 'number', align: 'right' },
|
|
3522
|
+
// v2.15+ — column-level currency override (defaults to USD)
|
|
3523
|
+
{ key: 'revenue', label: 'Revenue', sortable: true, formatter: 'currency', currency: 'EUR', align: 'right' },
|
|
3524
|
+
];
|
|
3525
|
+
|
|
3526
|
+
<HazoUiTable<Run>
|
|
3527
|
+
columns={columns}
|
|
3528
|
+
rows={runs}
|
|
3529
|
+
getRowKey={(r) => r.id}
|
|
3530
|
+
enableSearch
|
|
3531
|
+
enableSortDialog
|
|
3532
|
+
enableFilterDialog
|
|
3533
|
+
pagination={{ pageSize: 25 }}
|
|
3534
|
+
onRowClick={(r) => router.push(`/runs/${r.id}`)}
|
|
3535
|
+
/>
|
|
3536
|
+
```
|
|
3537
|
+
|
|
3538
|
+
Features:
|
|
3539
|
+
|
|
3540
|
+
- Header click cycles asc → desc → none. **Shift-click** a second
|
|
3541
|
+
header to append it as a secondary sort (v2.15). Multi-column sort
|
|
3542
|
+
also available via the optional sort dialog.
|
|
3543
|
+
- Free-text search across string-typed columns (debounced 200 ms).
|
|
3544
|
+
- Per-column filters via the filter dialog — declarable per column.
|
|
3545
|
+
- Loading state via `SkeletonBar`, empty / no-results via `EmptyState`.
|
|
3546
|
+
- Pagination footer with Prev / Next.
|
|
3547
|
+
- Row click with mouse and keyboard (Enter / Space) when `onRowClick`
|
|
3548
|
+
is set.
|
|
3549
|
+
- Mobile card-per-row fallback below `mobileBreakpoint` (default
|
|
3550
|
+
768 px). Opt out with `mobileCardFallback={false}`.
|
|
3551
|
+
- Optional `onLoad({ page, sort, filter })` for server-driven
|
|
3552
|
+
pagination — latest-request-wins.
|
|
3553
|
+
- Structured error UX (v2.15): pass `error={node | (err) => node}`
|
|
3554
|
+
to render a custom failure state when `onLoad` rejects; otherwise
|
|
3555
|
+
the table falls back to an `EmptyState` showing the thrown message.
|
|
3556
|
+
- Per-column `currency` (v2.15) for ISO-4217 codes other than USD.
|
|
3557
|
+
|
|
3558
|
+
The package also re-exports the bare shadcn primitives — `Table`,
|
|
3559
|
+
`TableHeader`, `TableBody`, `TableFooter`, `TableHead`, `TableRow`,
|
|
3560
|
+
`TableCell`, `TableCaption` — for consumers that prefer raw markup.
|
|
3561
|
+
|
|
3562
|
+
---
|
|
3563
|
+
|
|
3025
3564
|
## Troubleshooting
|
|
3026
3565
|
|
|
3027
3566
|
### Styles not applying (Tailwind v4)
|