@luxfi/ui 7.4.0 → 7.4.2

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 (130) hide show
  1. package/dist/avatar.cjs +7 -6
  2. package/dist/avatar.js +7 -5
  3. package/dist/badge.cjs +30 -32
  4. package/dist/badge.js +30 -32
  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 +950 -0
  12. package/dist/chrome.d.cts +93 -0
  13. package/dist/chrome.d.ts +93 -0
  14. package/dist/chrome.js +921 -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 +206 -0
  28. package/dist/iam.d.cts +65 -0
  29. package/dist/iam.d.ts +65 -0
  30. package/dist/iam.js +201 -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 +420 -0
  36. package/dist/identity.d.cts +64 -0
  37. package/dist/identity.d.ts +64 -0
  38. package/dist/identity.js +398 -0
  39. package/dist/index.cjs +1597 -833
  40. package/dist/index.d.cts +7 -3
  41. package/dist/index.d.ts +7 -3
  42. package/dist/index.js +1574 -828
  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 +99 -0
  54. package/dist/next.d.cts +29 -0
  55. package/dist/next.d.ts +29 -0
  56. package/dist/next.js +96 -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 +136 -4
  66. package/dist/provider.d.cts +15 -3
  67. package/dist/provider.d.ts +15 -3
  68. package/dist/provider.js +136 -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 +31 -33
  80. package/dist/tag.js +30 -32
  81. package/dist/textarea.cjs +3 -1
  82. package/dist/textarea.js +3 -1
  83. package/dist/tooltip.cjs +25 -27
  84. package/dist/tooltip.js +24 -26
  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 +40 -0
  90. package/dist/vite.d.cts +29 -0
  91. package/dist/vite.d.ts +29 -0
  92. package/dist/vite.js +38 -0
  93. package/dist/white-label.cjs +132 -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 +124 -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/expiration-selector.tsx +3 -2
  103. package/src/greeks-display.tsx +9 -6
  104. package/src/gui.ts +17 -0
  105. package/src/heading.tsx +12 -8
  106. package/src/iam.ts +170 -0
  107. package/src/icons.ts +18 -0
  108. package/src/identity.tsx +318 -0
  109. package/src/index.ts +44 -1
  110. package/src/input.tsx +13 -9
  111. package/src/lux.config.ts +16 -15
  112. package/src/menu.tsx +4 -4
  113. package/src/next.ts +142 -0
  114. package/src/option-chain.tsx +1 -1
  115. package/src/option-position.tsx +2 -1
  116. package/src/pnl-diagram.tsx +11 -6
  117. package/src/progress.tsx +15 -8
  118. package/src/provider.tsx +48 -7
  119. package/src/radio.tsx +15 -11
  120. package/src/separator.tsx +8 -3
  121. package/src/slider.tsx +35 -19
  122. package/src/strategy-builder.tsx +3 -2
  123. package/src/switch.tsx +17 -16
  124. package/src/textarea.tsx +8 -4
  125. package/src/tooltip.tsx +2 -21
  126. package/src/use-narrow.ts +40 -0
  127. package/src/vite.ts +78 -0
  128. package/src/white-label.ts +240 -0
  129. package/tokens.css +544 -319
  130. package/src/tokens.css +0 -438
package/src/avatar.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import * as RadixAvatar from '@radix-ui/react-avatar';
3
+ import { Avatar as GuiAvatar } from '@hanzo/gui';
4
4
  import * as React from 'react';
5
5
 
6
6
  import { cn } from './utils';
@@ -80,9 +80,12 @@ export const Avatar = React.forwardRef<HTMLSpanElement, AvatarProps>(
80
80
  const variant = variantProp ?? group.variant ?? DEFAULT_VARIANT;
81
81
  const isBorderless = borderless ?? group.borderless ?? false;
82
82
 
83
+ // gui's Avatar — the same load/fallback state machine, but as a gui
84
+ // primitive, so it themes and lays out identically on native.
83
85
  return (
84
- <RadixAvatar.Root
85
- ref={ ref }
86
+ <GuiAvatar
87
+ ref={ ref as never }
88
+ unstyled
86
89
  className={ cn(
87
90
  'relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full align-middle',
88
91
  SIZE_CLASSES[size],
@@ -90,20 +93,22 @@ export const Avatar = React.forwardRef<HTMLSpanElement, AvatarProps>(
90
93
  !isBorderless && 'ring-2 ring-white dark:ring-gray-900',
91
94
  className,
92
95
  ) }
93
- { ...rest }
96
+ { ...(rest as object) }
94
97
  >
95
98
  <AvatarFallback name={ name } icon={ icon }>
96
99
  { fallback }
97
100
  </AvatarFallback>
98
- <RadixAvatar.Image
99
- className="h-full w-full rounded-[inherit] object-cover"
100
- src={ src }
101
- srcSet={ srcSet }
102
- loading={ loading }
103
- alt={ name ?? '' }
104
- />
101
+ { src != null && (
102
+ <GuiAvatar.Image
103
+ className="h-full w-full rounded-[inherit] object-cover"
104
+ src={ src }
105
+ srcSet={ srcSet }
106
+ loading={ loading }
107
+ alt={ name ?? '' }
108
+ />
109
+ ) }
105
110
  { children }
106
- </RadixAvatar.Root>
111
+ </GuiAvatar>
107
112
  );
108
113
  },
109
114
  );
