hazo_ui 6.1.0 → 6.3.1

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,7 +5,101 @@ 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.1.0 (unreleased)
8
+ ## v6.3.1 (2026-07-29)
9
+
10
+ ### Fixed — test-app builds again
11
+
12
+ `npm run build:test-app` had been failing, which meant the 15 harness cases
13
+ added in 6.3.0 were never actually executed. Three separate causes, all in the
14
+ test-app rather than the library:
15
+
16
+ - The package had no `build:test-app` script at all (it used non-standard
17
+ `test:build` / `test:dev` names), and the test-app called `next` directly
18
+ instead of going through the `scripts/next.mjs` launcher the workspace
19
+ standard requires. Both adopted; `dev:test-app`/`build:test-app` are now the
20
+ entry points and `PORT` defaults to 3300.
21
+ - `next build` died with "ESM packages (hazo_core) need to be imported" via
22
+ `hazo_connect`'s ESM dist. Fixed with `experimental.esmExternals: 'loose'`.
23
+ Neither `transpilePackages` nor `serverComponentsExternalPackages` helps
24
+ here — the latter matches bare package specifiers, and the workspace symlink
25
+ makes the import trace resolve to a relative path that never matches.
26
+ - Type-check then failed on `CSSProperties is not assignable to
27
+ CSSProperties`: two `@types/react` copies (root 18.3.31, nested 18.3.28),
28
+ with source-aliased `hazo_ui` resolving against one and the test-app's own
29
+ files against the other. Pinned both to the root copy via tsconfig `paths`.
30
+
31
+ ### Note on release history
32
+
33
+ `v6.2.0` was committed but never published; `v6.3.0` was published to npm on
34
+ 2026-07-21 *before* its source was committed. npm therefore went
35
+ 6.1.0 → 6.3.0, and for eight days git and npm disagreed about what 6.3.0 was.
36
+ Both are reconciled as of this entry — noted here because neither history
37
+ tells that story on its own.
38
+
39
+ ## v6.3.0 (2026-07-21)
40
+
41
+ ### New
42
+ - feat: add `useLongPress()` (touch-only long-press hook) — ported as-is
43
+ from Kinstripe's `src/lib/hooks/useLongPress.ts` (already fully generic;
44
+ no app coupling to remove). Every returned handler
45
+ (`onPointerDown`/`onPointerMove`/`onPointerUp`/`onPointerCancel`/`onClick`)
46
+ is a no-op on fine-pointer (mouse) devices — desktop should keep using
47
+ `onContextMenu` for right-click. Cancels on movement past
48
+ `moveThresholdPx`, early `pointerup`, a second `pointerdown`
49
+ (multi-touch), or `pointercancel`.
50
+ - feat: add `announce()` + `<LiveRegion>` — a screen-reader announcement
51
+ bus (a `window` CustomEvent dispatched by `announce()`, consumed by a
52
+ mounted `<LiveRegion>`), generalised from Kinstripe's `announce.ts` +
53
+ `KsLiveRegion`. Both the hardcoded event name (`"kinstripe:announce"`)
54
+ and the sr-only class name (`"kn-sr-only"`) are now configurable:
55
+ `announce(message, priority, { eventName })` and
56
+ `<LiveRegion eventName className clearAfterMs />`, defaulting to
57
+ `DEFAULT_ANNOUNCE_EVENT` and Tailwind's built-in `"sr-only"` utility so
58
+ it works out of the box. Keeps the two-region (polite/assertive) design,
59
+ the default 6s self-clear, and the duplicate-text re-announce trick.
60
+ `announce()` is also exported from the server-safe `hazo_ui/utils`
61
+ subpath (same dual-export pattern as `cn`).
62
+ - feat: add `useScrollRestore()` — localStorage-backed scroll-position
63
+ save/load, generalised from Kinstripe's `useScrollRestore`. Keeps the
64
+ same `(treeId, viewName)` positional signature; the previously-hardcoded
65
+ `"kinstripe.scrollPos"` key prefix is now an optional third argument
66
+ (`{ prefix }`, default `DEFAULT_SCROLL_RESTORE_PREFIX`). The underlying
67
+ pure helpers (`buildScrollRestoreKey`, `saveScrollPos`, `loadScrollPos`)
68
+ are split into `lib/scroll_restore.ts` (same hook/lib split as
69
+ `useRememberedState`/`remembered_state.ts`) and also exported from
70
+ `hazo_ui/utils`.
71
+ - Demo pages added under `test-app/app/{long-press,live-region,scroll-restore}/`,
72
+ new "Ported utilities (v6.3)" sidebar section, and five new
73
+ `hazo_ui/test-harness` scenarios (`use_long_press`, `announce_dispatch`,
74
+ `live_region_round_trip`, `scroll_restore_helpers`,
75
+ `use_scroll_restore_hook`) registered in
76
+ `test-app/app/harness-self-test/hazo_ui_utility_ports_scenarios.ts` — the
77
+ pointer/DOM-driven ones mount a real React tree via `react-dom/client`
78
+ against the test-app's live browser DOM rather than settling for an
79
+ export-shape check.
80
+
81
+ ## v6.2.0 (2026-07-21)
82
+
83
+ ### New
84
+ - feat: add `useRememberedState<T>()` hook (FR-020) — a generic
85
+ localStorage-backed "remember last-used state across visits" hook:
86
+ restore-once-on-mount (ref-guarded, immune to `restore` identity churn),
87
+ a debounced write (`debounceMs`, default `600`), and an optional
88
+ `skipWhenUrlParam` escape hatch so an explicit shared-link/saved-record
89
+ query param always wins over remembered state. Generalised from
90
+ stocktools's app-local `useToolMemory` — the caller now supplies the full
91
+ storage `key` and generic `snapshot`/`restore` callbacks instead of a
92
+ hardcoded key prefix and `Seed`-typed shape.
93
+ - feat: add pure helpers `readRememberedState`, `writeRememberedState`,
94
+ `hasUrlParam` backing the hook — SSR-safe, JSON-parse/stringify with
95
+ corrupt-data tolerance, framework-free. Exported from **both** the main
96
+ `hazo_ui` barrel and the server-safe `hazo_ui/utils` subpath (same
97
+ dual-export pattern as `cn`), so Server Components can read/write
98
+ remembered state without pulling in the `"use client"` bundle.
99
+ - Demo page added to `test-app/app/remembered-state/` under the new
100
+ "Remembered state (v6.2)" sidebar section.
101
+
102
+ ## v6.1.0 (2026-07-17)
9
103
 
