hazo_ui 6.0.0 → 6.3.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,77 @@ 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
+ ## v6.3.0 (2026-07-21)
9
+
10
+ ### New
11
+ - feat: add `useLongPress()` (touch-only long-press hook) — ported as-is
12
+ from Kinstripe's `src/lib/hooks/useLongPress.ts` (already fully generic;
13
+ no app coupling to remove). Every returned handler
14
+ (`onPointerDown`/`onPointerMove`/`onPointerUp`/`onPointerCancel`/`onClick`)
15
+ is a no-op on fine-pointer (mouse) devices — desktop should keep using
16
+ `onContextMenu` for right-click. Cancels on movement past
17
+ `moveThresholdPx`, early `pointerup`, a second `pointerdown`
18
+ (multi-touch), or `pointercancel`.
19
+ - feat: add `announce()` + `<LiveRegion>` — a screen-reader announcement
20
+ bus (a `window` CustomEvent dispatched by `announce()`, consumed by a
21
+ mounted `<LiveRegion>`), generalised from Kinstripe's `announce.ts` +
22
+ `KsLiveRegion`. Both the hardcoded event name (`"kinstripe:announce"`)
23
+ and the sr-only class name (`"kn-sr-only"`) are now configurable:
24
+ `announce(message, priority, { eventName })` and
25
+ `<LiveRegion eventName className clearAfterMs />`, defaulting to
26
+ `DEFAULT_ANNOUNCE_EVENT` and Tailwind's built-in `"sr-only"` utility so
27
+ it works out of the box. Keeps the two-region (polite/assertive) design,
28
+ the default 6s self-clear, and the duplicate-text re-announce trick.
29
+ `announce()` is also exported from the server-safe `hazo_ui/utils`
30
+ subpath (same dual-export pattern as `cn`).
31
+ - feat: add `useScrollRestore()` — localStorage-backed scroll-position
32
+ save/load, generalised from Kinstripe's `useScrollRestore`. Keeps the
33
+ same `(treeId, viewName)` positional signature; the previously-hardcoded
34
+ `"kinstripe.scrollPos"` key prefix is now an optional third argument
35
+ (`{ prefix }`, default `DEFAULT_SCROLL_RESTORE_PREFIX`). The underlying
36
+ pure helpers (`buildScrollRestoreKey`, `saveScrollPos`, `loadScrollPos`)
37
+ are split into `lib/scroll_restore.ts` (same hook/lib split as
38
+ `useRememberedState`/`remembered_state.ts`) and also exported from
39
+ `hazo_ui/utils`.
40
+ - Demo pages added under `test-app/app/{long-press,live-region,scroll-restore}/`,
41
+ new "Ported utilities (v6.3)" sidebar section, and five new
42
+ `hazo_ui/test-harness` scenarios (`use_long_press`, `announce_dispatch`,
43
+ `live_region_round_trip`, `scroll_restore_helpers`,
44
+ `use_scroll_restore_hook`) registered in
45
+ `test-app/app/harness-self-test/hazo_ui_utility_ports_scenarios.ts` — the
46
+ pointer/DOM-driven ones mount a real React tree via `react-dom/client`
47
+ against the test-app's live browser DOM rather than settling for an
48
+ export-shape check.
49
+
50
+ ## v6.2.0 (2026-07-21)
51
+
52
+ ### New
53
+ - feat: add `useRememberedState<T>()` hook (FR-020) — a generic
54
+ localStorage-backed "remember last-used state across visits" hook:
55
+ restore-once-on-mount (ref-guarded, immune to `restore` identity churn),
56
+ a debounced write (`debounceMs`, default `600`), and an optional
57
+ `skipWhenUrlParam` escape hatch so an explicit shared-link/saved-record
58
+ query param always wins over remembered state. Generalised from
59
+ stocktools's app-local `useToolMemory` — the caller now supplies the full
60
+ storage `key` and generic `snapshot`/`restore` callbacks instead of a
61
+ hardcoded key prefix and `Seed`-typed shape.
62
+ - feat: add pure helpers `readRememberedState`, `writeRememberedState`,
63
+ `hasUrlParam` backing the hook — SSR-safe, JSON-parse/stringify with
64
+ corrupt-data tolerance, framework-free. Exported from **both** the main
65
+ `hazo_ui` barrel and the server-safe `hazo_ui/utils` subpath (same
66
+ dual-export pattern as `cn`), so Server Components can read/write
67
+ remembered state without pulling in the `"use client"` bundle.
68
+ - Demo page added to `test-app/app/remembered-state/` under the new
69
+ "Remembered state (v6.2)" sidebar section.
70
+
71
+ ## v6.1.0 (2026-07-17)
72
+
73
+ ### New
74
+ - feat: add `permissionToast` helper (FR-017) — an opinionated wrapper around
75
+ `errorToast` that standardizes permission-failure toasts (fixed "Permission
76
+ required" title, composed missing-permission description, optional action
77
+ button). Exported alongside `PermissionToastOptions` from `hazo_ui`.
78
+
8
79
  ## v6.0.0 (2026-07-11)
