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 +5 -0
- package/README.md +92 -0
- package/dist/index.cjs +214 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +43 -1
- package/dist/index.d.ts +43 -1
- package/dist/index.js +214 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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;
|