@@ -119,18 +124,19 @@ const AvatarFallback = React.forwardRef<HTMLSpanElement, AvatarFallbackInternalP
119
124
  function AvatarFallback(props, ref) {
120
125
  const { name, icon, children, className, ...rest } = props;
121
126
  return (
122
- <RadixAvatar.Fallback
123
- ref={ ref }
127
+ <GuiAvatar.Fallback
128
+ ref={ ref as never }
129
+ unstyled
124
130
  className={ cn(
125
131
  'flex h-full w-full items-center justify-center font-medium',
126
132
  className,
127
133
  ) }
128
- { ...rest }
134
+ { ...(rest as object) }
129
135
  >
130
136
  { children }
131
137
  { name != null && children == null && <>{ getInitials(name) }</> }
132
138
  { name == null && children == null && icon }
133
- </RadixAvatar.Fallback>
139
+ </GuiAvatar.Fallback>
134
140
  );
135
141
  },
136
142
  );
package/src/checkbox.tsx CHANGED
@@ -1,9 +1,11 @@
1
- import * as RadixCheckbox from '@radix-ui/react-checkbox';
2
- // chakra() HOC removed — pure Radix + Tailwind
1
+ import { Checkbox as GuiCheckbox } from '@hanzo/gui';
3
2
  import * as React from 'react';
4
3
 
5
4
  import { cn } from './utils';
6
5
 
6
+ /** Tri-state, as the Chakra/Radix contract consumers already code against. */
7
+ type CheckedState = boolean | 'indeterminate';
8
+
7
9
  const NOOP = () => { /* noop */ };
8
10
 
9
11
  // ─── CheckboxGroup context ───────────────────────────────────────────
@@ -98,7 +100,7 @@ export interface CheckboxProps extends Omit<React.ComponentPropsWithoutRef<'labe
98
100
  rootRef?: React.Ref<HTMLLabelElement>;
99
101
  checked?: boolean | 'indeterminate';
100
102
  defaultChecked?: boolean;
101
- onCheckedChange?: (checked: RadixCheckbox.CheckedState) => void;
103
+ onCheckedChange?: (checked: CheckedState) => void;
102
104
  onChange?: React.FormEventHandler<HTMLLabelElement>;
103
105
  disabled?: boolean;
104
106
  readOnly?: boolean;
@@ -189,12 +191,12 @@ const CheckboxBase = React.forwardRef<HTMLInputElement, CheckboxProps>(
189
191
  const isInGroup = group !== null && value !== undefined;
190
192
  const groupChecked = isInGroup ? group.value.includes(value) : undefined;
191
193
 
192
- const [ internalChecked, setInternalChecked ] = React.useState<RadixCheckbox.CheckedState>(
194
+ const [ internalChecked, setInternalChecked ] = React.useState<CheckedState>(
193
195
  defaultChecked ?? false,
194
196
  );
195
197
 
196
198
  const isControlled = checkedProp !== undefined || isInGroup;
197
- let checkedState: RadixCheckbox.CheckedState;
199
+ let checkedState: CheckedState;
198
200
  if (checkedProp !== undefined) {
199
201
  checkedState = checkedProp;
200
202
  } else if (isInGroup) {
@@ -204,7 +206,7 @@ const CheckboxBase = React.forwardRef<HTMLInputElement, CheckboxProps>(
204
206
  }
205
207
 
206
208
  const handleCheckedChange = React.useCallback(
207
- (nextChecked: RadixCheckbox.CheckedState) => {
209
+ (nextChecked: CheckedState) => {
208
210
  if (readOnly) return;
209
211
 
210
212
  if (!isControlled) {
@@ -252,7 +254,10 @@ const CheckboxBase = React.forwardRef<HTMLInputElement, CheckboxProps>(
252
254
  data-readonly={ readOnly || undefined }
253
255
  { ...rest }
254
256
  >
255
- <RadixCheckbox.Root
257
+ { /* gui's Checkbox — role=checkbox + aria-checked from its headless core,
258
+ a real Pressable on native. Paint stays in the Lux classes. */ }
259
+ <GuiCheckbox
260
+ unstyled
256
261
  checked={ checkedState }
257
262
  onCheckedChange={ handleCheckedChange }
258
263
  disabled={ disabled || readOnly }
@@ -272,15 +277,15 @@ const CheckboxBase = React.forwardRef<HTMLInputElement, CheckboxProps>(
272
277
  ) }
273
278
  >
274
279
  { icon ?? (
275
- <RadixCheckbox.Indicator className={ cn('flex items-center justify-center', sizeClasses.icon) }>
280
+ <GuiCheckbox.Indicator className={ cn('flex items-center justify-center', sizeClasses.icon) }>
276
281
  { checkedState === 'indeterminate' ? (
277
282
  <IndeterminateIcon className={ sizeClasses.icon }/>
278
283
  ) : (
279
284
  <CheckIcon className={ sizeClasses.icon }/>
280
285
  ) }
281
- </RadixCheckbox.Indicator>
286
+ </GuiCheckbox.Indicator>
282
287
  ) }
283
- </RadixCheckbox.Root>
288
+ </GuiCheckbox>
284
289
  { /* Hidden native input for form compatibility and ref forwarding */ }
285
290
  <input
286
291
  ref={ setHiddenInputRef }
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
+ };