hazo_ui 3.4.1 → 3.5.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 CHANGED
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## v3.5.0 (2026-06-12)
9
+
10
+ ### New
11
+ - `FunnelChart` — horizontal centered-bar conversion funnel with optional segment breakdown, hover tooltip (% of first / % of previous / drop-off), and edge-case handling (empty steps, zero values, single step). Closes FR-002.
12
+
8
13
  ## v3.4.1 — 2026-06-12
9
14
 
10
15
  **Fix:** Webpack compatibility in test-harness format.ts + source sync.
package/README.md CHANGED
@@ -3362,6 +3362,66 @@ return error ? <ErrorBanner message={error} onDismiss={clearError} /> : null;
3362
3362
 
3363
3363
  Returns `{ error: string | null; setError: (v: unknown) => void; clearError: () => void }`. Pass `null` (or any nullish value) to `setError` to clear.
3364
3364
 
3365
+ ### HazoUiEtaProgress (Requires optional `hazo_state` peer dependency)
3366
+
3367
+ Smart ETA progress bar that learns from historical operation durations. Fetches a rolling window of past durations from `hazo_state`, estimates completion time based on remaining work units and concurrency, and animates smoothly toward the estimate. Requires `hazo_state` to be installed.
3368
+
3369
+ ```tsx
3370
+ import { HazoUiEtaProgress, type EtaProgressHandle } from "hazo_ui";
3371
+ import { useRef } from "react";
3372
+
3373
+ function FileProcessor() {
3374
+ const handleRef = useRef<EtaProgressHandle>(null);
3375
+
3376
+ async function processFiles(files: File[]) {
3377
+ handleRef.current?.start();
3378
+
3379
+ for (let i = 0; i < files.length; i++) {
3380
+ await processFile(files[i]);
3381
+ handleRef.current?.unitDone();
3382
+ }
3383
+
3384
+ handleRef.current?.finish();
3385
+ }
3386
+
3387
+ return (
3388
+ <>
3389
+ <HazoUiEtaProgress
3390
+ estimateKey="file_processing"
3391
+ unitCount={totalFiles}
3392
+ concurrency={3}
3393
+ windowSize={10}
3394
+ fallbackUnitMs={2000}
3395
+ label="Processing files…"
3396
+ handleRef={handleRef}
3397
+ />
3398
+ <button onClick={() => processFiles(files)}>Start</button>
3399
+ </>
3400
+ );
3401
+ }
3402
+ ```
3403
+
3404
+ **Installation requirement:** This component requires `hazo_state` as a peer dependency. Install it before using `HazoUiEtaProgress`:
3405
+
3406
+ ```bash
3407
+ npm install hazo_state
3408
+ ```
3409
+
3410
+ | Prop | Type | Default | Description |
3411
+ |---|---|---|---|
3412
+ | `estimateKey` | `string` | **required** | Unique key for storing duration history in `hazo_state` |
3413
+ | `unitCount` | `number` | **required** | Total number of work units |
3414
+ | `concurrency` | `number` | `1` | How many units run in parallel |
3415
+ | `windowSize` | `number` | `5` | Number of past durations to average over |
3416
+ | `fallbackUnitMs` | `number` | `5000` | Per-unit estimate (ms) on cold start when no history exists |
3417
+ | `level` | `"global" \| "session" \| "local"` | `"global"` | hazo_state visibility level |
3418
+ | `label` | `string` | — | Label shown above the progress bar |
3419
+ | `endpoint` | `string` | `"/api/hazo-state"` | hazo_state API endpoint (for fetching history) |
3420
+ | `handleRef` | `RefObject<EtaProgressHandle>` | — | Ref exposing `start()`, `unitDone()`, `finish()` methods |
3421
+ | `className` | `string` | — | Additional classes |
3422
+
3423
+ **Headless hook alternative:** For more control, use `useEtaProgress` directly without the component.
3424
+
3365
3425
  ---
3366
3426
 
3367
3427
  ## HazoUiKanban — Drag-Drop Kanban (v2.13.0+)
@@ -3764,6 +3824,38 @@ Props:
3764
3824
  | `showYAxis` | `boolean` | `true` | Render Y-axis ticks (max / mid / 0). |
3765
3825
  | `className` | `string` | — | Container className passthrough. |
3766
3826
 
