@hanzo/ui 8.29.9 → 8.29.10

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.
@@ -0,0 +1,279 @@
1
+ 'use client';"use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.Corner = Corner;
5
+ const jsx_runtime_1 = require("react/jsx-runtime");
6
+ /**
7
+ * Corner — help and preferences, behind the one control that is on every page.
8
+ *
9
+ * <Corner
10
+ * help={[{ label: 'Get help', href: '/support' }, { label: 'Docs', href: 'https://docs.hanzo.ai' }]}
11
+ * language={{ value: locale, options: LANGUAGES, onChange: setLocale }}
12
+ * currency={{ value: fiat, options: CURRENCIES, onChange: setFiat }}
13
+ * />
14
+ *
15
+ * A small `?` sits at the bottom START corner of the window (bottom-left in a
16
+ * left-to-right page, bottom-right in a right-to-left one), clear of the notch
17
+ * and the home indicator. Pressing it opens one card that opens toward the
18
+ * inside: the host's help links first, then Theme, Language and Currency. The
19
+ * three settings a visitor is most likely to want are where they look when they
20
+ * want the page to explain itself, on every surface, in the same place.
21
+ *
22
+ * Theme is the library's own: `@hanzo/appearance`, written through `<Hanzo>`'s
23
+ * resolved stack when there is one (`Look`), so it is stored, painted before
24
+ * first paint and carried across origins exactly as the settings panel does.
25
+ * Language and currency are the HOST's — this package depends on no i18n or
26
+ * money library, so it draws the value it is handed and reports a choice. A row
27
+ * the host passes nothing for is not drawn.
28
+ *
29
+ * Behaviour is the package `Popover`'s: portalled, dismissed by Escape and an
30
+ * outside press, focus trapped while open and handed back to the trigger on
31
+ * close. Escape in a list returns to the card rather than closing it. Arrow keys
32
+ * move through the card's rows and through a list; a long list (more than
33
+ * `LONG`) gets a search field that owns the cursor (`aria-activedescendant`).
34
+ * Nothing animates, so there is no motion to reduce.
35
+ *
36
+ * Web-only — it is fixed to the window — so it ships at `@hanzo/ui/corner`
37
+ * beside `consent`, not on the barrel that promises native.
38
+ */
39
+ const gui_1 = require("@hanzo/gui");
40
+ const lucide_icons_2_1 = require("@hanzogui/lucide-icons-2");
41
+ const appearance_1 = require("@hanzo/appearance");
42
+ const react_1 = require("react");
43
+ const popover_1 = require("../backends/gui/popover.cjs");
44
+ const gesture_1 = require("../backends/gui/gesture.cjs");
45
+ const press_1 = require("../backends/gui/press.cjs");
46
+ const slot_1 = require("../backends/gui/slot.cjs");
47
+ const nav_1 = require("../masthead/nav.cjs");
48
+ const chipSelect_logic_1 = require("../product/chipSelect.logic.cjs");
49
+ const Filters_1 = require("../product/Filters.cjs");
50
+ const instrument_1 = require("../product/instrument.cjs");
51
+ const look_1 = require("../look.cjs");
52
+ const logic_1 = require("./logic.cjs");
53
+ /**
54
+ * The trigger's distance from the window's foot and its start edge: 10px, or the
55
+ * device's inset and 4px where that is wider. A right-to-left page puts the
56
+ * trigger on the right, so the inline edge takes the wider of the two side insets.
57
+ */
58
+ const EDGE = {
59
+ bottom: 'max(10px, calc(var(--safe-bottom, env(safe-area-inset-bottom, 0px)) + 4px))',
60
+ start: 'max(10px, calc(max(var(--safe-left, env(safe-area-inset-left, 0px)), var(--safe-right, env(safe-area-inset-right, 0px))) + 4px))',
61
+ };
62
+ /**
63
+ * A row's height: 36 under a mouse, 44 under a finger. Rows sit edge to edge,
64
+ * so the height itself is the target — an overlay past it would land on the
65
+ * next row. (`[role=button]` gets the same floor from design's base sheet.)
66
+ */
67
+ const ROW = { minH: 36, $touchable: { minH: 44 } };
68
+ /** The keys a list answers. */
69
+ const MOVES = new Set(['ArrowDown', 'ArrowUp', 'Home', 'End', 'PageDown', 'PageUp']);
70
+ /** Everything in the card the arrow keys step through, in document order. */
71
+ const STOPS = '[data-corner-stop], [data-slot="segmented-item"][data-state="checked"]';
72
+ /**
73
+ * The theme, as the person chose it, and the one way to change it.
74
+ *
75
+ * Under `<Hanzo>` it writes through the root's `useAppearance` (its org,
76
+ * install and account layers). Under a host's own gui provider it writes the
77
+ * device's layer and repaints the resolved stack, which is what `useAppearance`
78
+ * would do there. Nobody chose yet: the row marks what the document shows.
79
+ */
80
+ function useTheme() {
81
+ const look = (0, react_1.useContext)(look_1.Look);
82
+ const ground = (0, appearance_1.useScheme)();
83
+ const [own, setOwn] = (0, react_1.useState)(undefined);
84
+ (0, react_1.useEffect)(() => {
85
+ if (!look)
86
+ setOwn((0, appearance_1.read)().theme);
87
+ }, [look]);
88
+ const set = (0, react_1.useCallback)((theme) => {
89
+ if (look)
90
+ return look.set({ theme });
91
+ (0, appearance_1.write)({ ...(0, appearance_1.read)(), theme });
92
+ (0, appearance_1.apply)((0, appearance_1.current)().pref);
93
+ setOwn(theme);
94
+ }, [look]);
95
+ return [(look ? look.pref.theme : own) ?? ground, set];
96
+ }
97
+ function Corner({ help = [], language, currency, words }) {
98
+ const w = { ...logic_1.WORDS, ...words };
99
+ const [open, setOpen] = (0, react_1.useState)(false);
100
+ const [view, setView] = (0, react_1.useState)('main');
101
+ // Which row opened a list, so going back puts focus where it was.
102
+ const [from, setFrom] = (0, react_1.useState)('main');
103
+ const [rtl, setRtl] = (0, react_1.useState)(false);
104
+ const [theme, setTheme] = useTheme();
105
+ const emit = (0, instrument_1.useEmit)();
106
+ const trigger = (0, react_1.useRef)(null);
107
+ const panel = (0, react_1.useRef)(null);
108
+ const settings = { language, currency };
109
+ // Focus follows the view: a list puts it on its search or its options, the
110
+ // card on the row the list was opened from.
111
+ (0, react_1.useEffect)(() => {
112
+ if (!open)
113
+ return;
114
+ const root = panel.current;
115
+ if (!root)
116
+ return;
117
+ const target = view === 'main'
118
+ ? from === 'main'
119
+ ? null
120
+ : root.querySelector(`[data-corner-row="${from}"]`)
121
+ : root.querySelector('[data-corner-field]');
122
+ target?.focus();
123
+ }, [open, view, from]);
124
+ const toggle = (next) => {
125
+ if (next) {
126
+ // A right-to-left page mirrors the chevrons; read once, where the trigger sits.
127
+ if (trigger.current && typeof getComputedStyle === 'function')
128
+ setRtl(getComputedStyle(trigger.current).direction === 'rtl');
129
+ setView('main');
130
+ setFrom('main');
131
+ emit({ component: 'Corner', action: 'open' });
132
+ }
133
+ setOpen(next);
134
+ };
135
+ const back = () => setView('main');
136
+ const choose = (kind, code) => {
137
+ const s = settings[kind];
138
+ if (!s)
139
+ return;
140
+ if (code !== s.value) {
141
+ emit({ component: 'Corner', action: 'change', id: kind, value: code });
142
+ s.onChange(code);
143
+ }
144
+ back();
145
+ };
146
+ // The card's own arrows: up and down walk its stops. A radio group keeps its
147
+ // arrows (they move the theme, as a radio group's should).
148
+ const onCard = (e) => {
149
+ const key = e.key;
150
+ if (key !== 'ArrowDown' && key !== 'ArrowUp' && key !== 'Home' && key !== 'End')
151
+ return;
152
+ const el = e.target;
153
+ if (el?.closest?.('[role="radiogroup"]') && (key === 'ArrowDown' || key === 'ArrowUp'))
154
+ return;
155
+ const stops = Array.from(panel.current?.querySelectorAll(STOPS) ?? []);
156
+ if (!stops.length)
157
+ return;
158
+ e.preventDefault?.();
159
+ const at = stops.findIndex((s) => s === el || s.contains(el));
160
+ const to = key === 'Home' ? 0 : key === 'End' ? stops.length - 1 : Math.max(0, Math.min(stops.length - 1, at + (key === 'ArrowDown' ? 1 : -1)));
161
+ stops[to]?.focus();
162
+ };
163
+ const Forward = rtl ? lucide_icons_2_1.ChevronLeft : lucide_icons_2_1.ChevronRight;
164
+ return ((0, jsx_runtime_1.jsx)(gui_1.XStack, { ...(0, slot_1.slot)('corner'), position: "fixed", bottom: EDGE.bottom, insetInlineStart: EDGE.start, z: 'var(--z-dock, 40)', children: (0, jsx_runtime_1.jsxs)(popover_1.Popover, { open: open, onOpenChange: toggle, placement: "top-start", allowFlip: true, stayInFrame: true, children: [(0, jsx_runtime_1.jsx)(popover_1.PopoverTrigger, { asChild: true, children: (0, jsx_runtime_1.jsx)(gui_1.XStack, { ref: trigger, ...(0, slot_1.slot)('corner-trigger'), ...(0, slot_1.tip)(w.open), role: "button", tabIndex: 0, "aria-label": w.open, "aria-haspopup": "dialog", "aria-expanded": open, ...(0, gesture_1.touch)(32), width: 32, height: 32, items: "center", justify: "center", rounded: "$10", cursor: "pointer", opacity: open ? 1 : 0.85, hoverStyle: { opacity: 1 }, focusVisibleStyle: press_1.RING, onKeyDown: (e) => {
165
+ if (e?.key !== 'Enter' && e?.key !== ' ')
166
+ return;
167
+ e.preventDefault?.();
168
+ toggle(!open);
169
+ }, children: (0, jsx_runtime_1.jsx)(lucide_icons_2_1.CircleHelp, { size: 22, color: "$background", fill: "var(--foreground)", "aria-hidden": true }) }) }), (0, jsx_runtime_1.jsxs)(popover_1.PopoverContent, { ref: panel, ...(0, slot_1.slot)('corner-card'), role: "dialog", "aria-label": w.open, solid: true, sideOffset: 6, width: 288, maxW: "calc(100vw - 20px)", maxH: 'calc(100dvh - 72px)', overflowY: "auto", p: "$3", gap: "$2", items: "stretch", elevation: "$4", trapFocus: true, onOpenAutoFocus: (e) => {
170
+ // The first thing in the card that does something, not its close button.
171
+ e.preventDefault();
172
+ panel.current?.querySelector(STOPS)?.focus();
173
+ }, onCloseAutoFocus: (e) => {
174
+ e.preventDefault();
175
+ trigger.current?.focus();
176
+ }, onEscapeKeyDown: (e) => {
177
+ // Escape in a list goes back to the card; on the card it closes.
178
+ if (view === 'main')
179
+ return;
180
+ e.preventDefault();
181
+ back();
182
+ }, children: [(0, jsx_runtime_1.jsx)(Reflow, { on: view }), view === 'main' ? ((0, jsx_runtime_1.jsxs)(gui_1.YStack, { gap: "$2", onKeyDown: onCard, children: [help.length ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(Head, { title: w.help, close: w.close, onClose: () => toggle(false) }), (0, jsx_runtime_1.jsx)(gui_1.YStack, { ...(0, slot_1.slot)('corner-help'), role: "list", "aria-label": w.help, children: help.map((link) => ((0, jsx_runtime_1.jsx)(Item, { link: link, leaving: w.leaves, onPress: () => emit({ component: 'Corner', action: 'click', id: link.label }) }, link.href + link.label))) }), (0, jsx_runtime_1.jsx)(gui_1.YStack, { height: 1, bg: "$borderColor", my: "$1", "aria-hidden": true })] })) : null, (0, jsx_runtime_1.jsx)(Head, { title: w.preferences, close: help.length ? undefined : w.close, onClose: () => toggle(false) }), (0, jsx_runtime_1.jsxs)(gui_1.XStack, { ...(0, slot_1.slot)('corner-theme'), ...ROW, items: "center", justify: "space-between", gap: "$3", children: [(0, jsx_runtime_1.jsx)(gui_1.SizableText, { size: "$3", color: "$aside", children: w.theme }), (0, jsx_runtime_1.jsx)(Filters_1.Segmented, { variant: "track", itemWidth: "content", name: "theme", "aria-label": w.theme, value: theme, onChange: setTheme, options: [
183
+ { value: 'system', label: w.auto },
184
+ { value: 'light', label: '', 'aria-label': w.light, icon: (0, jsx_runtime_1.jsx)(lucide_icons_2_1.Sun, { size: 14, color: "$ink", "aria-hidden": true }) },
185
+ { value: 'dark', label: '', 'aria-label': w.dark, icon: (0, jsx_runtime_1.jsx)(lucide_icons_2_1.Moon, { size: 14, color: "$ink", "aria-hidden": true }) },
186
+ ] })] }), ['language', 'currency'].map((kind) => {
187
+ const s = settings[kind];
188
+ if (!s)
189
+ return null;
190
+ const chosen = s.options.find((o) => o.code === s.value);
191
+ return ((0, jsx_runtime_1.jsxs)(gui_1.XStack, { ...(0, slot_1.slot)('corner-row'), ...(0, press_1.press)(() => {
192
+ setFrom(kind);
193
+ setView(kind);
194
+ }), "data-corner-stop": "", "data-corner-row": kind, "aria-haspopup": "listbox", ...ROW, items: "center", gap: "$3", mx: -8, px: "$2", rounded: "$3", cursor: "pointer", hoverStyle: { bg: '$hover' }, focusVisibleStyle: press_1.RING, children: [(0, jsx_runtime_1.jsx)(gui_1.SizableText, { size: "$3", color: "$aside", flex: 1, children: w[kind] }), (0, jsx_runtime_1.jsx)(gui_1.SizableText, { size: "$3", color: "$ink", fontWeight: "600", numberOfLines: 1, shrink: 1, ...(kind === 'language' ? { lang: s.value, dir: chosen?.dir } : null), children: (0, logic_1.shown)(s) }), (0, jsx_runtime_1.jsx)(Forward, { size: 16, color: "$hint", "aria-hidden": true })] }, kind));
195
+ })] })) : ((0, jsx_runtime_1.jsx)(List, { kind: view, setting: settings[view], title: w[view], words: w, rtl: rtl, onBack: back, onChoose: (code) => choose(view, code) }, view))] })] }) }));
196
+ }
197
+ /**
198
+ * Places the card again when it changes size. gui's popper re-measures when its
199
+ * TRIGGER moves, never when the card does, so a card that grows into a list
200
+ * keeps its old top and runs off the foot of the window. Inside the content,
201
+ * so it reads the popper the content belongs to.
202
+ */
203
+ function Reflow({ on }) {
204
+ const { update } = (0, gui_1.usePopperContextSlow)();
205
+ (0, react_1.useLayoutEffect)(() => {
206
+ update?.();
207
+ }, [on, update]);
208
+ return null;
209
+ }
210
+ /** A section's quiet title, and the card's close button when the section is first. */
211
+ function Head({ title, close, onClose }) {
212
+ return ((0, jsx_runtime_1.jsxs)(gui_1.XStack, { items: "center", justify: "space-between", minH: 28, children: [(0, jsx_runtime_1.jsx)(gui_1.SizableText, { size: "$2", color: "$hint", render: "h2", m: 0, children: title }), close ? (0, jsx_runtime_1.jsx)(Icon, { label: close, onPress: onClose, glyph: (0, jsx_runtime_1.jsx)(lucide_icons_2_1.X, { size: 18, color: "$aside", "aria-hidden": true }) }) : null] }));
213
+ }
214
+ /** An icon-only button: 28px drawn, 44 under a finger. */
215
+ function Icon({ label, glyph, onPress }) {
216
+ return ((0, jsx_runtime_1.jsx)(gui_1.XStack, { ...(0, press_1.press)(onPress), ...(0, slot_1.tip)(label), "aria-label": label, ...(0, gesture_1.touch)(28), width: 28, height: 28, marginInlineEnd: -6, items: "center", justify: "center", rounded: "$10", cursor: "pointer", hoverStyle: { bg: '$hover' }, focusVisibleStyle: press_1.RING, children: glyph }));
217
+ }
218
+ /** One help link. A link that leaves the site opens a new tab and says so, to the eye and to a screen reader. */
219
+ function Item({ link, leaving, onPress }) {
220
+ const out = typeof location !== 'undefined' && (0, nav_1.leaves)(link.href, location.origin);
221
+ return ((0, jsx_runtime_1.jsx)(gui_1.XStack, { role: "listitem", children: (0, jsx_runtime_1.jsxs)(gui_1.Anchor, { ...(0, slot_1.slot)('corner-link'), "data-corner-stop": "", href: link.href, target: out ? '_blank' : undefined, rel: out ? 'noopener noreferrer' : undefined, "aria-label": out ? `${link.label} (${leaving})` : undefined, onPress: onPress, display: "flex", flexDirection: "row", flex: 1, items: "center", gap: "$2.5", ...ROW, mx: -8, px: "$2", rounded: "$3", textDecorationLine: "none", color: "$ink", hoverStyle: { bg: '$hover' }, focusVisibleStyle: press_1.RING, children: [link.icon ? ((0, jsx_runtime_1.jsx)(gui_1.XStack, { width: 20, items: "center", justify: "center", "aria-hidden": true, children: link.icon })) : null, (0, jsx_runtime_1.jsx)(gui_1.SizableText, { size: "$3", color: "$ink", fontWeight: "500", children: link.label }), out ? (0, jsx_runtime_1.jsx)(lucide_icons_2_1.ExternalLink, { size: 12, color: "$hint", "aria-hidden": true }) : null] }) }));
222
+ }
223
+ /**
224
+ * A setting's options, in place of the card: back, the title, a search when the
225
+ * list is long, then the options with a check on the current one.
226
+ *
227
+ * ARIA: the options are a listbox. With a search, the field is the combobox and
228
+ * owns the cursor through `aria-activedescendant`, so focus never leaves it
229
+ * while the arrows move; without one the listbox itself takes focus and does the
230
+ * same. Enter or a press chooses.
231
+ */
232
+ function List({ kind, setting, title, words, rtl, onBack, onChoose, }) {
233
+ const [q, setQ] = (0, react_1.useState)('');
234
+ const rows = (0, logic_1.find)(setting.options, q);
235
+ const [at, setAt] = (0, react_1.useState)(() => Math.max(0, rows.findIndex((o) => o.code === setting.value)));
236
+ const id = (0, react_1.useId)();
237
+ const listId = `${id}-list`;
238
+ const optionId = (i) => `${id}-opt-${i}`;
239
+ const searchable = setting.options.length > logic_1.LONG;
240
+ const Back = rtl ? lucide_icons_2_1.ChevronRight : lucide_icons_2_1.ChevronLeft;
241
+ // A new search starts the cursor at the top.
242
+ (0, react_1.useEffect)(() => {
243
+ if (q)
244
+ setAt(0);
245
+ }, [q]);
246
+ // Keep the row under the cursor in view as it moves.
247
+ (0, react_1.useEffect)(() => {
248
+ if (typeof document === 'undefined')
249
+ return;
250
+ document.getElementById(optionId(at))?.scrollIntoView?.({ block: 'nearest' });
251
+ // eslint-disable-next-line react-hooks/exhaustive-deps
252
+ }, [at]);
253
+ const onKey = (e) => {
254
+ const key = e?.key;
255
+ if (MOVES.has(key)) {
256
+ if ((key === 'Home' || key === 'End') && q)
257
+ return;
258
+ e.preventDefault?.();
259
+ setAt((0, chipSelect_logic_1.move)(key, at, rows.length));
260
+ return;
261
+ }
262
+ if (key === 'Enter' || (key === ' ' && !searchable)) {
263
+ e.preventDefault?.();
264
+ const o = rows[at];
265
+ if (o)
266
+ onChoose(o.code);
267
+ }
268
+ };
269
+ const active = rows[at] ? optionId(at) : undefined;
270
+ return ((0, jsx_runtime_1.jsxs)(gui_1.YStack, { gap: "$2", children: [(0, jsx_runtime_1.jsxs)(gui_1.XStack, { items: "center", gap: "$1", minH: 28, children: [(0, jsx_runtime_1.jsx)(gui_1.XStack, { marginInlineStart: -6, children: (0, jsx_runtime_1.jsx)(Icon, { label: words.back, onPress: onBack, glyph: (0, jsx_runtime_1.jsx)(Back, { size: 18, color: "$aside", "aria-hidden": true }) }) }), (0, jsx_runtime_1.jsx)(gui_1.SizableText, { size: "$3", color: "$ink", fontWeight: "600", render: "h2", m: 0, children: title })] }), searchable ? ((0, jsx_runtime_1.jsxs)(gui_1.XStack, { ...(0, slot_1.slot)('corner-search'), height: 32, px: "$2", gap: "$1.5", items: "center", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", bg: "$background", focusWithinStyle: { borderColor: '$outlineColor' }, children: [(0, jsx_runtime_1.jsx)(lucide_icons_2_1.Search, { size: 14, color: "$hint", "aria-hidden": true }), (0, jsx_runtime_1.jsx)(gui_1.Input, { ...(0, slot_1.slot)('corner-input'), "data-corner-field": "", unstyled: true, flex: 1, minW: 0, height: 30, p: 0, borderWidth: 0, bg: "transparent", fontSize: "$3", color: "$ink", placeholderTextColor: "$placeholderColor", placeholder: words.search, value: q, onChangeText: setQ, role: "combobox", "aria-label": `${words.search}: ${title}`, "aria-expanded": true, "aria-controls": listId, "aria-autocomplete": "list", "aria-activedescendant": active, autoComplete: "off", spellCheck: false, onKeyDown: onKey })] })) : null, (0, jsx_runtime_1.jsxs)(gui_1.YStack, { ...(0, slot_1.slot)('corner-list'), id: listId, role: "listbox", "aria-label": title, ...(searchable
271
+ ? null
272
+ : { 'data-corner-field': '', tabIndex: 0, 'aria-activedescendant': active, onKeyDown: onKey, focusVisibleStyle: press_1.RING }),
273
+ // A height, not a ceiling: a search that narrows the list leaves the card
274
+ // where it is instead of shrinking it away from the trigger.
275
+ height: 'min(320px, calc(100dvh - 180px))', overflowY: "auto", mx: -4, px: "$1", rounded: "$3", children: [rows.map((o, i) => {
276
+ const picked = o.code === setting.value;
277
+ return ((0, jsx_runtime_1.jsxs)(gui_1.XStack, { ...(0, slot_1.slot)('corner-option'), id: optionId(i), role: "option", "aria-selected": picked, ...ROW, px: "$2", gap: "$2", items: "center", rounded: "$3", cursor: "pointer", bg: i === at ? '$hover' : 'transparent', onHoverIn: () => setAt(i), onPress: () => onChoose(o.code), children: [(0, jsx_runtime_1.jsx)(gui_1.YStack, { flex: 1, minW: 0, children: (0, jsx_runtime_1.jsx)(gui_1.SizableText, { size: "$3", color: "$ink", fontWeight: picked ? '600' : '400', numberOfLines: 1, self: "flex-start", maxW: "100%", ...(kind === 'language' ? { lang: o.code, dir: o.dir } : null), children: o.label }) }), o.hint ? ((0, jsx_runtime_1.jsx)(gui_1.SizableText, { size: "$2", color: "$hint", numberOfLines: 1, shrink: 0, children: o.hint })) : null, (0, jsx_runtime_1.jsx)(gui_1.XStack, { width: 16, items: "center", justify: "center", shrink: 0, "aria-hidden": true, children: picked ? (0, jsx_runtime_1.jsx)(lucide_icons_2_1.Check, { size: 16, color: "$ink" }) : null })] }, o.code));
278
+ }), rows.length === 0 ? ((0, jsx_runtime_1.jsx)(gui_1.XStack, { ...ROW, px: "$2", items: "center", role: "status", children: (0, jsx_runtime_1.jsx)(gui_1.SizableText, { size: "$3", color: "$hint", children: words.none }) })) : null] })] }));
279
+ }
@@ -0,0 +1,21 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type Setting, type Words } from './logic.js';
3
+ export type { Option, Setting, Words } from './logic.js';
4
+ /** A help link. `icon` leads the label; an address on another host opens in a new tab with the outbound mark. */
5
+ export type Link = {
6
+ label: string;
7
+ href: string;
8
+ icon?: ReactNode;
9
+ };
10
+ export type CornerProps = {
11
+ /** Help links, in order: Get help, Docs, Contact us. None, and the card opens on Preferences. */
12
+ help?: readonly Link[];
13
+ /** The page's language, owned by the host. Absent, the row is not drawn. */
14
+ language?: Setting;
15
+ /** The page's display currency, owned by the host. Absent, the row is not drawn. */
16
+ currency?: Setting;
17
+ /** The panel's own words, for a host that speaks another language. */
18
+ words?: Partial<Words>;
19
+ };
20
+ export declare function Corner({ help, language, currency, words }: CornerProps): import("react").JSX.Element;
21
+ //# sourceMappingURL=Corner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Corner.d.ts","sourceRoot":"","sources":["../../src/corner/Corner.tsx"],"names":[],"mappings":"AAsCA,OAAO,EAAgF,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAWpH,OAAO,EAAyC,KAAK,OAAO,EAAE,KAAK,KAAK,EAAE,MAAM,SAAS,CAAA;AAEzF,YAAY,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AAErD,iHAAiH;AACjH,MAAM,MAAM,IAAI,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,SAAS,CAAA;CAAE,CAAA;AAEpE,MAAM,MAAM,WAAW,GAAG;IACxB,iGAAiG;IACjG,IAAI,CAAC,EAAE,SAAS,IAAI,EAAE,CAAA;IACtB,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,oFAAoF;IACpF,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,sEAAsE;IACtE,KAAK,CAAC,EAAE,OAAO,CAAC,KAAK,CAAC,CAAA;CACvB,CAAA;AAuDD,wBAAgB,MAAM,CAAC,EAAE,IAAS,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,WAAW,+BA0O3E"}
@@ -0,0 +1,277 @@
1
+ 'use client';
2
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * Corner — help and preferences, behind the one control that is on every page.
5
+ *
6
+ * <Corner
7
+ * help={[{ label: 'Get help', href: '/support' }, { label: 'Docs', href: 'https://docs.hanzo.ai' }]}
8
+ * language={{ value: locale, options: LANGUAGES, onChange: setLocale }}
9
+ * currency={{ value: fiat, options: CURRENCIES, onChange: setFiat }}
10
+ * />
11
+ *
12
+ * A small `?` sits at the bottom START corner of the window (bottom-left in a
13
+ * left-to-right page, bottom-right in a right-to-left one), clear of the notch
14
+ * and the home indicator. Pressing it opens one card that opens toward the
15
+ * inside: the host's help links first, then Theme, Language and Currency. The
16
+ * three settings a visitor is most likely to want are where they look when they
17
+ * want the page to explain itself, on every surface, in the same place.
18
+ *
19
+ * Theme is the library's own: `@hanzo/appearance`, written through `<Hanzo>`'s
20
+ * resolved stack when there is one (`Look`), so it is stored, painted before
21
+ * first paint and carried across origins exactly as the settings panel does.
22
+ * Language and currency are the HOST's — this package depends on no i18n or
23
+ * money library, so it draws the value it is handed and reports a choice. A row
24
+ * the host passes nothing for is not drawn.
25
+ *
26
+ * Behaviour is the package `Popover`'s: portalled, dismissed by Escape and an
27
+ * outside press, focus trapped while open and handed back to the trigger on
28
+ * close. Escape in a list returns to the card rather than closing it. Arrow keys
29
+ * move through the card's rows and through a list; a long list (more than
30
+ * `LONG`) gets a search field that owns the cursor (`aria-activedescendant`).
31
+ * Nothing animates, so there is no motion to reduce.
32
+ *
33
+ * Web-only — it is fixed to the window — so it ships at `@hanzo/ui/corner`
34
+ * beside `consent`, not on the barrel that promises native.
35
+ */
36
+ import { Anchor, Input as GuiInput, SizableText, XStack, YStack, usePopperContextSlow } from '@hanzo/gui';
37
+ import { Check, ChevronLeft, ChevronRight, CircleHelp, ExternalLink, Moon, Search, Sun, X } from '@hanzogui/lucide-icons-2';
38
+ import { apply, current, read, useScheme, write } from '@hanzo/appearance';
39
+ import { useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
40
+ import { Popover, PopoverContent, PopoverTrigger } from '../backends/gui/popover.js';
41
+ import { touch } from '../backends/gui/gesture.js';
42
+ import { RING, press } from '../backends/gui/press.js';
43
+ import { slot, tip } from '../backends/gui/slot.js';
44
+ import { leaves } from '../masthead/nav.js';
45
+ import { move } from '../product/chipSelect.logic.js';
46
+ import { Segmented } from '../product/Filters.js';
47
+ import { useEmit } from '../product/instrument.js';
48
+ import { Look } from '../look.js';
49
+ import { LONG, WORDS, find, shown } from './logic.js';
50
+ /**
51
+ * The trigger's distance from the window's foot and its start edge: 10px, or the
52
+ * device's inset and 4px where that is wider. A right-to-left page puts the
53
+ * trigger on the right, so the inline edge takes the wider of the two side insets.
54
+ */
55
+ const EDGE = {
56
+ bottom: 'max(10px, calc(var(--safe-bottom, env(safe-area-inset-bottom, 0px)) + 4px))',
57
+ start: 'max(10px, calc(max(var(--safe-left, env(safe-area-inset-left, 0px)), var(--safe-right, env(safe-area-inset-right, 0px))) + 4px))',
58
+ };
59
+ /**
60
+ * A row's height: 36 under a mouse, 44 under a finger. Rows sit edge to edge,
61
+ * so the height itself is the target — an overlay past it would land on the
62
+ * next row. (`[role=button]` gets the same floor from design's base sheet.)
63
+ */
64
+ const ROW = { minH: 36, $touchable: { minH: 44 } };
65
+ /** The keys a list answers. */
66
+ const MOVES = new Set(['ArrowDown', 'ArrowUp', 'Home', 'End', 'PageDown', 'PageUp']);
67
+ /** Everything in the card the arrow keys step through, in document order. */
68
+ const STOPS = '[data-corner-stop], [data-slot="segmented-item"][data-state="checked"]';
69
+ /**
70
+ * The theme, as the person chose it, and the one way to change it.
71
+ *
72
+ * Under `<Hanzo>` it writes through the root's `useAppearance` (its org,
73
+ * install and account layers). Under a host's own gui provider it writes the
74
+ * device's layer and repaints the resolved stack, which is what `useAppearance`
75
+ * would do there. Nobody chose yet: the row marks what the document shows.
76
+ */
77
+ function useTheme() {
78
+ const look = useContext(Look);
79
+ const ground = useScheme();
80
+ const [own, setOwn] = useState(undefined);
81
+ useEffect(() => {
82
+ if (!look)
83
+ setOwn(read().theme);
84
+ }, [look]);
85
+ const set = useCallback((theme) => {
86
+ if (look)
87
+ return look.set({ theme });
88
+ write({ ...read(), theme });
89
+ apply(current().pref);
90
+ setOwn(theme);
91
+ }, [look]);
92
+ return [(look ? look.pref.theme : own) ?? ground, set];
93
+ }
94
+ export function Corner({ help = [], language, currency, words }) {
95
+ const w = { ...WORDS, ...words };
96
+ const [open, setOpen] = useState(false);
97
+ const [view, setView] = useState('main');
98
+ // Which row opened a list, so going back puts focus where it was.
99
+ const [from, setFrom] = useState('main');
100
+ const [rtl, setRtl] = useState(false);
101
+ const [theme, setTheme] = useTheme();
102
+ const emit = useEmit();
103
+ const trigger = useRef(null);
104
+ const panel = useRef(null);
105
+ const settings = { language, currency };
106
+ // Focus follows the view: a list puts it on its search or its options, the
107
+ // card on the row the list was opened from.
108
+ useEffect(() => {
109
+ if (!open)
110
+ return;
111
+ const root = panel.current;
112
+ if (!root)
113
+ return;
114
+ const target = view === 'main'
115
+ ? from === 'main'
116
+ ? null
117
+ : root.querySelector(`[data-corner-row="${from}"]`)
118
+ : root.querySelector('[data-corner-field]');
119
+ target?.focus();
120
+ }, [open, view, from]);
121
+ const toggle = (next) => {
122
+ if (next) {
123
+ // A right-to-left page mirrors the chevrons; read once, where the trigger sits.
124
+ if (trigger.current && typeof getComputedStyle === 'function')
125
+ setRtl(getComputedStyle(trigger.current).direction === 'rtl');
126
+ setView('main');
127
+ setFrom('main');
128
+ emit({ component: 'Corner', action: 'open' });
129
+ }
130
+ setOpen(next);
131
+ };
132
+ const back = () => setView('main');
133
+ const choose = (kind, code) => {
134
+ const s = settings[kind];
135
+ if (!s)
136
+ return;
137
+ if (code !== s.value) {
138
+ emit({ component: 'Corner', action: 'change', id: kind, value: code });
139
+ s.onChange(code);
140
+ }
141
+ back();
142
+ };
143
+ // The card's own arrows: up and down walk its stops. A radio group keeps its
144
+ // arrows (they move the theme, as a radio group's should).
145
+ const onCard = (e) => {
146
+ const key = e.key;
147
+ if (key !== 'ArrowDown' && key !== 'ArrowUp' && key !== 'Home' && key !== 'End')
148
+ return;
149
+ const el = e.target;
150
+ if (el?.closest?.('[role="radiogroup"]') && (key === 'ArrowDown' || key === 'ArrowUp'))
151
+ return;
152
+ const stops = Array.from(panel.current?.querySelectorAll(STOPS) ?? []);
153
+ if (!stops.length)
154
+ return;
155
+ e.preventDefault?.();
156
+ const at = stops.findIndex((s) => s === el || s.contains(el));
157
+ const to = key === 'Home' ? 0 : key === 'End' ? stops.length - 1 : Math.max(0, Math.min(stops.length - 1, at + (key === 'ArrowDown' ? 1 : -1)));
158
+ stops[to]?.focus();
159
+ };
160
+ const Forward = rtl ? ChevronLeft : ChevronRight;
161
+ return (_jsx(XStack, { ...slot('corner'), position: "fixed", bottom: EDGE.bottom, insetInlineStart: EDGE.start, z: 'var(--z-dock, 40)', children: _jsxs(Popover, { open: open, onOpenChange: toggle, placement: "top-start", allowFlip: true, stayInFrame: true, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx(XStack, { ref: trigger, ...slot('corner-trigger'), ...tip(w.open), role: "button", tabIndex: 0, "aria-label": w.open, "aria-haspopup": "dialog", "aria-expanded": open, ...touch(32), width: 32, height: 32, items: "center", justify: "center", rounded: "$10", cursor: "pointer", opacity: open ? 1 : 0.85, hoverStyle: { opacity: 1 }, focusVisibleStyle: RING, onKeyDown: (e) => {
162
+ if (e?.key !== 'Enter' && e?.key !== ' ')
163
+ return;
164
+ e.preventDefault?.();
165
+ toggle(!open);
166
+ }, children: _jsx(CircleHelp, { size: 22, color: "$background", fill: "var(--foreground)", "aria-hidden": true }) }) }), _jsxs(PopoverContent, { ref: panel, ...slot('corner-card'), role: "dialog", "aria-label": w.open, solid: true, sideOffset: 6, width: 288, maxW: "calc(100vw - 20px)", maxH: 'calc(100dvh - 72px)', overflowY: "auto", p: "$3", gap: "$2", items: "stretch", elevation: "$4", trapFocus: true, onOpenAutoFocus: (e) => {
167
+ // The first thing in the card that does something, not its close button.
168
+ e.preventDefault();
169
+ panel.current?.querySelector(STOPS)?.focus();
170
+ }, onCloseAutoFocus: (e) => {
171
+ e.preventDefault();
172
+ trigger.current?.focus();
173
+ }, onEscapeKeyDown: (e) => {
174
+ // Escape in a list goes back to the card; on the card it closes.
175
+ if (view === 'main')
176
+ return;
177
+ e.preventDefault();
178
+ back();
179
+ }, children: [_jsx(Reflow, { on: view }), view === 'main' ? (_jsxs(YStack, { gap: "$2", onKeyDown: onCard, children: [help.length ? (_jsxs(_Fragment, { children: [_jsx(Head, { title: w.help, close: w.close, onClose: () => toggle(false) }), _jsx(YStack, { ...slot('corner-help'), role: "list", "aria-label": w.help, children: help.map((link) => (_jsx(Item, { link: link, leaving: w.leaves, onPress: () => emit({ component: 'Corner', action: 'click', id: link.label }) }, link.href + link.label))) }), _jsx(YStack, { height: 1, bg: "$borderColor", my: "$1", "aria-hidden": true })] })) : null, _jsx(Head, { title: w.preferences, close: help.length ? undefined : w.close, onClose: () => toggle(false) }), _jsxs(XStack, { ...slot('corner-theme'), ...ROW, items: "center", justify: "space-between", gap: "$3", children: [_jsx(SizableText, { size: "$3", color: "$aside", children: w.theme }), _jsx(Segmented, { variant: "track", itemWidth: "content", name: "theme", "aria-label": w.theme, value: theme, onChange: setTheme, options: [
180
+ { value: 'system', label: w.auto },
181
+ { value: 'light', label: '', 'aria-label': w.light, icon: _jsx(Sun, { size: 14, color: "$ink", "aria-hidden": true }) },
182
+ { value: 'dark', label: '', 'aria-label': w.dark, icon: _jsx(Moon, { size: 14, color: "$ink", "aria-hidden": true }) },
183
+ ] })] }), ['language', 'currency'].map((kind) => {
184
+ const s = settings[kind];
185
+ if (!s)
186
+ return null;
187
+ const chosen = s.options.find((o) => o.code === s.value);
188
+ return (_jsxs(XStack, { ...slot('corner-row'), ...press(() => {
189
+ setFrom(kind);
190
+ setView(kind);
191
+ }), "data-corner-stop": "", "data-corner-row": kind, "aria-haspopup": "listbox", ...ROW, items: "center", gap: "$3", mx: -8, px: "$2", rounded: "$3", cursor: "pointer", hoverStyle: { bg: '$hover' }, focusVisibleStyle: RING, children: [_jsx(SizableText, { size: "$3", color: "$aside", flex: 1, children: w[kind] }), _jsx(SizableText, { size: "$3", color: "$ink", fontWeight: "600", numberOfLines: 1, shrink: 1, ...(kind === 'language' ? { lang: s.value, dir: chosen?.dir } : null), children: shown(s) }), _jsx(Forward, { size: 16, color: "$hint", "aria-hidden": true })] }, kind));
192
+ })] })) : (_jsx(List, { kind: view, setting: settings[view], title: w[view], words: w, rtl: rtl, onBack: back, onChoose: (code) => choose(view, code) }, view))] })] }) }));
193
+ }
194
+ /**
195
+ * Places the card again when it changes size. gui's popper re-measures when its
196
+ * TRIGGER moves, never when the card does, so a card that grows into a list
197
+ * keeps its old top and runs off the foot of the window. Inside the content,
198
+ * so it reads the popper the content belongs to.
199
+ */
200
+ function Reflow({ on }) {
201
+ const { update } = usePopperContextSlow();
202
+ useLayoutEffect(() => {
203
+ update?.();
204
+ }, [on, update]);
205
+ return null;
206
+ }
207
+ /** A section's quiet title, and the card's close button when the section is first. */
208
+ function Head({ title, close, onClose }) {
209
+ return (_jsxs(XStack, { items: "center", justify: "space-between", minH: 28, children: [_jsx(SizableText, { size: "$2", color: "$hint", render: "h2", m: 0, children: title }), close ? _jsx(Icon, { label: close, onPress: onClose, glyph: _jsx(X, { size: 18, color: "$aside", "aria-hidden": true }) }) : null] }));
210
+ }
211
+ /** An icon-only button: 28px drawn, 44 under a finger. */
212
+ function Icon({ label, glyph, onPress }) {
213
+ return (_jsx(XStack, { ...press(onPress), ...tip(label), "aria-label": label, ...touch(28), width: 28, height: 28, marginInlineEnd: -6, items: "center", justify: "center", rounded: "$10", cursor: "pointer", hoverStyle: { bg: '$hover' }, focusVisibleStyle: RING, children: glyph }));
214
+ }
215
+ /** One help link. A link that leaves the site opens a new tab and says so, to the eye and to a screen reader. */
216
+ function Item({ link, leaving, onPress }) {
217
+ const out = typeof location !== 'undefined' && leaves(link.href, location.origin);
218
+ return (_jsx(XStack, { role: "listitem", children: _jsxs(Anchor, { ...slot('corner-link'), "data-corner-stop": "", href: link.href, target: out ? '_blank' : undefined, rel: out ? 'noopener noreferrer' : undefined, "aria-label": out ? `${link.label} (${leaving})` : undefined, onPress: onPress, display: "flex", flexDirection: "row", flex: 1, items: "center", gap: "$2.5", ...ROW, mx: -8, px: "$2", rounded: "$3", textDecorationLine: "none", color: "$ink", hoverStyle: { bg: '$hover' }, focusVisibleStyle: RING, children: [link.icon ? (_jsx(XStack, { width: 20, items: "center", justify: "center", "aria-hidden": true, children: link.icon })) : null, _jsx(SizableText, { size: "$3", color: "$ink", fontWeight: "500", children: link.label }), out ? _jsx(ExternalLink, { size: 12, color: "$hint", "aria-hidden": true }) : null] }) }));
219
+ }
220
+ /**
221
+ * A setting's options, in place of the card: back, the title, a search when the
222
+ * list is long, then the options with a check on the current one.
223
+ *
224
+ * ARIA: the options are a listbox. With a search, the field is the combobox and
225
+ * owns the cursor through `aria-activedescendant`, so focus never leaves it
226
+ * while the arrows move; without one the listbox itself takes focus and does the
227
+ * same. Enter or a press chooses.
228
+ */
229
+ function List({ kind, setting, title, words, rtl, onBack, onChoose, }) {
230
+ const [q, setQ] = useState('');
231
+ const rows = find(setting.options, q);
232
+ const [at, setAt] = useState(() => Math.max(0, rows.findIndex((o) => o.code === setting.value)));
233
+ const id = useId();
234
+ const listId = `${id}-list`;
235
+ const optionId = (i) => `${id}-opt-${i}`;
236
+ const searchable = setting.options.length > LONG;
237
+ const Back = rtl ? ChevronRight : ChevronLeft;
238
+ // A new search starts the cursor at the top.
239
+ useEffect(() => {
240
+ if (q)
241
+ setAt(0);
242
+ }, [q]);
243
+ // Keep the row under the cursor in view as it moves.
244
+ useEffect(() => {
245
+ if (typeof document === 'undefined')
246
+ return;
247
+ document.getElementById(optionId(at))?.scrollIntoView?.({ block: 'nearest' });
248
+ // eslint-disable-next-line react-hooks/exhaustive-deps
249
+ }, [at]);
250
+ const onKey = (e) => {
251
+ const key = e?.key;
252
+ if (MOVES.has(key)) {
253
+ if ((key === 'Home' || key === 'End') && q)
254
+ return;
255
+ e.preventDefault?.();
256
+ setAt(move(key, at, rows.length));
257
+ return;
258
+ }
259
+ if (key === 'Enter' || (key === ' ' && !searchable)) {
260
+ e.preventDefault?.();
261
+ const o = rows[at];
262
+ if (o)
263
+ onChoose(o.code);
264
+ }
265
+ };
266
+ const active = rows[at] ? optionId(at) : undefined;
267
+ return (_jsxs(YStack, { gap: "$2", children: [_jsxs(XStack, { items: "center", gap: "$1", minH: 28, children: [_jsx(XStack, { marginInlineStart: -6, children: _jsx(Icon, { label: words.back, onPress: onBack, glyph: _jsx(Back, { size: 18, color: "$aside", "aria-hidden": true }) }) }), _jsx(SizableText, { size: "$3", color: "$ink", fontWeight: "600", render: "h2", m: 0, children: title })] }), searchable ? (_jsxs(XStack, { ...slot('corner-search'), height: 32, px: "$2", gap: "$1.5", items: "center", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", bg: "$background", focusWithinStyle: { borderColor: '$outlineColor' }, children: [_jsx(Search, { size: 14, color: "$hint", "aria-hidden": true }), _jsx(GuiInput, { ...slot('corner-input'), "data-corner-field": "", unstyled: true, flex: 1, minW: 0, height: 30, p: 0, borderWidth: 0, bg: "transparent", fontSize: "$3", color: "$ink", placeholderTextColor: "$placeholderColor", placeholder: words.search, value: q, onChangeText: setQ, role: "combobox", "aria-label": `${words.search}: ${title}`, "aria-expanded": true, "aria-controls": listId, "aria-autocomplete": "list", "aria-activedescendant": active, autoComplete: "off", spellCheck: false, onKeyDown: onKey })] })) : null, _jsxs(YStack, { ...slot('corner-list'), id: listId, role: "listbox", "aria-label": title, ...(searchable
268
+ ? null
269
+ : { 'data-corner-field': '', tabIndex: 0, 'aria-activedescendant': active, onKeyDown: onKey, focusVisibleStyle: RING }),
270
+ // A height, not a ceiling: a search that narrows the list leaves the card
271
+ // where it is instead of shrinking it away from the trigger.
272
+ height: 'min(320px, calc(100dvh - 180px))', overflowY: "auto", mx: -4, px: "$1", rounded: "$3", children: [rows.map((o, i) => {
273
+ const picked = o.code === setting.value;
274
+ return (_jsxs(XStack, { ...slot('corner-option'), id: optionId(i), role: "option", "aria-selected": picked, ...ROW, px: "$2", gap: "$2", items: "center", rounded: "$3", cursor: "pointer", bg: i === at ? '$hover' : 'transparent', onHoverIn: () => setAt(i), onPress: () => onChoose(o.code), children: [_jsx(YStack, { flex: 1, minW: 0, children: _jsx(SizableText, { size: "$3", color: "$ink", fontWeight: picked ? '600' : '400', numberOfLines: 1, self: "flex-start", maxW: "100%", ...(kind === 'language' ? { lang: o.code, dir: o.dir } : null), children: o.label }) }), o.hint ? (_jsx(SizableText, { size: "$2", color: "$hint", numberOfLines: 1, shrink: 0, children: o.hint })) : null, _jsx(XStack, { width: 16, items: "center", justify: "center", shrink: 0, "aria-hidden": true, children: picked ? _jsx(Check, { size: 16, color: "$ink" }) : null })] }, o.code));
275
+ }), rows.length === 0 ? (_jsx(XStack, { ...ROW, px: "$2", items: "center", role: "status", children: _jsx(SizableText, { size: "$3", color: "$hint", children: words.none }) })) : null] })] }));
276
+ }
277
+ //# sourceMappingURL=Corner.js.map