@luxfi/ui 7.4.0 → 7.4.3

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.
Files changed (131) hide show
  1. package/dist/avatar.cjs +7 -6
  2. package/dist/avatar.js +7 -5
  3. package/dist/badge.cjs +33 -35
  4. package/dist/badge.js +33 -35
  5. package/dist/bank.d.cts +3 -3
  6. package/dist/bank.d.ts +3 -3
  7. package/dist/checkbox.cjs +4 -4
  8. package/dist/checkbox.d.cts +3 -2
  9. package/dist/checkbox.d.ts +3 -2
  10. package/dist/checkbox.js +5 -4
  11. package/dist/chrome.cjs +960 -0
  12. package/dist/chrome.d.cts +93 -0
  13. package/dist/chrome.d.ts +93 -0
  14. package/dist/chrome.js +931 -0
  15. package/dist/drawer.cjs +86 -20
  16. package/dist/drawer.js +85 -19
  17. package/dist/expiration-selector.cjs +3 -2
  18. package/dist/expiration-selector.js +3 -2
  19. package/dist/greeks-display.cjs +6 -6
  20. package/dist/greeks-display.js +6 -6
  21. package/dist/gui.cjs +13 -0
  22. package/dist/gui.d.cts +2 -0
  23. package/dist/gui.d.ts +2 -0
  24. package/dist/gui.js +2 -0
  25. package/dist/heading.cjs +4 -2
  26. package/dist/heading.js +4 -2
  27. package/dist/iam.cjs +216 -0
  28. package/dist/iam.d.cts +65 -0
  29. package/dist/iam.d.ts +65 -0
  30. package/dist/iam.js +211 -0
  31. package/dist/icons.cjs +13 -0
  32. package/dist/icons.d.cts +1 -0
  33. package/dist/icons.d.ts +1 -0
  34. package/dist/icons.js +2 -0
  35. package/dist/identity.cjs +430 -0
  36. package/dist/identity.d.cts +64 -0
  37. package/dist/identity.d.ts +64 -0
  38. package/dist/identity.js +408 -0
  39. package/dist/index.cjs +1610 -836
  40. package/dist/index.d.cts +7 -3
  41. package/dist/index.d.ts +7 -3
  42. package/dist/index.js +1587 -831
  43. package/dist/input.cjs +3 -1
  44. package/dist/input.d.cts +3 -5
  45. package/dist/input.d.ts +3 -5
  46. package/dist/input.js +3 -1
  47. package/dist/lux.config.cjs +23 -2
  48. package/dist/lux.config.d.cts +28 -10
  49. package/dist/lux.config.d.ts +28 -10
  50. package/dist/lux.config.js +23 -2
  51. package/dist/menu.cjs +4 -4
  52. package/dist/menu.js +4 -4
  53. package/dist/next.cjs +132 -0
  54. package/dist/next.d.cts +32 -0
  55. package/dist/next.d.ts +32 -0
  56. package/dist/next.js +129 -0
  57. package/dist/option-chain.cjs +1 -1
  58. package/dist/option-chain.js +1 -1
  59. package/dist/option-position.cjs +2 -1
  60. package/dist/option-position.js +2 -1
  61. package/dist/pnl-diagram.cjs +8 -6
  62. package/dist/pnl-diagram.js +8 -6
  63. package/dist/progress.cjs +7 -6
  64. package/dist/progress.js +7 -5
  65. package/dist/provider.cjs +146 -4
  66. package/dist/provider.d.cts +15 -3
  67. package/dist/provider.d.ts +15 -3
  68. package/dist/provider.js +146 -5
  69. package/dist/radio.cjs +9 -7
  70. package/dist/radio.js +10 -7
  71. package/dist/separator.cjs +3 -1
  72. package/dist/separator.js +3 -1
  73. package/dist/slider.cjs +28 -16
  74. package/dist/slider.js +28 -15
  75. package/dist/strategy-builder.cjs +2 -1
  76. package/dist/strategy-builder.js +2 -1
  77. package/dist/switch.cjs +11 -12
  78. package/dist/switch.js +12 -12
  79. package/dist/tag.cjs +34 -36
  80. package/dist/tag.js +33 -35
  81. package/dist/textarea.cjs +3 -1
  82. package/dist/textarea.js +3 -1
  83. package/dist/tooltip.cjs +28 -30
  84. package/dist/tooltip.js +27 -29
  85. package/dist/use-narrow.cjs +46 -0
  86. package/dist/use-narrow.d.cts +12 -0
  87. package/dist/use-narrow.d.ts +12 -0
  88. package/dist/use-narrow.js +20 -0
  89. package/dist/vite.cjs +109 -0
  90. package/dist/vite.d.cts +29 -0
  91. package/dist/vite.d.ts +29 -0
  92. package/dist/vite.js +107 -0
  93. package/dist/white-label.cjs +142 -0
  94. package/dist/white-label.d.cts +59 -0
  95. package/dist/white-label.d.ts +59 -0
  96. package/dist/white-label.js +134 -0
  97. package/package.json +85 -19
  98. package/src/avatar.tsx +22 -16
  99. package/src/checkbox.tsx +15 -10
  100. package/src/chrome.tsx +459 -0
  101. package/src/drawer.tsx +83 -10
  102. package/src/engine.ts +130 -0
  103. package/src/expiration-selector.tsx +3 -2
  104. package/src/greeks-display.tsx +9 -6
  105. package/src/gui.ts +17 -0
  106. package/src/heading.tsx +12 -8
  107. package/src/iam.ts +170 -0
  108. package/src/icons.ts +18 -0
  109. package/src/identity.tsx +318 -0
  110. package/src/index.ts +44 -1
  111. package/src/input.tsx +13 -9
  112. package/src/lux.config.ts +16 -15
  113. package/src/menu.tsx +4 -4
  114. package/src/next.ts +89 -0
  115. package/src/option-chain.tsx +1 -1
  116. package/src/option-position.tsx +2 -1
  117. package/src/pnl-diagram.tsx +11 -6
  118. package/src/progress.tsx +15 -8
  119. package/src/provider.tsx +48 -7
  120. package/src/radio.tsx +15 -11
  121. package/src/separator.tsx +8 -3
  122. package/src/slider.tsx +35 -19
  123. package/src/strategy-builder.tsx +3 -2
  124. package/src/switch.tsx +17 -16
  125. package/src/textarea.tsx +8 -4
  126. package/src/tooltip.tsx +15 -24
  127. package/src/use-narrow.ts +40 -0
  128. package/src/vite.ts +80 -0
  129. package/src/white-label.ts +254 -0
  130. package/tokens.css +613 -319
  131. package/src/tokens.css +0 -438