10
104
  ### New
11
105
  - feat: add `permissionToast` helper (FR-017) — an opinionated wrapper around
@@ -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
@@ -11944,6 +11944,253 @@ function AppFooter({ variant, footer, identity, className }) {
11944
11944
  );
11945
11945
  }
11946
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
+
11947
12194
  Object.defineProperty(exports, "rawToast", {
11948
12195
  enumerable: true,
11949
12196
  get: function () { return sonner.toast; }
@@ -12007,6 +12254,7 @@ exports.CollapsibleTrigger = CollapsibleTrigger2;
12007
12254
  exports.CommandNodeExtension = CommandNodeExtension;
12008
12255
  exports.CommandPill = CommandPill;
12009
12256
  exports.CommandPopover = CommandPopover;
12257
+ exports.DEFAULT_ANNOUNCE_EVENT = DEFAULT_ANNOUNCE_EVENT;
12010
12258
  exports.DateRangeSelector = DateRangeSelector;
12011
12259
  exports.Drawer = Drawer;
12012
12260
  exports.DrawerClose = DrawerClose;
@@ -12076,6 +12324,7 @@ exports.Input = Input;
12076
12324
  exports.InputAffix = InputAffix;
12077
12325
  exports.InverseSparkline = InverseSparkline;
12078
12326
  exports.Label = Label3;
12327
+ exports.LiveRegion = LiveRegion;
12079
12328
  exports.LoadingTimeout = LoadingTimeout;
12080
12329
  exports.Logo = Logo;
12081
12330
  exports.MarkdownEditor = MarkdownEditor;
@@ -12137,6 +12386,7 @@ exports.Tooltip = Tooltip;
12137
12386
  exports.TooltipContent = TooltipContent;
12138
12387
  exports.TooltipProvider = TooltipProvider;
12139
12388
  exports.TooltipTrigger = TooltipTrigger;
12389
+ exports.announce = announce;
12140
12390
  exports.applyKanbanFilter = applyKanbanFilter;
12141
12391
  exports.buttonGroupVariants = buttonGroupVariants;
12142
12392
  exports.celebrate = celebrate;
@@ -12149,11 +12399,13 @@ exports.format_num = format_num;
12149
12399
  exports.generateUUID = generateUUID;
12150
12400
  exports.get_hazo_ui_config = get_hazo_ui_config;
12151
12401
  exports.get_logger = get_logger;
12402
+ exports.hasUrlParam = hasUrlParam;
12152
12403
  exports.isLucideValue = isLucideValue;
12153
12404
  exports.median = median;
12154
12405
  exports.parse_commands_from_text = parse_commands_from_text;
12155
12406
  exports.permissionToast = permissionToast;
12156
12407
  exports.pick_x_label_indices = pick_x_label_indices;
12408
+ exports.readRememberedState = readRememberedState;
12157
12409
  exports.reset_hazo_ui_config = reset_hazo_ui_config;
12158
12410
  exports.resolve_animation_classes = resolve_animation_classes;
12159
12411
  exports.safeHref = safeHref;
@@ -12172,11 +12424,15 @@ exports.useFullscreen = useFullscreen;
12172
12424
  exports.useIsMobile = useIsMobile;
12173
12425
  exports.useLoadingState = useLoadingState;
12174
12426
  exports.useLocalStorage = useLocalStorage;
12427
+ exports.useLongPress = useLongPress;
12175
12428
  exports.useMediaQuery = useMediaQuery;
12429
+ exports.useRememberedState = useRememberedState;
12430
+ exports.useScrollRestore = useScrollRestore;
12176
12431
  exports.useSessionStorage = useSessionStorage;
12177
12432
  exports.useViewport = useViewport;
12178
12433
  exports.useWakeLock = useWakeLock;
12179
12434
  exports.use_fullscreen = use_fullscreen;
12180
12435
  exports.use_wake_lock = use_wake_lock;
12436
+ exports.writeRememberedState = writeRememberedState;
12181
12437
  //# sourceMappingURL=index.cjs.map
12182
12438
  //# sourceMappingURL=index.cjs.map