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/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)