package/src/chrome.tsx ADDED
@@ -0,0 +1,459 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import {
5
+ Building2,
6
+ Check,
7
+ ChevronsUpDown,
8
+ Folder,
9
+ LogOut,
10
+ Menu,
11
+ Settings,
12
+ User,
13
+ } from 'lucide-react';
14
+
15
+ import { DrawerRoot, DrawerContent, DrawerHeader, DrawerBody, DrawerTitle } from './drawer';
16
+ import { useIdentity, type IdentityStatus } from './identity';
17
+ import { MenuRoot, MenuTrigger, MenuContent, MenuItem, MenuSeparator } from './menu';
18
+ import { useIsNarrow } from './use-narrow';
19
+ import { cn } from './utils';
20
+
21
+ // The application chrome: ONE user switcher, ONE org switcher, ONE project
22
+ // switcher, ONE settings menu, ONE nav — for every Lux surface.
23
+ //
24
+ // Every one of them is the SAME mechanism (`Switcher`) given different values.
25
+ // A switcher is a labelled trigger plus a list of rows; org / project / user /
26
+ // settings differ only in where the rows come from. Four components that each
27
+ // re-derived popover state, click-away, keyboard nav and a mobile presentation
28
+ // is four chances to get it wrong, and the fleet took all four.
29
+ //
30
+ // Presentation is a phone sheet below 640px and an anchored menu above it —
31
+ // both from the primitives this package already ships (`./drawer` is a gui
32
+ // Sheet on a phone, `./menu` is an anchored dropdown), so there is no
33
+ // hand-rolled drawer and the phone case drags and dismisses like a native one.
34
+ //
35
+ // Identity comes from `useIdentity()` (IAM), brand from the host. Nothing here
36
+ // reads a per-app org table or a per-app branding map.
37
+
38
+ /** One row in a switcher. */
39
+ export interface ChromeItem {
40
+ readonly id: string;
41
+ readonly label: string;
42
+ /** Secondary text, right-aligned — a plan, a count, a shortcut. */
43
+ readonly hint?: string;
44
+ readonly icon?: React.ReactNode;
45
+ readonly selected?: boolean;
46
+ readonly danger?: boolean;
47
+ readonly href?: string;
48
+ readonly onSelect?: () => void;
49
+ }
50
+
51
+ /** 1–2 letter monogram — the honest fallback when there is no logo. */
52
+ export function initials(label: string): string {
53
+ return label
54
+ .split(/[\s._-]+/)
55
+ .filter(Boolean)
56
+ .map((w) => w[0])
57
+ .join('')
58
+ .slice(0, 2)
59
+ .toUpperCase();
60
+ }
61
+
62
+ const Monogram = ({ label, logo }: { label: string; logo?: string }) => (
63
+ logo ?
64
+ <img src={ logo } alt="" className="h-5 w-5 shrink-0 rounded object-contain"/> :
65
+ <span
66
+ aria-hidden="true"
67
+ className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-[rgba(127,127,127,0.22)] text-[10px] font-bold"
68
+ >
69
+ { initials(label) }
70
+ </span>
71
+ );
72
+
73
+ export interface SwitcherProps {
74
+ /** Panel heading, drawer title and the trigger's accessible name. */
75
+ readonly title: string;
76
+ /** Trigger label. */
77
+ readonly label: string;
78
+ /** Trigger icon — usually a logo or monogram. */
79
+ readonly icon?: React.ReactNode;
80
+ readonly items: ReadonlyArray<ChromeItem>;
81
+ /** Rows below a separator — create, settings, sign out. */
82
+ readonly footer?: ReadonlyArray<ChromeItem>;
83
+ /** Drives the honest empty states. Defaults to 'ready'. */
84
+ readonly status?: IdentityStatus;
85
+ /** Shown when signed in and the list really is empty. */
86
+ readonly empty?: string;
87
+ /** Shown, as a sign-in action, when nobody is signed in. */
88
+ readonly onSignIn?: () => void;
89
+ readonly signInLabel?: string;
90
+ readonly error?: string | null;
91
+ readonly align?: 'start' | 'end';
92
+ readonly testId?: string;
93
+ }
94
+
95
+ /**
96
+ * One row, in either presentation.
97
+ *
98
+ * `menu` is the only difference: inside an anchored menu the row must be a
99
+ * Radix `MenuItem` (that is what gives the panel roving focus and type-ahead),
100
+ * while inside the phone sheet there is no menu to belong to — putting one
101
+ * there throws "`MenuItem` must be used within `MenuContent`" and takes the
102
+ * whole page down. Same markup, same handlers, one parameter.
103
+ */
104
+ const Row = ({ item, onDone, menu }: { item: ChromeItem; onDone: () => void; menu: boolean }) => {
105
+ const body = (
106
+ <>
107
+ { item.icon }
108
+ <span className="flex-1 truncate">{ item.label }</span>
109
+ { item.hint ? <span className="text-xs opacity-60">{ item.hint }</span> : null }
110
+ { item.selected ? <Check aria-hidden="true" className="h-4 w-4 shrink-0"/> : null }
111
+ </>
112
+ );
113
+ const className = cn(
114
+ 'flex w-full cursor-pointer select-none items-center gap-2.5 rounded-md px-2 py-2 text-left text-sm',
115
+ 'hover:bg-[rgba(127,127,127,0.14)]',
116
+ item.danger && 'text-[var(--color-text-error,#e5484d)]',
117
+ );
118
+ const activate = () => {
119
+ onDone();
120
+ item.onSelect?.();
121
+ };
122
+ const shared = {
123
+ 'data-testid': `chrome-item-${ item.id }`,
124
+ 'aria-current': item.selected ? ('true' as const) : undefined,
125
+ className,
126
+ };
127
+ const control = item.href ?
128
+ <a href={ item.href } { ...shared } onClick={ onDone }>{ body }</a> :
129
+ <button type="button" { ...shared } onClick={ activate }>{ body }</button>;
130
+
131
+ return menu ? <MenuItem value={ item.id } asChild>{ control }</MenuItem> : control;
132
+ };
133
+
134
+ /**
135
+ * The ONE switcher. A trigger plus rows, presented as a phone sheet or an
136
+ * anchored menu depending on the viewport — never both, never hand-rolled.
137
+ */
138
+ export const Switcher = ({
139
+ title,
140
+ label,
141
+ icon,
142
+ items,
143
+ footer = [],
144
+ status = 'ready',
145
+ empty = 'Nothing here yet.',
146
+ onSignIn,
147
+ signInLabel = 'Sign in',
148
+ error,
149
+ align = 'start',
150
+ testId,
151
+ }: SwitcherProps): React.ReactElement => {
152
+ const [ open, setOpen ] = React.useState(false);
153
+ const narrow = useIsNarrow();
154
+ const close = React.useCallback(() => setOpen(false), []);
155
+
156
+ // The four states a switcher can be in, said out loud. An anonymous visitor
157
+ // gets a sign-in row, NOT an empty list that reads as "an account with no
158
+ // organizations"; an IAM failure says so instead of claiming emptiness.
159
+ const message =
160
+ status === 'loading' ? 'Loading…' :
161
+ status === 'error' ? (error ?? 'Could not reach IAM.') :
162
+ status === 'anonymous' ? null :
163
+ items.length === 0 ? empty : null;
164
+
165
+ const rows: ReadonlyArray<ChromeItem> =
166
+ status === 'anonymous' && onSignIn ?
167
+ [ { id: 'sign-in', label: signInLabel, icon: <User aria-hidden="true" className="h-4 w-4"/>, onSelect: onSignIn } ] :
168
+ status === 'ready' ? items : [];
169
+
170
+ // ONE panel body, rendered by both presentations.
171
+ const panel = (menu: boolean) => (
172
+ <>
173
+ <div className="px-2 pb-1 pt-1 text-[11px] font-semibold uppercase tracking-wide opacity-55">{ title }</div>
174
+ { message ?
175
+ <div data-testid="chrome-message" className="px-2 py-2 text-sm opacity-70">{ message }</div> :
176
+ null }
177
+ { rows.map((item) => <Row key={ item.id } item={ item } onDone={ close } menu={ menu }/>) }
178
+ { footer.length > 0 ?
179
+ <>
180
+ { menu ?
181
+ <MenuSeparator className="my-1 h-px bg-[var(--color-border-divider,rgba(127,127,127,0.25))]"/> :
182
+ <div className="my-1 h-px bg-[var(--color-border-divider,rgba(127,127,127,0.25))]"/> }
183
+ { footer.map((item) => <Row key={ item.id } item={ item } onDone={ close } menu={ menu }/>) }
184
+ </> :
185
+ null }
186
+ </>
187
+ );
188
+
189
+ const trigger = (
190
+ <button
191
+ type="button"
192
+ data-testid={ testId }
193
+ aria-label={ title }
194
+ aria-haspopup="menu"
195
+ aria-expanded={ open }
196
+ className={ cn(
197
+ 'flex max-w-[14rem] items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium',
198
+ 'hover:bg-[rgba(127,127,127,0.14)]',
199
+ ) }
200
+ >
201
+ { icon }
202
+ <span className="truncate">{ label }</span>
203
+ <ChevronsUpDown aria-hidden="true" className="h-3.5 w-3.5 shrink-0 opacity-60"/>
204
+ </button>
205
+ );
206
+
207
+ // Phone: the same rows in a gui Sheet — drag, momentum, snap-to-dismiss.
208
+ if (narrow) {
209
+ return (
210
+ <DrawerRoot open={ open } onOpenChange={ (d) => setOpen(d.open) } placement="bottom" size="md">
211
+ { React.cloneElement(trigger, { onClick: () => setOpen(true) }) }
212
+ <DrawerContent data-testid={ testId ? `${ testId }-panel` : undefined }>
213
+ <DrawerHeader><DrawerTitle>{ title }</DrawerTitle></DrawerHeader>
214
+ <DrawerBody>{ panel(false) }</DrawerBody>
215
+ </DrawerContent>
216
+ </DrawerRoot>
217
+ );
218
+ }
219
+
220
+ return (
221
+ <MenuRoot
222
+ open={ open }
223
+ onOpenChange={ (d) => setOpen(d.open) }
224
+ positioning={{ placement: align === 'end' ? 'bottom-end' : 'bottom-start' }}
225
+ >
226
+ <MenuTrigger asChild>{ trigger }</MenuTrigger>
227
+ <MenuContent minW="16rem" data-testid={ testId ? `${ testId }-panel` : undefined }>
228
+ { panel(true) }
229
+ </MenuContent>
230
+ </MenuRoot>
231
+ );
232
+ };
233
+
234
+ // --- The four switchers ----------------------------------------------------
235
+ // Each is the mechanism above plus the values it switches over. That is the
236
+ // whole implementation; there is nothing else to keep in sync.
237
+
238
+ /** Which organization is this surface acting as. */
239
+ export const OrgSwitcher = (): React.ReactElement => {
240
+ const id = useIdentity();
241
+ return (
242
+ <Switcher
243
+ testId="org-switcher"
244
+ title="Organization"
245
+ label={ id.org?.displayName || id.org?.name || id.whiteLabel.name }
246
+ icon={ <Monogram label={ id.org?.displayName || id.org?.name || id.whiteLabel.name } logo={ id.org?.logo }/> }
247
+ status={ id.status }
248
+ error={ id.error }
249
+ empty="No organizations for this account."
250
+ onSignIn={ id.signIn }
251
+ signInLabel={ `Sign in with ${ id.whiteLabel.iamDomain }` }
252
+ items={ id.orgs.map((o) => ({
253
+ id: o.name,
254
+ label: o.displayName || o.name,
255
+ icon: <Monogram label={ o.displayName || o.name } logo={ o.logo }/>,
256
+ selected: o.name === id.org?.name,
257
+ onSelect: () => id.setOrg(o.name),
258
+ })) }
259
+ />
260
+ );
261
+ };
262
+
263
+ /** Which project inside that organization. */
264
+ export const ProjectSwitcher = (): React.ReactElement | null => {
265
+ const id = useIdentity();
266
+ // A surface whose org has no projects should not grow a dead control.
267
+ if (id.status === 'ready' && id.projects.length === 0) return null;
268
+ return (
269
+ <Switcher
270
+ testId="project-switcher"
271
+ title="Project"
272
+ label={ id.project?.displayName || id.project?.name || 'Project' }
273
+ icon={ <Folder aria-hidden="true" className="h-4 w-4 opacity-70"/> }
274
+ status={ id.status }
275
+ error={ id.error }
276
+ empty="No projects in this organization."
277
+ onSignIn={ id.signIn }
278
+ items={ id.projects.map((p) => ({
279
+ id: p.name,
280
+ label: p.displayName || p.name,
281
+ icon: <Folder aria-hidden="true" className="h-4 w-4 opacity-70"/>,
282
+ selected: p.name === id.project?.name,
283
+ onSelect: () => id.setProject(p.name),
284
+ })) }
285
+ />
286
+ );
287
+ };
288
+
289
+ export interface UserMenuProps {
290
+ /** Extra rows above Sign out — Profile, API keys, whatever the surface has. */
291
+ readonly items?: ReadonlyArray<ChromeItem>;
292
+ }
293
+
294
+ /** Who is signed in, and how to stop being signed in. */
295
+ export const UserMenu = ({ items = [] }: UserMenuProps): React.ReactElement => {
296
+ const id = useIdentity();
297
+ const who = id.account?.displayName || id.account?.email || id.account?.name || 'Account';
298
+ return (
299
+ <Switcher
300
+ testId="user-menu"
301
+ align="end"
302
+ title={ id.status === 'ready' ? who : 'Account' }
303
+ label={ id.status === 'ready' ? who : 'Sign in' }
304
+ icon={ id.status === 'ready' ?
305
+ <Monogram label={ who } logo={ id.account?.avatar }/> :
306
+ <User aria-hidden="true" className="h-4 w-4 opacity-70"/> }
307
+ status={ id.status }
308
+ error={ id.error }
309
+ onSignIn={ id.signIn }
310
+ signInLabel={ `Sign in with ${ id.whiteLabel.iamDomain }` }
311
+ items={ [
312
+ { id: 'account', label: id.account?.email || who, icon: <User aria-hidden="true" className="h-4 w-4 opacity-70"/> },
313
+ ...items,
314
+ ] }
315
+ footer={ id.status === 'ready' ?
316
+ [ { id: 'sign-out', label: 'Sign out', danger: true, icon: <LogOut aria-hidden="true" className="h-4 w-4"/>, onSelect: id.signOut } ] :
317
+ [] }
318
+ />
319
+ );
320
+ };
321
+
322
+ export interface SettingsMenuProps {
323
+ /** The surface's own settings rows. */
324
+ readonly items?: ReadonlyArray<ChromeItem>;
325
+ /** Where "All settings" goes. */
326
+ readonly href?: string;
327
+ }
328
+
329
+ /** The surface's settings, in the one place a user looks for them. */
330
+ export const SettingsMenu = ({ items = [], href = '/settings' }: SettingsMenuProps): React.ReactElement => (
331
+ <Switcher
332
+ testId="settings-menu"
333
+ align="end"
334
+ title="Settings"
335
+ label="Settings"
336
+ icon={ <Settings aria-hidden="true" className="h-4 w-4 opacity-70"/> }
337
+ items={ items }
338
+ empty="No settings on this surface."
339
+ footer={ [ { id: 'all-settings', label: 'All settings', href, icon: <Settings aria-hidden="true" className="h-4 w-4 opacity-70"/> } ] }
340
+ />
341
+ );
342
+
343
+ // --- The one nav -----------------------------------------------------------
344
+
345
+ export interface NavLink {
346
+ readonly href: string;
347
+ readonly label: string;
348
+ readonly active?: boolean;
349
+ }
350
+
351
+ export interface AppNavProps {
352
+ /** Brand slot — defaults to the host-resolved org name. */
353
+ readonly brand?: React.ReactNode;
354
+ /** Where the brand links to. */
355
+ readonly home?: string;
356
+ readonly links?: ReadonlyArray<NavLink>;
357
+ /** Show the org switcher. Default true. */
358
+ readonly org?: boolean;
359
+ /** Show the project switcher. Default false — most surfaces have no projects. */
360
+ readonly project?: boolean;
361
+ /** Rows for the settings menu; omit the prop to hide the menu. */
362
+ readonly settings?: ReadonlyArray<ChromeItem>;
363
+ /** Extra rows in the user menu. */
364
+ readonly userItems?: ReadonlyArray<ChromeItem>;
365
+ /** Free slot between the links and the right-hand cluster. */
366
+ readonly children?: React.ReactNode;
367
+ }
368
+
369
+ /**
370
+ * The ONE nav. Brand, org, project, links, settings, user — in that order, at
371
+ * every surface. On a phone the links collapse into the same sheet the
372
+ * switchers use, so there is no second mobile menu to maintain.
373
+ */
374
+ export const AppNav = ({
375
+ brand,
376
+ home = '/',
377
+ links = [],
378
+ org = true,
379
+ project = false,
380
+ settings,
381
+ userItems,
382
+ children,
383
+ }: AppNavProps): React.ReactElement => {
384
+ const id = useIdentity();
385
+ const narrow = useIsNarrow();
386
+ const [ menu, setMenu ] = React.useState(false);
387
+
388
+ return (
389
+ <header
390
+ data-testid="app-nav"
391
+ className="sticky top-0 z-50 border-b border-[var(--color-border-divider,rgba(127,127,127,0.25))] bg-[var(--color-bg-body,inherit)]"
392
+ >
393
+ <div className="mx-auto flex h-14 max-w-7xl items-center gap-2 px-4">
394
+ { narrow && links.length > 0 ? (
395
+ <DrawerRoot open={ menu } onOpenChange={ (d) => setMenu(d.open) } placement="bottom" size="md">
396
+ <button
397
+ type="button"
398
+ aria-label="Menu"
399
+ data-testid="nav-menu"
400
+ className="rounded-md p-2 hover:bg-[rgba(127,127,127,0.14)]"
401
+ onClick={ () => setMenu(true) }
402
+ >
403
+ <Menu aria-hidden="true" className="h-5 w-5"/>
404
+ </button>
405
+ <DrawerContent data-testid="nav-menu-panel">
406
+ <DrawerHeader><DrawerTitle>Menu</DrawerTitle></DrawerHeader>
407
+ <DrawerBody>
408
+ { links.map((l) => (
409
+ <a
410
+ key={ l.href }
411
+ href={ l.href }
412
+ onClick={ () => setMenu(false) }
413
+ aria-current={ l.active ? 'page' : undefined }
414
+ className="block rounded-md px-2 py-2.5 text-sm hover:bg-[rgba(127,127,127,0.14)]"
415
+ >
416
+ { l.label }
417
+ </a>
418
+ )) }
419
+ </DrawerBody>
420
+ </DrawerContent>
421
+ </DrawerRoot>
422
+ ) : null }
423
+
424
+ <a href={ home } className="flex items-center gap-2 font-semibold tracking-tight">
425
+ { brand ?? <><Building2 aria-hidden="true" className="h-4 w-4 opacity-70"/>{ id.whiteLabel.name }</> }
426
+ </a>
427
+
428
+ { org ? <OrgSwitcher/> : null }
429
+ { project ? <ProjectSwitcher/> : null }
430
+
431
+ { !narrow && links.length > 0 ? (
432
+ <nav className="flex items-center gap-1">
433
+ { links.map((l) => (
434
+ <a
435
+ key={ l.href }
436
+ href={ l.href }
437
+ aria-current={ l.active ? 'page' : undefined }
438
+ className={ cn(
439
+ 'rounded-md px-3 py-1.5 text-sm font-medium',
440
+ l.active ?
441
+ 'bg-[rgba(127,127,127,0.14)]' :
442
+ 'opacity-75 hover:opacity-100',
443
+ ) }
444
+ >
445
+ { l.label }
446
+ </a>
447
+ )) }
448
+ </nav>
449
+ ) : null }
450
+
451
+ <div className="flex flex-1 items-center justify-end gap-1">
452
+ { children }
453
+ { settings ? <SettingsMenu items={ settings }/> : null }
454
+ <UserMenu items={ userItems }/>
455
+ </div>
456
+ </div>
457
+ </header>
458
+ );
459
+ };
package/src/drawer.tsx CHANGED
@@ -1,10 +1,19 @@
1
+ import { Sheet as GuiSheet } from '@hanzo/gui';
1
2
  import * as RadixDialog from '@radix-ui/react-dialog';