9
80
 
10
81
  Major — the P1 hazo_theme rehome. hazo_ui stops owning theme tokens, preset, and provider;
package/README.md CHANGED
@@ -226,7 +226,7 @@ Lightweight, opinionated components for the four ubiquitous async states: **load
226
226
  - **[ErrorPage](#errorpage)** - Full-page error fallback with title, description, error code tag, correlation id, and CTAs.
227
227
  - **[LoadingTimeout](#loadingtimeout)** - Wraps a loading region and escalates messaging at 5s / 15s / 30s thresholds before showing a retry banner.
228
228
  - **[ProgressiveImage](#progressiveimage)** - Three-stage image render: grey placeholder → blurred LQIP → sharp final image.
229
- - **[HazoUiToaster + toast helpers](#toasts-hazouitoaster--successtoast--errortoast)** - `sonner`-backed toaster with `successToast()` and `errorToast()` imperative helpers.
229
+ - **[HazoUiToaster + toast helpers](#toasts-hazouitoaster--successtoast--errortoast)** - `sonner`-backed toaster with `successToast()`, `errorToast()`, and `permissionToast()` imperative helpers.
230
230
  - **[useLoadingState](#useloadingstate)** hook - `{ isLoading, setLoading, withLoading }` with an async wrapper.
231
231
  - **[useErrorDisplay](#useerrordisplay)** hook - Passive `{ error, setError, clearError }` that coerces `Error` instances to strings.
232
232
 
@@ -3443,6 +3443,29 @@ try { await sync(); } catch (e) {
3443
3443
  | `duration` | `number` (ms) | `3000` success / `5000` error |
3444
3444
  | `action` | `{ label: string; onClick: () => void }` | — |
3445
3445
 
3446
+ **`permissionToast(opts: PermissionToastOptions)`** — an opinionated wrapper around
3447
+ `errorToast` for permission-failure UX. Always titles the toast `"Permission required"`
3448
+ and composes a description from the missing permission key(s) (and optional human
3449
+ descriptions), so every "you can't do that" moment in the app reads consistently:
3450
+
3451
+ ```tsx
3452
+ import { permissionToast } from "hazo_ui";
3453
+
3454
+ permissionToast({
3455
+ missingPermissions: ["env.migrate"],
3456
+ descriptions: { "env.migrate": "Migrate environments" },
3457
+ action: { label: "Request access", onClick: () => requestAccess() },
3458
+ });
3459
+ // → "Permission required" / "Missing: `env.migrate` — Migrate environments"
3460
+ ```
3461
+
3462
+ | Prop | Type | Default |
3463
+ |---|---|---|
3464
+ | `missingPermissions` | `string[]` | **required** |
3465
+ | `descriptions` | `Record<string, string>` | — |
3466
+ | `action` | `{ label: string; onClick: () => void }` | — |
3467
+ | `duration` | `number` (ms) | `5000` |
3468
+
3446
3469
  Also exports `rawToast` (re-export of sonner's `toast`) for advanced use cases.
3447
3470
 
3448
3471
  ### useWakeLock / use_wake_lock
@@ -0,0 +1,92 @@
1
+ import { ClassValue } from 'clsx';
2
+
3
+ /**
4
+ * Merges class names using clsx and tailwind-merge
5
+ * @param inputs - Class values to merge
6
+ * @returns Merged class string
7
+ */
8
+ declare function cn(...inputs: ClassValue[]): string;
9
+ /**
10
+ * Sanitizes a user/admin-supplied href against a scheme allowlist to prevent
11
+ * `javascript:`/`data:`/`vbscript:` URI XSS when the value is rendered into an
12
+ * `<a href>`. Allows http(s), mailto, and same-origin absolute paths (`/…` but
13
+ * not protocol-relative `//…`). Returns `undefined` for anything else, so the
14
+ * anchor renders with no href rather than an active malicious one.
15
+ * @param url - Candidate href (may be undefined)
16
+ * @returns The original url if allowed, otherwise undefined
17
+ */
18
+ declare function safeHref(url?: string): string | undefined;
19
+
20
+ /**
21
+ * Reads and JSON-parses the remembered value stored under `key`. SSR-safe —
22
+ * returns `null` on the server, when nothing is stored, or when the stored
23
+ * value is corrupt/unparsable.
24
+ *
25
+ * @param key - localStorage key to read.
26
+ *
27
+ * @example
28
+ * const seed = readRememberedState<MySeed>("myapp:tool:cgt");
29
+ */
30
+ declare function readRememberedState<T>(key: string): T | null;
31
+ /**
32
+ * JSON-stringifies and writes `snapshot` under `key`. SSR-safe — no-ops on
33
+ * the server. Quota / private-mode errors are swallowed; losing a memory
34
+ * write is non-fatal.
35
+ *
36
+ * @param key - localStorage key to write.
37
+ * @param snapshot - Serialisable value to remember.
38
+ *
39
+ * @example
40
+ * writeRememberedState("myapp:tool:cgt", { proceeds: 1000 });
41
+ */
42
+ declare function writeRememberedState<T>(key: string, snapshot: T): void;
43
+ /**
44
+ * True when `paramName` is present in the given search string (e.g.
45
+ * `window.location.search`). Generic form of the "an explicit shared-link /
46
+ * saved-record query param always wins over remembered state" precedence
47
+ * check — callers supply the param name (e.g. `"restore"`); this module
48
+ * never hardcodes one.
49
+ *
50
+ * @param search - A URL search string, e.g. `window.location.search`.
51
+ * @param paramName - The query param to look for.
52
+ *
53
+ * @example
54
+ * hasUrlParam("?restore=abc123", "restore"); // true
55
+ */
56
+ declare function hasUrlParam(search: string, paramName: string): boolean;
57
+
58
+ type AnnouncePriority = "polite" | "assertive";
59
+ interface LiveAnnouncement {
60
+ message: string;
61
+ priority: AnnouncePriority;
62
+ }
63
+ interface AnnounceOptions {
64
+ /** Custom-event name the paired `LiveRegion` listens on. Must match the `eventName` it was mounted with. Default `DEFAULT_ANNOUNCE_EVENT`. */
65
+ eventName?: string;
66
+ }
67
+ /** Default `window` CustomEvent name used when `announce()` / `LiveRegion` are not given an explicit `eventName`. */
68
+ declare const DEFAULT_ANNOUNCE_EVENT = "hazo_ui:announce";
69
+ /**
70
+ * Dispatch a screen-reader announcement via a `window` CustomEvent. Safe to
71
+ * call from anywhere in the client tree — no-ops during SSR or before a
72
+ * `LiveRegion` has mounted (fire-and-forget; there's no queue/replay).
73
+ *
74
+ * Use `"polite"` (default) for ambient updates ("Sarah joined the canvas",
75
+ * "2 new notifications"). Use `"assertive"` only for things the user must
76
+ * hear immediately (errors that interrupt a task).
77
+ *
78
+ * @param message - Text to announce. Empty / whitespace-only messages are dropped (no dispatch).
79
+ * @param priority - `"polite"` (default) or `"assertive"`.
80
+ * @param options.eventName - Custom-event name to dispatch on. Default `DEFAULT_ANNOUNCE_EVENT`.
81
+ *
82
+ * @example
83
+ * announce("2 new notifications");
84
+ * announce("Save failed — check your connection", "assertive");
85
+ *
86
+ * @example
87
+ * // namespaced channel — must match the eventName the LiveRegion was mounted with
88
+ * announce("Saved", "polite", { eventName: "myapp:announce" });
89
+ */
90
+ declare function announce(message: string, priority?: AnnouncePriority, options?: AnnounceOptions): void;
91
+
92
+ export { type AnnounceOptions as A, DEFAULT_ANNOUNCE_EVENT as D, type LiveAnnouncement as L, type AnnouncePriority as a, announce as b, cn as c, hasUrlParam as h, readRememberedState as r, safeHref as s, writeRememberedState as w };
@@ -0,0 +1,92 @@
1
+ import { ClassValue } from 'clsx';
2
+
3
+ /**
4
+ * Merges class names using clsx and tailwind-merge
5
+ * @param inputs - Class values to merge
6
+ * @returns Merged class string
7
+ */
8
+ declare function cn(...inputs: ClassValue[]): string;
9
+ /**
10
+ * Sanitizes a user/admin-supplied href against a scheme allowlist to prevent
11
+ * `javascript:`/`data:`/`vbscript:` URI XSS when the value is rendered into an
12
+ * `<a href>`. Allows http(s), mailto, and same-origin absolute paths (`/…` but
13
+ * not protocol-relative `//…`). Returns `undefined` for anything else, so the
14
+ * anchor renders with no href rather than an active malicious one.
15
+ * @param url - Candidate href (may be undefined)
16
+ * @returns The original url if allowed, otherwise undefined
17
+ */
18
+ declare function safeHref(url?: string): string | undefined;
19
+
20
+ /**
21
+ * Reads and JSON-parses the remembered value stored under `key`. SSR-safe —
22
+ * returns `null` on the server, when nothing is stored, or when the stored
23
+ * value is corrupt/unparsable.
24
+ *
25
+ * @param key - localStorage key to read.
26
+ *
27
+ * @example
28
+ * const seed = readRememberedState<MySeed>("myapp:tool:cgt");
29
+ */
30
+ declare function readRememberedState<T>(key: string): T | null;
31
+ /**
32
+ * JSON-stringifies and writes `snapshot` under `key`. SSR-safe — no-ops on
33
+ * the server. Quota / private-mode errors are swallowed; losing a memory
34
+ * write is non-fatal.
35
+ *
36
+ * @param key - localStorage key to write.
37
+ * @param snapshot - Serialisable value to remember.
38
+ *
39
+ * @example
40
+ * writeRememberedState("myapp:tool:cgt", { proceeds: 1000 });
41
+ */
42
+ declare function writeRememberedState<T>(key: string, snapshot: T): void;
43
+ /**
44
+ * True when `paramName` is present in the given search string (e.g.
45
+ * `window.location.search`). Generic form of the "an explicit shared-link /
46
+ * saved-record query param always wins over remembered state" precedence
47
+ * check — callers supply the param name (e.g. `"restore"`); this module
48
+ * never hardcodes one.
49
+ *
50
+ * @param search - A URL search string, e.g. `window.location.search`.
51
+ * @param paramName - The query param to look for.
52
+ *
53
+ * @example
54
+ * hasUrlParam("?restore=abc123", "restore"); // true
55
+ */
56
+ declare function hasUrlParam(search: string, paramName: string): boolean;
57
+
58
+ type AnnouncePriority = "polite" | "assertive";
59
+ interface LiveAnnouncement {
60
+ message: string;
61
+ priority: AnnouncePriority;
62
+ }
63
+ interface AnnounceOptions {
64
+ /** Custom-event name the paired `LiveRegion` listens on. Must match the `eventName` it was mounted with. Default `DEFAULT_ANNOUNCE_EVENT`. */
65
+ eventName?: string;
66
+ }
67
+ /** Default `window` CustomEvent name used when `announce()` / `LiveRegion` are not given an explicit `eventName`. */
68
+ declare const DEFAULT_ANNOUNCE_EVENT = "hazo_ui:announce";
69
+ /**
70
+ * Dispatch a screen-reader announcement via a `window` CustomEvent. Safe to
71
+ * call from anywhere in the client tree — no-ops during SSR or before a
72
+ * `LiveRegion` has mounted (fire-and-forget; there's no queue/replay).
73
+ *
74
+ * Use `"polite"` (default) for ambient updates ("Sarah joined the canvas",
75
+ * "2 new notifications"). Use `"assertive"` only for things the user must
76
+ * hear immediately (errors that interrupt a task).
77
+ *
78
+ * @param message - Text to announce. Empty / whitespace-only messages are dropped (no dispatch).
79
+ * @param priority - `"polite"` (default) or `"assertive"`.
80
+ * @param options.eventName - Custom-event name to dispatch on. Default `DEFAULT_ANNOUNCE_EVENT`.
81
+ *
82
+ * @example
83
+ * announce("2 new notifications");
84
+ * announce("Save failed — check your connection", "assertive");
85
+ *
86
+ * @example
87
+ * // namespaced channel — must match the eventName the LiveRegion was mounted with
88
+ * announce("Saved", "polite", { eventName: "myapp:announce" });
89
+ */
90
+ declare function announce(message: string, priority?: AnnouncePriority, options?: AnnounceOptions): void;
91
+
92
+ export { type AnnounceOptions as A, DEFAULT_ANNOUNCE_EVENT as D, type LiveAnnouncement as L, type AnnouncePriority as a, announce as b, cn as c, hasUrlParam as h, readRememberedState as r, safeHref as s, writeRememberedState as w };
package/dist/index.cjs CHANGED
@@ -8819,6 +8819,23 @@ function errorToast({ title, description, duration = 5e3, action }) {
8819
8819
  action: action ? { label: action.label, onClick: action.onClick } : void 0
8820
8820
  });
8821
8821
  }
8822
+ function permissionToast({
8823
+ missingPermissions,
8824
+ descriptions,
8825
+ action,
8826
+ duration = 5e3
8827
+ }) {
8828
+ const description = missingPermissions.length === 0 ? "You do not have permission to perform this action." : `Missing: ${missingPermissions.map((key) => {
8829
+ const label = descriptions?.[key];
8830
+ return label ? `\`${key}\` \u2014 ${label}` : `\`${key}\``;
8831
+ }).join("; ")}`;
8832
+ return errorToast({
8833
+ title: "Permission required",
8834
+ description,
8835
+ action,
8836
+ duration
8837
+ });
8838
+ }
8822
8839
  function useLoadingState(initial = false) {
8823
8840
  const [isLoading, setLoading] = React27.useState(initial);
8824
8841
  const withLoading = React27.useCallback(async (fn) => {
@@ -11927,6 +11944,253 @@ function AppFooter({ variant, footer, identity, className }) {
11927
11944
  );
11928
11945
  }
11929
11946
 
11947
+ // src/lib/remembered_state.ts
11948
+ function readRememberedState(key) {
11949
+ if (typeof window === "undefined") return null;
11950
+ try {
11951
+ const raw = window.localStorage.getItem(key);
11952
+ return raw ? JSON.parse(raw) : null;
11953
+ } catch {
11954
+ return null;
11955
+ }
11956
+ }
11957
+ function writeRememberedState(key, snapshot) {
11958
+ if (typeof window === "undefined") return;
11959
+ try {
11960
+ window.localStorage.setItem(key, JSON.stringify(snapshot));
11961
+ } catch {
11962
+ }
11963
+ }
11964
+ function hasUrlParam(search, paramName) {
11965
+ try {
11966
+ return new URLSearchParams(search).has(paramName);
11967
+ } catch {
11968
+ return false;
11969
+ }
11970
+ }
11971
+
11972
+ // src/hooks/use_remembered_state.ts
11973
+ function useRememberedState({
11974
+ key,
11975
+ snapshot,
11976
+ restore,
11977
+ enabled = true,
11978
+ debounceMs = 600,
11979
+ skipWhenUrlParam
11980
+ }) {
11981
+ const restoreRef = React27.useRef(restore);
11982
+ React27.useEffect(() => {
11983
+ restoreRef.current = restore;
11984
+ });
11985
+ const didRestore = React27.useRef(false);
11986
+ React27.useEffect(() => {
11987
+ if (didRestore.current) return;
11988
+ didRestore.current = true;
11989
+ if (!enabled) return;
11990
+ if (typeof window === "undefined") return;
11991
+ if (skipWhenUrlParam && hasUrlParam(window.location.search, skipWhenUrlParam)) return;
11992
+ const remembered = readRememberedState(key);
11993
+ if (remembered === null) return;
11994
+ try {
11995
+ restoreRef.current(remembered);
11996
+ } catch {
11997
+ }
11998
+ }, [key, enabled, skipWhenUrlParam]);
11999
+ const snapshotJson = JSON.stringify(snapshot);
12000
+ const writeTimer = React27.useRef(null);
12001
+ React27.useEffect(() => {
12002
+ if (!enabled) return;
12003
+ if (writeTimer.current) clearTimeout(writeTimer.current);
12004
+ writeTimer.current = setTimeout(() => {
12005
+ writeRememberedState(key, snapshot);
12006
+ }, debounceMs);
12007
+ return () => {
12008
+ if (writeTimer.current) clearTimeout(writeTimer.current);
12009
+ };
12010
+ }, [key, snapshotJson, enabled, debounceMs]);
12011
+ }
12012
+ var COARSE_QUERY = "(pointer: coarse)";
12013
+ function subscribe_coarse(notify) {
12014
+ if (typeof window === "undefined") return () => {
12015
+ };
12016
+ const mql = window.matchMedia(COARSE_QUERY);
12017
+ mql.addEventListener("change", notify);
12018
+ return () => mql.removeEventListener("change", notify);
12019
+ }
12020
+ function get_coarse_snapshot() {
12021
+ return window.matchMedia(COARSE_QUERY).matches;
12022
+ }
12023
+ function get_coarse_server_snapshot() {
12024
+ return false;
12025
+ }
12026
+ function useLongPress({
12027
+ onLongPress,
12028
+ delayMs = 500,
12029
+ moveThresholdPx = 8,
12030
+ hapticMs = 10
12031
+ }) {
12032
+ const isCoarse = React27.useSyncExternalStore(
12033
+ subscribe_coarse,
12034
+ get_coarse_snapshot,
12035
+ get_coarse_server_snapshot
12036
+ );
12037
+ const timerRef = React27.useRef(null);
12038
+ const originRef = React27.useRef(null);
12039
+ const firedRef = React27.useRef(false);
12040
+ const eventRef = React27.useRef(null);
12041
+ const cancel = React27.useCallback(() => {
12042
+ if (timerRef.current !== null) {
12043
+ clearTimeout(timerRef.current);
12044
+ timerRef.current = null;
12045
+ }
12046
+ originRef.current = null;
12047
+ eventRef.current = null;
12048
+ }, []);
12049
+ React27.useEffect(() => () => cancel(), [cancel]);
12050
+ const noop = React27.useCallback(() => {
12051
+ }, []);
12052
+ const handlePointerDown = React27.useCallback(
12053
+ (e) => {
12054
+ if (originRef.current && originRef.current.pointerId !== e.pointerId) {
12055
+ cancel();
12056
+ return;
12057
+ }
12058
+ firedRef.current = false;
12059
+ originRef.current = { x: e.clientX, y: e.clientY, pointerId: e.pointerId };
12060
+ eventRef.current = e;
12061
+ timerRef.current = setTimeout(() => {
12062
+ timerRef.current = null;
12063
+ firedRef.current = true;
12064
+ if (hapticMs > 0 && typeof navigator !== "undefined" && "vibrate" in navigator) {
12065
+ try {
12066
+ navigator.vibrate?.(hapticMs);
12067
+ } catch {
12068
+ }
12069
+ }
12070
+ const evt = eventRef.current;
12071
+ if (evt) onLongPress(evt);
12072
+ }, delayMs);
12073
+ },
12074
+ [delayMs, hapticMs, cancel, onLongPress]
12075
+ );
12076
+ const handlePointerMove = React27.useCallback(
12077
+ (e) => {
12078
+ if (!originRef.current) return;
12079
+ const dx = e.clientX - originRef.current.x;
12080
+ const dy = e.clientY - originRef.current.y;
12081
+ if (Math.hypot(dx, dy) > moveThresholdPx) cancel();
12082
+ },
12083
+ [moveThresholdPx, cancel]
12084
+ );
12085
+ const handlePointerUpOrCancel = React27.useCallback(() => {
12086
+ cancel();
12087
+ }, [cancel]);
12088
+ const handleClick = React27.useCallback((e) => {
12089
+ if (firedRef.current) {
12090
+ e.preventDefault();
12091
+ e.stopPropagation();
12092
+ firedRef.current = false;
12093
+ }
12094
+ }, []);
12095
+ if (!isCoarse) {
12096
+ return {
12097
+ onPointerDown: noop,
12098
+ onPointerMove: noop,
12099
+ onPointerUp: noop,
12100
+ onPointerCancel: noop,
12101
+ onClick: noop
12102
+ };
12103
+ }
12104
+ return {
12105
+ onPointerDown: handlePointerDown,
12106
+ onPointerMove: handlePointerMove,
12107
+ onPointerUp: handlePointerUpOrCancel,
12108
+ onPointerCancel: handlePointerUpOrCancel,
12109
+ onClick: handleClick
12110
+ };
12111
+ }
12112
+
12113
+ // src/lib/announce.ts
12114
+ var DEFAULT_ANNOUNCE_EVENT = "hazo_ui:announce";
12115
+ function announce(message, priority = "polite", options) {
12116
+ if (typeof window === "undefined") return;
12117
+ const trimmed = message.trim();
12118
+ if (!trimmed) return;
12119
+ const eventName = options?.eventName ?? DEFAULT_ANNOUNCE_EVENT;
12120
+ window.dispatchEvent(
12121
+ new CustomEvent(eventName, {
12122
+ detail: { message: trimmed, priority }
12123
+ })
12124
+ );
12125
+ }
12126
+ function LiveRegion({
12127
+ eventName = DEFAULT_ANNOUNCE_EVENT,
12128
+ className = "sr-only",
12129
+ clearAfterMs = 6e3
12130
+ }) {
12131
+ const [polite, set_polite] = React27.useState("");
12132
+ const [assertive, set_assertive] = React27.useState("");
12133
+ const polite_timer = React27.useRef(null);
12134
+ const assertive_timer = React27.useRef(null);
12135
+ React27.useEffect(() => {
12136
+ const handler = (e) => {
12137
+ const detail = e.detail;
12138
+ if (!detail?.message) return;
12139
+ if (detail.priority === "assertive") {
12140
+ if (assertive_timer.current) clearTimeout(assertive_timer.current);
12141
+ set_assertive((prev) => prev === detail.message ? `${detail.message} ` : detail.message);
12142
+ assertive_timer.current = setTimeout(() => set_assertive(""), clearAfterMs);
12143
+ } else {
12144
+ if (polite_timer.current) clearTimeout(polite_timer.current);
12145
+ set_polite((prev) => prev === detail.message ? `${detail.message} ` : detail.message);
12146
+ polite_timer.current = setTimeout(() => set_polite(""), clearAfterMs);
12147
+ }
12148
+ };
12149
+ window.addEventListener(eventName, handler);
12150
+ return () => {
12151
+ window.removeEventListener(eventName, handler);
12152
+ if (polite_timer.current) clearTimeout(polite_timer.current);
12153
+ if (assertive_timer.current) clearTimeout(assertive_timer.current);
12154
+ };
12155
+ }, [eventName, clearAfterMs]);
12156
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
12157
+ /* @__PURE__ */ jsxRuntime.jsx("div", { role: "status", "aria-live": "polite", "aria-atomic": "true", className, children: polite }),
12158
+ /* @__PURE__ */ jsxRuntime.jsx("div", { role: "alert", "aria-live": "assertive", "aria-atomic": "true", className, children: assertive })
12159
+ ] });
12160
+ }
12161
+
12162
+ // src/lib/scroll_restore.ts
12163
+ var DEFAULT_SCROLL_RESTORE_PREFIX = "hazo_ui:scrollPos";
12164
+ function buildScrollRestoreKey(prefix, treeId, viewName) {
12165
+ return `${prefix}.${treeId}.${viewName}`;
12166
+ }
12167
+ function saveScrollPos(key, scrollY) {
12168
+ if (typeof window === "undefined") return;
12169
+ try {
12170
+ window.localStorage.setItem(key, String(Math.round(scrollY)));
12171
+ } catch {
12172
+ }
12173
+ }
12174
+ function loadScrollPos(key) {
12175
+ if (typeof window === "undefined") return null;
12176
+ try {
12177
+ const raw = window.localStorage.getItem(key);
12178
+ if (!raw) return null;
12179
+ const n = Number.parseInt(raw, 10);
12180
+ return Number.isFinite(n) ? n : null;
12181
+ } catch {
12182
+ return null;
12183
+ }
12184
+ }
12185
+
12186
+ // src/hooks/use_scroll_restore.ts
12187
+ function useScrollRestore(treeId, viewName, options) {
12188
+ const key = buildScrollRestoreKey(options?.prefix ?? DEFAULT_SCROLL_RESTORE_PREFIX, treeId, viewName);
12189
+ const save = React27.useCallback((scrollY) => saveScrollPos(key, scrollY), [key]);
12190
+ const load = React27.useCallback(() => loadScrollPos(key), [key]);
12191
+ return { save, load };
12192
+ }
12193
+
11930
12194
  Object.defineProperty(exports, "rawToast", {
11931
12195
  enumerable: true,
11932
12196
  get: function () { return sonner.toast; }
@@ -11990,6 +12254,7 @@ exports.CollapsibleTrigger = CollapsibleTrigger2;
11990
12254
  exports.CommandNodeExtension = CommandNodeExtension;
11991
12255
  exports.CommandPill = CommandPill;
11992
12256
  exports.CommandPopover = CommandPopover;
12257
+ exports.DEFAULT_ANNOUNCE_EVENT = DEFAULT_ANNOUNCE_EVENT;
11993
12258
  exports.DateRangeSelector = DateRangeSelector;
11994
12259
  exports.Drawer = Drawer;
11995
12260
  exports.DrawerClose = DrawerClose;
@@ -12059,6 +12324,7 @@ exports.Input = Input;
12059
12324
  exports.InputAffix = InputAffix;
12060
12325
  exports.InverseSparkline = InverseSparkline;
12061
12326
  exports.Label = Label3;
12327
+ exports.LiveRegion = LiveRegion;
12062
12328
  exports.LoadingTimeout = LoadingTimeout;
12063
12329
  exports.Logo = Logo;
12064
12330
  exports.MarkdownEditor = MarkdownEditor;
@@ -12120,6 +12386,7 @@ exports.Tooltip = Tooltip;
12120
12386
  exports.TooltipContent = TooltipContent;
12121
12387
  exports.TooltipProvider = TooltipProvider;
12122
12388
  exports.TooltipTrigger = TooltipTrigger;
12389
+ exports.announce = announce;
12123
12390
  exports.applyKanbanFilter = applyKanbanFilter;
12124
12391
  exports.buttonGroupVariants = buttonGroupVariants;
12125
12392
  exports.celebrate = celebrate;
@@ -12132,10 +12399,13 @@ exports.format_num = format_num;
12132
12399
  exports.generateUUID = generateUUID;
12133
12400
  exports.get_hazo_ui_config = get_hazo_ui_config;
12134
12401
  exports.get_logger = get_logger;
12402
+ exports.hasUrlParam = hasUrlParam;
12135
12403
  exports.isLucideValue = isLucideValue;
12136
12404
  exports.median = median;
12137
12405
  exports.parse_commands_from_text = parse_commands_from_text;
12406
+ exports.permissionToast = permissionToast;
12138
12407
  exports.pick_x_label_indices = pick_x_label_indices;
12408
+ exports.readRememberedState = readRememberedState;
12139
12409
  exports.reset_hazo_ui_config = reset_hazo_ui_config;
12140
12410
  exports.resolve_animation_classes = resolve_animation_classes;
12141
12411
  exports.safeHref = safeHref;
@@ -12154,11 +12424,15 @@ exports.useFullscreen = useFullscreen;
12154
12424
  exports.useIsMobile = useIsMobile;
12155
12425
  exports.useLoadingState = useLoadingState;
12156
12426
  exports.useLocalStorage = useLocalStorage;
12427
+ exports.useLongPress = useLongPress;
12157
12428
  exports.useMediaQuery = useMediaQuery;
12429
+ exports.useRememberedState = useRememberedState;
12430
+ exports.useScrollRestore = useScrollRestore;
12158
12431
  exports.useSessionStorage = useSessionStorage;
12159
12432
  exports.useViewport = useViewport;
12160
12433
  exports.useWakeLock = useWakeLock;
12161
12434
  exports.use_fullscreen = use_fullscreen;
12162
12435
  exports.use_wake_lock = use_wake_lock;
12436
+ exports.writeRememberedState = writeRememberedState;
12163
12437
  //# sourceMappingURL=index.cjs.map
12164
12438
  //# sourceMappingURL=index.cjs.map