3827
+ ### FunnelChart
3828
+
3829
+ Conversion-funnel visualization. Horizontal centered bars, one row per step.
3830
+
3831
+ ```tsx
3832
+ import { FunnelChart } from 'hazo_ui';
3833
+
3834
+ <FunnelChart
3835
+ steps={[
3836
+ { label: 'Landing', value: 1000 },
3837
+ { label: 'Timer start', value: 420 },
3838
+ { label: 'Timer complete', value: 180 },
3839
+ ]}
3840
+ showDropoff
3841
+ />
3842
+ ```
3843
+
3844
+ Optional `segments` prop splits each bar into colored sub-bands (mobile/desktop split, traffic-source mix, etc.).
3845
+
3846
+ Props:
3847
+
3848
+ | Prop | Type | Default | Description |
3849
+ |----------------|-------------------------------|----------------|----------------------------------------------------------|
3850
+ | `steps` | `FunnelStep[]` | — | Ordered funnel steps. Each has `label`, optional `value`, optional `segments`. |
3851
+ | `width` | `number` | `360` | viewBox width. |
3852
+ | `height` | `number` | auto | viewBox height. Defaults to `PAD_TOP + steps.length * 44 + PAD_BOTTOM`. |
3853
+ | `color` | `string` | `"#3b82f6"` | Bar fill for steps without segments. |
3854
+ | `valueFormat` | `(n: number) => string` | `format_num` | Override value formatting. |
3855
+ | `showDropoff` | `boolean` | `true` | Show per-step drop-off count below the value label. |
3856
+ | `showTooltip` | `boolean` | `true` | Enable hover tooltip with full step metrics. |
3857
+ | `className` | `string` | — | Container className passthrough. |
3858
+
3767
3859
  ### DateRangeSelector
3768
3860
 
3769
3861
  Framework-agnostic segmented control (`value` + `onChange`) for picking
package/dist/index.cjs CHANGED
@@ -10428,6 +10428,219 @@ function DateRangeSelector({
10428
10428
  }
10429
10429
  );
10430
10430
  }
