hazo_ui 2.11.0 → 2.17.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,8 +202,12 @@ 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
+
205
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.
206
208
 
209
+ - **[Chart Primitives](#chart-primitives-v2170)** (v2.17.0) - Pure-SVG chart components for KPI cards and trend dashboards: `Sparkline`, `InverseSparkline`, `LineChart`, `MultiLineChart`, `StackedBars`, and `DateRangeSelector`. Zero third-party chart deps. Gap-aware paths, hover tooltips, and per-series endpoint markers built in.
210
+
207
211
  ### State Primitives (v2.10.0)
208
212
 
209
213
  Lightweight, opinionated components for the four ubiquitous async states: **loading**, **empty**, **error**, and **success**.
@@ -3317,6 +3321,493 @@ Returns `{ error: string | null; setError: (v: unknown) => void; clearError: ()
3317
3321
 
3318
3322
  ---
3319
3323
 
3324
+ ## HazoUiKanban — Drag-Drop Kanban (v2.13.0+)
3325
+
3326
+ Drag-drop board with mobile tab-strip / desktop column-grid layouts,
3327
+ optimistic updates with revert, theme-able priority borders, and
3328
+ keyboard-driven DnD. Wraps `@dnd-kit` internally.
3329
+
3330
+ ### Minimal usage
3331
+
3332
+ ```tsx
3333
+ import {
3334
+ HazoUiKanban,
3335
+ HazoUiKanbanFilter,
3336
+ applyKanbanFilter,
3337
+ type KanbanColumn,
3338
+ type KanbanFilterValue,
3339
+ } from 'hazo_ui';
3340
+
3341
+ const COLUMNS: KanbanColumn[] = [
3342
+ { key: 'todo', title: 'To Do' },
3343
+ { key: 'in_progress', title: 'In Progress' },
3344
+ { key: 'blocked', title: 'Blocked' },
3345
+ { key: 'done', title: 'Done' },
3346
+ ];
3347
+
3348
+ function Actions({ initial }) {
3349
+ const [items, setItems] = useState(initial);
3350
+ const [filter, setFilter] = useState<KanbanFilterValue>({
3351
+ search: '', categories: [], priority: null,
3352
+ });
3353
+ const visible = useMemo(() => applyKanbanFilter(items, filter), [items, filter]);
3354
+
3355
+ return (
3356
+ <>
3357
+ <HazoUiKanbanFilter
3358
+ categories={['On-page SEO', 'Technical SEO', 'Content']}
3359
+ priorities={['P0', 'P1', 'P2', 'P3']}
3360
+ value={filter}
3361
+ onChange={setFilter}
3362
+ />
3363
+ <HazoUiKanban
3364
+ columns={COLUMNS}
3365
+ items={visible}
3366
+ renderCard={(action) => <ActionCard action={action} />}
3367
+ itemLabel={(action) => `action ${action.id}`}
3368
+ onMove={async (event) => {
3369
+ try {
3370
+ const res = await fetch(`/api/v1/actions/${event.itemId}`, {
3371
+ method: 'PATCH',
3372
+ body: JSON.stringify({ status: event.toColumn }),
3373
+ });
3374
+ if (!res.ok) throw new Error('PATCH failed');
3375
+ setItems(prev => prev.map(it =>
3376
+ it.id === event.itemId ? { ...it, columnKey: event.toColumn } : it,
3377
+ ));
3378
+ } catch {
3379
+ event.revert();
3380
+ }
3381
+ }}
3382
+ />
3383
+ </>
3384
+ );
3385
+ }
3386
+ ```
3387
+
3388
+ ### Optimistic update + revert
3389
+
3390
+ `onMove` fires after the library has already moved the card visually. The
3391
+ event carries `revert()` — call it when your API request fails and the
3392
+ overlay snaps back to whatever `items` says.
3393
+
3394
+ ### Theming (CSS custom properties)
3395
+
3396
+ | Variable | Default | Purpose |
3397
+ |---|---|---|
3398
+ | `--hazo-kanban-priority-p0` | `0 84% 60%` (red) | Left border for `P0` cards |
3399
+ | `--hazo-kanban-priority-p1` | `45 93% 55%` (yellow) | Left border for `P1` cards |
3400
+ | `--hazo-kanban-priority-p2` | `217 91% 60%` (blue) | Left border for `P2` cards |
3401
+ | `--hazo-kanban-priority-p3` | `220 9% 64%` (grey) | Left border for `P3` cards |
3402
+ | `--hazo-kanban-card-bg` | `var(--card)` | Card background |
3403
+ | `--hazo-kanban-card-border` | `var(--border)` | Card border |
3404
+ | `--hazo-kanban-column-gap` | `0.75rem` | Gap between columns |
3405
+
3406
+ Values are HSL channels (no `hsl()` wrapper) to match the shadcn theme
3407
+ convention. Custom priorities like `'critical'` work — define
3408
+ `--hazo-kanban-priority-critical` and pass `priority: 'critical'`.
3409
+
3410
+ ### Visual reference
3411
+
3412
+ Run `npm run dev:test-app` and visit `/board` for five demo scenarios
3413
+ (default kanban, controlled / uncontrolled filter, keyboard-only flow,
3414
+ and optimistic + revert).
3415
+
3416
+ ### Out-of-the-box card editor
3417
+
3418
+ Each card carries a pencil icon (top-right, opacity-0 idle, opacity-100
3419
+ on hover/focus). Clicking it opens a `HazoUiDialog` editor:
3420
+
3421
+ ```tsx
3422
+ <HazoUiKanban
3423
+ columns={COLUMNS}
3424
+ items={items}
3425
+ renderCard={(action) => <ActionCard action={action} />}
3426
+ // Declarative field config — library renders one row per declaration.
3427
+ editorFields={[
3428
+ { key: 'title', type: 'textarea', required: true },
3429
+ { key: 'category', type: 'select', options: CATEGORIES },
3430
+ { key: 'priority', type: 'priority' },
3431
+ ]}
3432
+ onCardSave={async (event) => {
3433
+ const res = await fetch(`/api/v1/actions/${event.itemId}`, {
3434
+ method: 'PATCH',
3435
+ body: JSON.stringify(event.next),
3436
+ });
3437
+ if (!res.ok) throw new Error('PATCH failed');
3438
+ setItems(prev => prev.map(it =>
3439
+ it.id === event.itemId ? event.next : it,
3440
+ ));
3441
+ }}
3442
+ />
3443
+ ```
3444
+
3445
+ Field types: `text`, `textarea`, `select`, `number`, `checkbox`,
3446
+ `priority` (a select pre-populated with `editorPriorities` —
3447
+ defaults to `["P0","P1","P2","P3"]`).
3448
+
3449
+ If you don't pass `editorFields`, the library auto-detects all
3450
+ string-valued fields on the item (except `id`, `columnKey`, `priority`)
3451
+ and renders each as a text input with a humanized label.
3452
+
3453
+ #### Custom form via renderCardEditor
3454
+
3455
+ When the declarative config isn't enough, replace the dialog body
3456
+ with your own form:
3457
+
3458
+ ```tsx
3459
+ <HazoUiKanban
3460
+ // ...
3461
+ renderCardEditor={(item, ctx) => (
3462
+ <MyComplexForm
3463
+ value={ctx.draft}
3464
+ onChange={(next) => ctx.setDraft(next)}
3465
+ saving={ctx.saving}
3466
+ error={ctx.error}
3467
+ />
3468
+ )}
3469
+ onCardSave={async (event) => { /* ... */ }}
3470
+ />
3471
+ ```
3472
+
3473
+ `ctx` provides `draft`, `setDraft`, `save()`, `close()`, `saving`, `error`,
3474
+ and `isDirty`. The library still renders the dialog shell, title, and
3475
+ the default Save/Cancel footer. To render your own buttons too, pass
3476
+ `hideEditorFooter={true}` and call `ctx.save()`/`ctx.close()` from
3477
+ within your form.
3478
+
3479
+ #### Save lifecycle
3480
+
3481
+ `onCardSave` returns `void | Promise<void>`. A returned Promise gates
3482
+ the dialog close and shows a `Saving…` spinner; a rejected Promise
3483
+ keeps the dialog open with `ctx.error` populated. Consumer is
3484
+ responsible for updating `items` on success — symmetric with `onMove`.
3485
+
3486
+ If `onCardSave` is not provided, the pencil is hidden entirely
3487
+ (read-only kanban). To explicitly hide editing without removing
3488
+ `onCardSave`, pass `disableEdit={true}`.
3489
+
3490
+ ---
3491
+
3492
+ ## HazoUiTable — Column-config-driven Data Table (v2.14.0+, v2.15 additions)
3493
+
3494
+ A higher-level data table that composes the shadcn `Table` primitive
3495
+ family with the existing `HazoUiMultiSortDialog` /
3496
+ `HazoUiMultiFilterDialog` and an in-memory filter / sort / paginate
3497
+ pipeline.
3498
+
3499
+ ```tsx
3500
+ import { HazoUiTable, type TableColumn } from 'hazo_ui';
3501
+
3502
+ interface Run {
3503
+ id: string;
3504
+ date: Date;
3505
+ status: 'ok' | 'fail';
3506
+ count: number;
3507
+ }
3508
+
3509
+ const columns: TableColumn<Run>[] = [
3510
+ { key: 'date', label: 'Date', sortable: true, formatter: 'date', filterType: 'date' },
3511
+ {
3512
+ key: 'status',
3513
+ label: 'Status',
3514
+ sortable: true,
3515
+ filterType: 'combobox',
3516
+ filterConfig: {
3517
+ comboboxOptions: [
3518
+ { label: 'OK', value: 'ok' },
3519
+ { label: 'Fail', value: 'fail' },
3520
+ ],
3521
+ },
3522
+ },
3523
+ { key: 'count', label: 'Count', sortable: true, formatter: 'number', align: 'right' },
3524
+ // v2.15+ — column-level currency override (defaults to USD)
3525
+ { key: 'revenue', label: 'Revenue', sortable: true, formatter: 'currency', currency: 'EUR', align: 'right' },
3526
+ ];
3527
+
3528
+ <HazoUiTable<Run>
3529
+ columns={columns}
3530
+ rows={runs}
3531
+ getRowKey={(r) => r.id}
3532
+ enableSearch
3533
+ enableSortDialog
3534
+ enableFilterDialog
3535
+ pagination={{ pageSize: 25 }}
3536
+ onRowClick={(r) => router.push(`/runs/${r.id}`)}
3537
+ />
3538
+ ```
3539
+
3540
+ Features:
3541
+
3542
+ - Header click cycles asc → desc → none. **Shift-click** a second
3543
+ header to append it as a secondary sort (v2.15). Multi-column sort
3544
+ also available via the optional sort dialog.
3545
+ - Free-text search across string-typed columns (debounced 200 ms).
3546
+ - Per-column filters via the filter dialog — declarable per column.
3547
+ - Loading state via `SkeletonBar`, empty / no-results via `EmptyState`.
3548
+ - Pagination footer with Prev / Next.
3549
+ - Row click with mouse and keyboard (Enter / Space) when `onRowClick`
3550
+ is set.
3551
+ - Mobile card-per-row fallback below `mobileBreakpoint` (default
3552
+ 768 px). Opt out with `mobileCardFallback={false}`.
3553
+ - Optional `onLoad({ page, sort, filter })` for server-driven
3554
+ pagination — latest-request-wins.
3555
+ - Structured error UX (v2.15): pass `error={node | (err) => node}`
3556
+ to render a custom failure state when `onLoad` rejects; otherwise
3557
+ the table falls back to an `EmptyState` showing the thrown message.
3558
+ - Per-column `currency` (v2.15) for ISO-4217 codes other than USD.
3559
+
3560
+ The package also re-exports the bare shadcn primitives — `Table`,
3561
+ `TableHeader`, `TableBody`, `TableFooter`, `TableHead`, `TableRow`,
3562
+ `TableCell`, `TableCaption` — for consumers that prefer raw markup.
3563
+
3564
+ ---
3565
+
3566
+ ## Chart Primitives (v2.17.0)
3567
+
3568
+ A set of dependency-free, pure-SVG chart components for KPI cards,
3569
+ trend visualisations, and time-series dashboards. No `recharts`,
3570
+ no `chart.js`, no canvas — every chart is a flat `<svg>` whose math
3571
+ is computed in React. Hover tooltips and gap-aware paths are built in.
3572
+
3573
+ ```tsx
3574
+ import {
3575
+ Sparkline,
3576
+ InverseSparkline,
3577
+ LineChart,
3578
+ MultiLineChart,
3579
+ StackedBars,
3580
+ DateRangeSelector,
3581
+ format_num,
3582
+ pick_x_label_indices,
3583
+ type ChartDataSeries,
3584
+ type MultiSeries,
3585
+ type StackedBar,
3586
+ type DateRangeOption,
3587
+ } from 'hazo_ui';
3588
+ ```
3589
+
3590
+ ### Sparkline
3591
+
3592
+ Axisless single-series trend line for KPI cards. Stretches edge-to-edge
3593
+ across the parent (`width: 100%`), 12% area fill, 2px endpoint dot at the
3594
+ latest non-null value. By design no hover — the numeric value belongs
3595
+ above the sparkline in the KPI card itself.
3596
+
3597
+ ```tsx
3598
+ <Sparkline data={[2, 3, 5, 4, 7, 9, 12]} color="#10b981" height={40} />
3599
+ ```
3600
+
3601
+ Props:
3602
+
3603
+ | Prop | Type | Default | Description |
3604
+ |-------------|---------------------|---------|--------------------------------------------|
3605
+ | `data` | `(number \| null)[]`| — | Series. `null` entries are skipped. |
3606
+ | `color` | `string` | — | Stroke, dot, and area-fill color. |
3607
+ | `height` | `number` | `40` | Pixel height. Width is always 100%. |
3608
+ | `className` | `string` | — | Container className passthrough. |
3609
+
3610
+ ### InverseSparkline
3611
+
3612
+ Same shape as `Sparkline`, but the Y-axis is **inverted** — lower values
3613
+ plot higher on the screen. Built for GSC average-position trends (rank 1
3614
+ = best). Fixed 62×18 default to fit inline in scrollable query lists.
3615
+ No area fill, no dot — the line direction is the only signal.
3616
+
3617
+ ```tsx
3618
+ <InverseSparkline data={[12, 10, 8, 5, 3]} color="#3b82f6" />
3619
+ ```
3620
+
3621
+ Props:
3622
+
3623
+ | Prop | Type | Default | Description |
3624
+ |-------------|---------------------|---------|--------------------------------------------|
3625
+ | `data` | `(number \| null)[]`| — | Series. `null` entries are skipped. |
3626
+ | `color` | `string` | — | Stroke color. |
3627
+ | `width` | `number` | `62` | Pixel width. |
3628
+ | `height` | `number` | `18` | Pixel height. |
3629
+ | `className` | `string` | — | Container className passthrough. |
3630
+
3631
+ ### LineChart
3632
+
3633
+ Full-anatomy single-series chart: three dashed gridlines at 0/50/100% of
3634
+ plot height, Y-axis ticks (max/mid/min) at the left, three X-axis labels
3635
+ (start/mid/end) below, marker dot at the last data point with a dashed
3636
+ guide-line back to the Y-axis and a value label rendered above-left.
3637
+
3638
+ Hover the plot area to surface a vertical cursor line and a value bubble
3639
+ at the nearest data index. Pass `showTooltip={false}` for static use
3640
+ (print export, embeds).
3641
+
3642
+ ```tsx
3643
+ <LineChart
3644
+ data={[12, 14, 18, 20, 25, 30, 36, 42]}
3645
+ dates={['May 1', 'May 2', 'May 3', 'May 4', 'May 5', 'May 6', 'May 7', 'May 8']}
3646
+ color="#10b981"
3647
+ unit="%"
3648
+ />
3649
+ ```
3650
+
3651
+ Props:
3652
+
3653
+ | Prop | Type | Default | Description |
3654
+ |---------------|---------------------|---------|----------------------------------------------------------|
3655
+ | `data` | `(number \| null)[]`| — | Series. `null` entries are gaps in the line. |
3656
+ | `dates` | `string[]` | — | X-axis label per data point (length should match `data`).|
3657
+ | `color` | `string` | — | Stroke / area / marker color. |
3658
+ | `width` | `number` | `360` | viewBox width. |
3659
+ | `height` | `number` | `130` | viewBox height. |
3660
+ | `unit` | `string` | — | Suffix on the current-value label (e.g. `"%"`). |
3661
+ | `showTooltip` | `boolean` | `true` | Disable hover tooltip when `false`. |
3662
+ | `className` | `string` | — | Container className passthrough. |
3663
+
3664
+ ### MultiLineChart
3665
+
3666
+ Multiple lines sharing a Y-axis. **No area fill** — multi-series uses
3667
+ lines only so colors don't muddy each other. Per-series endpoint marker +
3668
+ value label keeps the latest reading visible without hovering. Hover
3669
+ surfaces one cursor with a stacked bubble showing every series value at
3670
+ that index. Optional legend below the SVG.
3671
+
3672
+ ```tsx
3673
+ <MultiLineChart
3674
+ series={[
3675
+ { label: 'Indexed', color: '#10b981', data: [1, 2, 5, 12, 26, 48, 62] },
3676
+ { label: 'Crawled', color: '#f59e0b', data: [3, 4, 8, 15, 22, 28, 30] },
3677
+ ]}
3678
+ dates={['May 1', 'May 2', 'May 3', 'May 4', 'May 5', 'May 6', 'May 7']}
3679
+ />
3680
+ ```
3681
+
3682
+ Props:
3683
+
3684
+ | Prop | Type | Default | Description |
3685
+ |---------------|-----------------|---------|-------------------------------------------------|
3686
+ | `series` | `MultiSeries[]` | — | One entry per line (`label`, `color`, `data`). |
3687
+ | `dates` | `string[]` | — | X-axis labels. |
3688
+ | `width` | `number` | `360` | viewBox width. |
3689
+ | `height` | `number` | `140` | viewBox height. |
3690
+ | `showTooltip` | `boolean` | `true` | Disable hover tooltip when `false`. |
3691
+ | `showLegend` | `boolean` | `true` | Render legend below the chart. |
3692
+ | `className` | `string` | — | Container className passthrough. |
3693
+
3694
+ ### StackedBars
3695
+
3696
+ Generic stacked-bar over time. One bar per X position; each bar split
3697
+ top-to-bottom into colored bands defined by `segments`. Good for
3698
+ distribution-over-time visuals (GSC position buckets, traffic-source mix,
3699
+ category breakdowns).
3700
+
3701
+ ```tsx
3702
+ <StackedBars
3703
+ bars={[
3704
+ { label: 'May 1', segments: [
3705
+ { value: 4, color: '#10b981' },
3706
+ { value: 6, color: '#3b82f6' },
3707
+ { value: 2, color: '#f59e0b' },
3708
+ ]},
3709
+ // ...one entry per bar
3710
+ ]}
3711
+ />
3712
+ ```
3713
+
3714
+ Props:
3715
+
3716
+ | Prop | Type | Default | Description |
3717
+ |--------------|----------------|---------|-----------------------------------------------------|
3718
+ | `bars` | `StackedBar[]` | — | One entry per column; `segments` are top-to-bottom. |
3719
+ | `width` | `number` | `360` | viewBox width. |
3720
+ | `height` | `number` | `140` | viewBox height. |
3721
+ | `showYAxis` | `boolean` | `true` | Render Y-axis ticks (max / mid / 0). |
3722
+ | `className` | `string` | — | Container className passthrough. |
3723
+
3724
+ ### DateRangeSelector
3725
+
3726
+ Framework-agnostic segmented control (`value` + `onChange`) for picking
3727
+ a chart's time window. The active pill is tinted with the
3728
+ `--primary` CSS variable; inactive pills use muted foreground colors.
3729
+ No router coupling — wire the value into URL state, local state, or any
3730
+ store yourself.
3731
+
3732
+ ```tsx
3733
+ const [range, setRange] = React.useState('30d');
3734
+
3735
+ <DateRangeSelector
3736
+ value={range}
3737
+ onChange={setRange}
3738
+ options={[
3739
+ { value: '7d', label: '7d' },
3740
+ { value: '30d', label: '30d' },
3741
+ { value: '90d', label: '90d' },
3742
+ ]}
3743
+ />
3744
+ ```
3745
+
3746
+ Props:
3747
+
3748
+ | Prop | Type | Default | Description |
3749
+ |-------------|-------------------------------|----------------|--------------------------------------|
3750
+ | `value` | `string` | — | Currently selected option value. |
3751
+ | `onChange` | `(value: string) => void` | — | Fires when the user picks an option. |
3752
+ | `options` | `DateRangeOption[]` | — | `{ value, label }` pairs. |
3753
+ | `ariaLabel` | `string` | `"Date range"` | Aria label for the button group. |
3754
+ | `className` | `string` | — | Container className passthrough. |
3755
+
3756
+ ### Helpers
3757
+
3758
+ Two pure helpers are exported for callers that want to format values or
3759
+ pick label positions consistently with the charts:
3760
+
3761
+ ```tsx
3762
+ format_num(1234); // "1.2k"
3763
+ format_num(1_500_000); // "1.5M"
3764
+ format_num(5.73); // "5.7"
3765
+ format_num(null); // ""
3766
+
3767
+ pick_x_label_indices(14); // [0, 7, 13] — start / mid / end
3768
+ ```
3769
+
3770
+ ### Types
3771
+
3772
+ ```ts
3773
+ type ChartDataPoint = number | null;
3774
+ type ChartDataSeries = ChartDataPoint[];
3775
+
3776
+ interface MultiSeries {
3777
+ label: string;
3778
+ color: string;
3779
+ data: ChartDataSeries;
3780
+ }
3781
+
3782
+ interface StackedBar {
3783
+ label: string;
3784
+ segments: { value: number; color: string }[];
3785
+ }
3786
+
3787
+ interface DateRangeOption {
3788
+ value: string;
3789
+ label: string;
3790
+ }
3791
+ ```
3792
+
3793
+ ### Notes
3794
+
3795
+ - **Null handling.** A `null` entry in any series is a *gap* — the line
3796
+ breaks rather than connecting around the missing point. The Y range is
3797
+ computed from non-null values only, so one missing day won't deform the
3798
+ chart.
3799
+ - **Axis label color is pinned.** Gridlines (`#2a3441`) and axis labels
3800
+ (`#8b949e`) are hardcoded for dark-themed dashboards. Theming via CSS
3801
+ variables is a follow-up if a second consumer needs it.
3802
+ - **All charts are Client Components.** The library bundle is
3803
+ `"use client"`-stamped at build time, so charts work in any Next.js app
3804
+ but don't get RSC-rendered.
3805
+ - **Hover assumes `width: 100%`.** `LineChart` / `MultiLineChart` map
3806
+ `clientX` to a viewBox X via `getBoundingClientRect()`. CSS transforms
3807
+ on the wrapper (`scale(...)`) will desync the mapping.
3808
+
3809
+ ---
3810
+
3320
3811
  ## Troubleshooting
3321
3812
 
3322
3813
  ### Styles not applying (Tailwind v4)