2
3
  import * as React from 'react';
3
4
 
5
+ import { useIsNarrow } from './use-narrow';
4
6
  import { cn } from './utils';
5
7
 
6
8
  import { CloseButton } from './close-button';
7
9
 
10
+ // A drawer is a side panel on a wide screen and a bottom sheet on a phone. Below
11
+ // `NARROW_PX` the content is presented by gui's Sheet — draggable, with
12
+ // snap-to-dismiss and momentum — which is what a native app does and what a
13
+ // `position: fixed` panel with a slide-in keyframe cannot fake. Same components,
14
+ // same props, at every call site. The breakpoint itself lives in ./use-narrow,
15
+ // so "is this phone-sized?" has one answer across the package.
16
+
8
17
  // ─── DrawerRoot ─────────────────────────────────────────────────
9
18
 
10
19
  export interface DrawerRootProps {
@@ -23,6 +32,18 @@ export interface DrawerRootProps {
23
32
  const DrawerPlacementContext = React.createContext<string>('right');
24
33
  const DrawerSizeContext = React.createContext<string>('md');
25
34
 
35
+ interface DrawerStateValue {
36
+ readonly open: boolean;
37
+ readonly setOpen: (next: boolean) => void;
38
+ readonly modal: boolean;
39
+ }
40
+
41
+ const DrawerStateContext = React.createContext<DrawerStateValue>({
42
+ open: false,
43
+ setOpen: () => { /* noop */ },
44
+ modal: true,
45
+ });
46
+
26
47
  export const DrawerRoot = (props: DrawerRootProps) => {
27
48
  const {
28
49
  children,
@@ -34,21 +55,35 @@ export const DrawerRoot = (props: DrawerRootProps) => {
34
55
  size = 'md',
35
56
  } = props;
36
57
 
37
- const handleOpenChange = React.useCallback((nextOpen: boolean) => {
58
+ // The root owns the open state (controllable) rather than leaving it inside
59
+ // Radix, because the phone presentation is a gui Sheet that lives outside the
60
+ // dialog tree and needs the same state.
61
+ const [ uncontrolled, setUncontrolled ] = React.useState(defaultOpen ?? false);
62
+ const isControlled = open !== undefined;
63
+ const isOpen = isControlled ? open : uncontrolled;
64
+
65
+ const setOpen = React.useCallback((nextOpen: boolean) => {
66
+ if (!isControlled) setUncontrolled(nextOpen);
38
67
  onOpenChange?.({ open: nextOpen });
39
- }, [ onOpenChange ]);
68
+ }, [ isControlled, onOpenChange ]);
69
+
70
+ const state = React.useMemo(
71
+ () => ({ open: isOpen, setOpen, modal }),
72
+ [ isOpen, setOpen, modal ],
73
+ );
40
74
 
41
75
  return (
42
76
  <DrawerPlacementContext.Provider value={ placement }>
43
77
  <DrawerSizeContext.Provider value={ size }>
44
- <RadixDialog.Root
45
- open={ open }
46
- defaultOpen={ defaultOpen }
47
- onOpenChange={ handleOpenChange }
48
- modal={ modal }
49
- >
50
- { children }
51
- </RadixDialog.Root>
78
+ <DrawerStateContext.Provider value={ state }>
79
+ <RadixDialog.Root
80
+ open={ isOpen }
81
+ onOpenChange={ setOpen }
82
+ modal={ modal }
83
+ >
84
+ { children }
85
+ </RadixDialog.Root>
86
+ </DrawerStateContext.Provider>
52
87
  </DrawerSizeContext.Provider>
53
88
  </DrawerPlacementContext.Provider>
54
89
  );
@@ -98,6 +133,44 @@ export const DrawerContent = React.forwardRef<HTMLDivElement, DrawerContentProps
98
133
  const { children, portalled = true, portalRef, offset: _offset, backdrop = true, className, ...rest } = props;
99
134
  const placement = React.useContext(DrawerPlacementContext);
100
135
  const size = React.useContext(DrawerSizeContext);
136
+ const { open, setOpen, modal } = React.useContext(DrawerStateContext);
137
+ const isNarrow = useIsNarrow();
138
+
139
+ if (isNarrow) {
140
+ return (
141
+ <GuiSheet
142
+ open={ open }
143
+ onOpenChange={ setOpen }
144
+ modal={ modal }
145
+ dismissOnSnapToBottom
146
+ snapPointsMode="percent"
147
+ snapPoints={ [ 88 ] }
148
+ transition="medium"
149
+ >
150
+ { backdrop && (
151
+ <GuiSheet.Overlay
152
+ transition="lazy"
153
+ enterStyle={{ opacity: 0 }}
154
+ exitStyle={{ opacity: 0 }}
155
+ className="bg-black/50"
156
+ />
157
+ ) }
158
+ <GuiSheet.Handle/>
159
+ { /* Sheet.Frame is a gui component, not a DOM node — the DOM ref the
160
+ Chakra contract hands us has nothing to attach to here. */ }
161
+ <GuiSheet.Frame
162
+ unstyled
163
+ className={ cn(
164
+ 'flex flex-col rounded-t-2xl bg-[var(--color-drawer-bg)] shadow-[var(--shadow-drawer)]',
165
+ className,
166
+ ) }
167
+ { ...(rest as object) }
168
+ >
169
+ { children }
170
+ </GuiSheet.Frame>
171
+ </GuiSheet>
172
+ );
173
+ }
101
174
 
102
175
  const content = (
103
176
  <>