10431
+ var PAD_LEFT4 = 80;
10432
+ var PAD_RIGHT4 = 72;
10433
+ var PAD_TOP4 = 12;
10434
+ var PAD_BOTTOM4 = 12;
10435
+ var ROW_H = 44;
10436
+ var LABEL_H = 14;
10437
+ var BAR_H = 22;
10438
+ var AXIS_LABEL_COLOR4 = "#8b949e";
10439
+ var GRIDLINE_COLOR3 = "#2a3441";
10440
+ function step_total(step) {
10441
+ if (step.value !== void 0) return step.value;
10442
+ if (step.segments) return step.segments.reduce((s, g) => s + g.value, 0);
10443
+ return 0;
10444
+ }
10445
+ function FunnelChart({
10446
+ steps,
10447
+ width = 360,
10448
+ height,
10449
+ color: color2 = "#3b82f6",
10450
+ valueFormat,
10451
+ showDropoff = true,
10452
+ showTooltip = true,
10453
+ className
10454
+ }) {
10455
+ const [hover_idx, set_hover_idx] = React26__namespace.useState(null);
10456
+ const fmt = valueFormat ?? format_num;
10457
+ const totals = steps.map(step_total);
10458
+ const value_max = Math.max(1, ...totals);
10459
+ const plot_w = width - PAD_LEFT4 - PAD_RIGHT4;
10460
+ const vbox_h = height ?? PAD_TOP4 + steps.length * ROW_H + PAD_BOTTOM4;
10461
+ const handle_mouse_move = React26__namespace.useCallback(
10462
+ (e) => {
10463
+ if (steps.length === 0) return;
10464
+ const rect = e.currentTarget.getBoundingClientRect();
10465
+ if (rect.height === 0) return;
10466
+ const vbox_y = (e.clientY - rect.top) / rect.height * vbox_h;
10467
+ const row_idx = Math.floor((vbox_y - PAD_TOP4) / ROW_H);
10468
+ if (row_idx < 0 || row_idx >= steps.length) {
10469
+ set_hover_idx(null);
10470
+ return;
10471
+ }
10472
+ set_hover_idx(row_idx);
10473
+ },
10474
+ [steps.length, vbox_h]
10475
+ );
10476
+ const handle_mouse_leave = React26__namespace.useCallback(() => set_hover_idx(null), []);
10477
+ return /* @__PURE__ */ jsxRuntime.jsxs(
10478
+ "svg",
10479
+ {
10480
+ viewBox: `0 0 ${width} ${vbox_h}`,
10481
+ onMouseMove: showTooltip ? handle_mouse_move : void 0,
10482
+ onMouseLeave: showTooltip ? handle_mouse_leave : void 0,
10483
+ className: cn("cls_hazo_chart cls_hazo_chart_funnel", className),
10484
+ style: {
10485
+ width: "100%",
10486
+ height: "auto",
10487
+ display: "block",
10488
+ cursor: showTooltip ? "crosshair" : "default"
10489
+ },
10490
+ children: [
10491
+ steps.length > 0 && /* @__PURE__ */ jsxRuntime.jsx(
10492
+ "line",
10493
+ {
10494
+ x1: PAD_LEFT4 + plot_w / 2,
10495
+ x2: PAD_LEFT4 + plot_w / 2,
10496
+ y1: PAD_TOP4,
10497
+ y2: PAD_TOP4 + steps.length * ROW_H,
10498
+ stroke: GRIDLINE_COLOR3,
10499
+ strokeWidth: 0.5,
10500
+ strokeDasharray: "2,3"
10501
+ }
10502
+ ),
10503
+ steps.map((step, i) => {
10504
+ const total = totals[i];
10505
+ const bar_w = total / value_max * plot_w;
10506
+ const bar_x = PAD_LEFT4 + (plot_w - bar_w) / 2;
10507
+ const bar_y = PAD_TOP4 + i * ROW_H + LABEL_H;
10508
+ const pct_first = i === 0 ? 100 : Math.round(total / (totals[0] || 1) * 100);
10509
+ const value_label = i === 0 ? `${fmt(total)} (100%)` : `${fmt(total)} (${pct_first}%)`;
10510
+ const dropoff = i > 0 ? totals[i - 1] - total : 0;
10511
+ const label_x = bar_x + bar_w + 6;
10512
+ return /* @__PURE__ */ jsxRuntime.jsxs("g", { children: [
10513
+ /* @__PURE__ */ jsxRuntime.jsx(
10514
+ "text",
10515
+ {
10516
+ x: PAD_LEFT4 - 6,
10517
+ y: bar_y + BAR_H / 2 + 4,
10518
+ textAnchor: "end",
10519
+ fill: AXIS_LABEL_COLOR4,
10520
+ fontSize: 9,
10521
+ children: step.label
10522
+ }
10523
+ ),
10524
+ step.segments ? (() => {
10525
+ let cursor_x = bar_x;
10526
+ return step.segments.map((seg, s_idx) => {
10527
+ const seg_w = seg.value / value_max * plot_w;
10528
+ const x = cursor_x;
10529
+ cursor_x += seg_w;
10530
+ return /* @__PURE__ */ jsxRuntime.jsx(
10531
+ "rect",
10532
+ {
10533
+ x,
10534
+ y: bar_y,
10535
+ width: seg_w,
10536
+ height: BAR_H,
10537
+ fill: seg.color
10538
+ },
10539
+ `seg_${s_idx}`
10540
+ );
10541
+ });
10542
+ })() : /* @__PURE__ */ jsxRuntime.jsx("rect", { x: bar_x, y: bar_y, width: bar_w, height: BAR_H, fill: color2 }),
10543
+ /* @__PURE__ */ jsxRuntime.jsx(
10544
+ "text",
10545
+ {
10546
+ x: label_x,
10547
+ y: bar_y + BAR_H / 2,
10548
+ textAnchor: "start",
10549
+ fill: color2,
10550
+ fontSize: 9,
10551
+ dominantBaseline: "middle",
10552
+ children: value_label
10553
+ }
10554
+ ),
10555
+ showDropoff && i > 0 && /* @__PURE__ */ jsxRuntime.jsx(
10556
+ "text",
10557
+ {
10558
+ x: label_x,
10559
+ y: bar_y + BAR_H / 2 + 10,
10560
+ textAnchor: "start",
10561
+ fill: AXIS_LABEL_COLOR4,
10562
+ fontSize: 8,
10563
+ children: `\u2212${fmt(dropoff)}`
10564
+ }
10565
+ )
10566
+ ] }, `step_${i}`);
10567
+ }),
10568
+ showTooltip && hover_idx !== null && (() => {
10569
+ const i = hover_idx;
10570
+ const step = steps[i];
10571
+ const total = totals[i];
10572
+ const pct_first = i === 0 ? 100 : Math.round(total / (totals[0] || 1) * 100);
10573
+ const pct_prev = i === 0 ? 100 : Math.round(total / (totals[i - 1] || 1) * 100);
10574
+ const dropoff = i > 0 ? totals[i - 1] - total : 0;
10575
+ const bar_w = total / value_max * plot_w;
10576
+ const bar_x = PAD_LEFT4 + (plot_w - bar_w) / 2;
10577
+ const bar_y = PAD_TOP4 + i * ROW_H + LABEL_H;
10578
+ const bubble_anchor_y = bar_y + BAR_H / 2;
10579
+ const lines = [
10580
+ { text: step.label, accent: true },
10581
+ { text: `Total: ${fmt(total)}`, accent: false },
10582
+ { text: `% of first: ${pct_first}%`, accent: false }
10583
+ ];
10584
+ if (i > 0) {
10585
+ lines.push({ text: `% of prev: ${pct_prev}%`, accent: false });
10586
+ lines.push({ text: `Drop-off: \u2212${fmt(dropoff)}`, accent: false });
10587
+ }
10588
+ if (step.segments) {
10589
+ step.segments.forEach((seg) => {
10590
+ lines.push({ text: `${seg.label}: ${fmt(seg.value)}`, accent: false });
10591
+ });
10592
+ }
10593
+ const line_h = 13;
10594
+ const pad_v = 8;
10595
+ const bubble_w = 120;
10596
+ const bubble_h = lines.length * line_h + pad_v * 2;
10597
+ const right_x = bar_x + bar_w + 6;
10598
+ const left_x = bar_x - bubble_w - 6;
10599
+ const fits_right = right_x + bubble_w <= width - 4;
10600
+ const fits_left = left_x >= 4;
10601
+ let bubble_x;
10602
+ let bubble_y;
10603
+ if (fits_right || fits_left) {
10604
+ bubble_x = fits_right ? right_x : left_x;
10605
+ bubble_y = Math.max(4, Math.min(bubble_anchor_y - bubble_h / 2, vbox_h - bubble_h - 4));
10606
+ } else {
10607
+ bubble_x = Math.min(Math.max(bar_x + bar_w / 2 - bubble_w / 2, 4), width - bubble_w - 4);
10608
+ const below_y = bar_y + BAR_H + 6;
10609
+ bubble_y = below_y + bubble_h <= vbox_h - 4 ? below_y : Math.max(4, bar_y - bubble_h - 6);
10610
+ }
10611
+ return /* @__PURE__ */ jsxRuntime.jsxs("g", { children: [
10612
+ /* @__PURE__ */ jsxRuntime.jsx(
10613
+ "rect",
10614
+ {
10615
+ x: bubble_x,
10616
+ y: bubble_y,
10617
+ width: bubble_w,
10618
+ height: bubble_h,
10619
+ rx: 3,
10620
+ fill: "#0d1117",
10621
+ stroke: color2,
10622
+ strokeWidth: 0.5,
10623
+ fillOpacity: 0.92
10624
+ }
10625
+ ),
10626
+ lines.map((line, k) => /* @__PURE__ */ jsxRuntime.jsx(
10627
+ "text",
10628
+ {
10629
+ x: bubble_x + 7,
10630
+ y: bubble_y + pad_v + k * line_h + line_h * 0.75,
10631
+ fill: line.accent ? color2 : AXIS_LABEL_COLOR4,
10632
+ fontSize: line.accent ? 10 : 8,
10633
+ fontWeight: line.accent ? 700 : 400,
10634
+ children: line.text
10635
+ },
10636
+ k
10637
+ ))
10638
+ ] });
10639
+ })()
10640
+ ]
10641
+ }
10642
+ );
10643
+ }
10431
10644
 
10432
10645
  // src/assets/celebration-chime.mp3
10433
10646
  var celebration_chime_default = "data:text/plain;charset=utf-8,";
@@ -10954,6 +11167,7 @@ exports.DropdownMenuTrigger = DropdownMenuTrigger;
10954
11167
  exports.EmptyState = EmptyState;
10955
11168
  exports.ErrorBanner = ErrorBanner;
10956
11169
  exports.ErrorPage = ErrorPage;
11170
+ exports.FunnelChart = FunnelChart;
10957
11171
  exports.HazoContextProvider = HazoContextProvider;
10958
11172
  exports.HazoUiConfirmDialog = HazoUiConfirmDialog;
10959
11173
  exports.HazoUiDialog = HazoUiDialog;