@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.
- package/dist/corner/Corner.cjs +279 -0
- package/dist/corner/Corner.d.ts +21 -0
- package/dist/corner/Corner.d.ts.map +1 -0
- package/dist/corner/Corner.js +277 -0
- package/dist/corner/Corner.js.map +1 -0
- package/dist/corner/index.cjs +11 -0
- package/dist/corner/index.d.ts +3 -0
- package/dist/corner/index.d.ts.map +1 -0
- package/dist/corner/index.js +3 -0
- package/dist/corner/index.js.map +1 -0
- package/dist/corner/logic.cjs +47 -0
- package/dist/corner/logic.d.ts +59 -0
- package/dist/corner/logic.d.ts.map +1 -0
- package/dist/corner/logic.js +42 -0
- package/dist/corner/logic.js.map +1 -0
- package/dist/gallery.cjs +3 -1
- package/dist/gallery.d.ts.map +1 -1
- package/dist/gallery.html +11 -5
- package/dist/gallery.js +3 -1
- package/dist/gallery.js.map +1 -1
- package/dist/look.cjs +19 -0
- package/dist/look.d.ts +9 -0
- package/dist/look.d.ts.map +1 -0
- package/dist/look.js +17 -0
- package/dist/look.js.map +1 -0
- package/dist/product/Filters.cjs +5 -1
- package/dist/product/Filters.d.ts.map +1 -1
- package/dist/product/Filters.js +5 -1
- package/dist/product/Filters.js.map +1 -1
- package/dist/root.cjs +8 -2
- package/dist/root.d.ts +1 -1
- package/dist/root.d.ts.map +1 -1
- package/dist/root.js +8 -2
- package/dist/root.js.map +1 -1
- package/dist/root.node.cjs +8 -2
- package/dist/root.node.js +8 -2
- package/dist/styles.css +6 -0
- package/package.json +8 -1
|
@@ -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
|