@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.
- package/dist/avatar.cjs +7 -6
- package/dist/avatar.js +7 -5
- package/dist/badge.cjs +30 -32
- package/dist/badge.js +30 -32
- package/dist/bank.d.cts +3 -3
- package/dist/bank.d.ts +3 -3
- package/dist/checkbox.cjs +4 -4
- package/dist/checkbox.d.cts +3 -2
- package/dist/checkbox.d.ts +3 -2
- package/dist/checkbox.js +5 -4
- package/dist/chrome.cjs +950 -0
- package/dist/chrome.d.cts +93 -0
- package/dist/chrome.d.ts +93 -0
- package/dist/chrome.js +921 -0
- package/dist/drawer.cjs +86 -20
- package/dist/drawer.js +85 -19
- package/dist/expiration-selector.cjs +3 -2
- package/dist/expiration-selector.js +3 -2
- package/dist/greeks-display.cjs +6 -6
- package/dist/greeks-display.js +6 -6
- package/dist/gui.cjs +13 -0
- package/dist/gui.d.cts +2 -0
- package/dist/gui.d.ts +2 -0
- package/dist/gui.js +2 -0
- package/dist/heading.cjs +4 -2
- package/dist/heading.js +4 -2
- package/dist/iam.cjs +206 -0
- package/dist/iam.d.cts +65 -0
- package/dist/iam.d.ts +65 -0
- package/dist/iam.js +201 -0
- package/dist/icons.cjs +13 -0
- package/dist/icons.d.cts +1 -0
- package/dist/icons.d.ts +1 -0
- package/dist/icons.js +2 -0
- package/dist/identity.cjs +420 -0
- package/dist/identity.d.cts +64 -0
- package/dist/identity.d.ts +64 -0
- package/dist/identity.js +398 -0
- package/dist/index.cjs +1597 -833
- package/dist/index.d.cts +7 -3
- package/dist/index.d.ts +7 -3
- package/dist/index.js +1574 -828
- package/dist/input.cjs +3 -1
- package/dist/input.d.cts +3 -5
- package/dist/input.d.ts +3 -5
- package/dist/input.js +3 -1
- package/dist/lux.config.cjs +23 -2
- package/dist/lux.config.d.cts +28 -10
- package/dist/lux.config.d.ts +28 -10
- package/dist/lux.config.js +23 -2
- package/dist/menu.cjs +4 -4
- package/dist/menu.js +4 -4
- package/dist/next.cjs +99 -0
- package/dist/next.d.cts +29 -0
- package/dist/next.d.ts +29 -0
- package/dist/next.js +96 -0
- package/dist/option-chain.cjs +1 -1
- package/dist/option-chain.js +1 -1
- package/dist/option-position.cjs +2 -1
- package/dist/option-position.js +2 -1
- package/dist/pnl-diagram.cjs +8 -6
- package/dist/pnl-diagram.js +8 -6
- package/dist/progress.cjs +7 -6
- package/dist/progress.js +7 -5
- package/dist/provider.cjs +136 -4
- package/dist/provider.d.cts +15 -3
- package/dist/provider.d.ts +15 -3
- package/dist/provider.js +136 -5
- package/dist/radio.cjs +9 -7
- package/dist/radio.js +10 -7
- package/dist/separator.cjs +3 -1
- package/dist/separator.js +3 -1
- package/dist/slider.cjs +28 -16
- package/dist/slider.js +28 -15
- package/dist/strategy-builder.cjs +2 -1
- package/dist/strategy-builder.js +2 -1
- package/dist/switch.cjs +11 -12
- package/dist/switch.js +12 -12
- package/dist/tag.cjs +31 -33
- package/dist/tag.js +30 -32
- package/dist/textarea.cjs +3 -1
- package/dist/textarea.js +3 -1
- package/dist/tooltip.cjs +25 -27
- package/dist/tooltip.js +24 -26
- package/dist/use-narrow.cjs +46 -0
- package/dist/use-narrow.d.cts +12 -0
- package/dist/use-narrow.d.ts +12 -0
- package/dist/use-narrow.js +20 -0
- package/dist/vite.cjs +40 -0
- package/dist/vite.d.cts +29 -0
- package/dist/vite.d.ts +29 -0
- package/dist/vite.js +38 -0
- package/dist/white-label.cjs +132 -0
- package/dist/white-label.d.cts +59 -0
- package/dist/white-label.d.ts +59 -0
- package/dist/white-label.js +124 -0
- package/package.json +85 -19
- package/src/avatar.tsx +22 -16
- package/src/checkbox.tsx +15 -10
- package/src/chrome.tsx +459 -0
- package/src/drawer.tsx +83 -10
- package/src/expiration-selector.tsx +3 -2
- package/src/greeks-display.tsx +9 -6
- package/src/gui.ts +17 -0
- package/src/heading.tsx +12 -8
- package/src/iam.ts +170 -0
- package/src/icons.ts +18 -0
- package/src/identity.tsx +318 -0
- package/src/index.ts +44 -1
- package/src/input.tsx +13 -9
- package/src/lux.config.ts +16 -15
- package/src/menu.tsx +4 -4
- package/src/next.ts +142 -0
- package/src/option-chain.tsx +1 -1
- package/src/option-position.tsx +2 -1
- package/src/pnl-diagram.tsx +11 -6
- package/src/progress.tsx +15 -8
- package/src/provider.tsx +48 -7
- package/src/radio.tsx +15 -11
- package/src/separator.tsx +8 -3
- package/src/slider.tsx +35 -19
- package/src/strategy-builder.tsx +3 -2
- package/src/switch.tsx +17 -16
- package/src/textarea.tsx +8 -4
- package/src/tooltip.tsx +2 -21
- package/src/use-narrow.ts +40 -0
- package/src/vite.ts +78 -0
- package/src/white-label.ts +240 -0
- package/tokens.css +544 -319
- package/src/tokens.css +0 -438
package/src/avatar.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import
|
|
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
|
-
<
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
</
|
|
139
|
+
</GuiAvatar.Fallback>
|
|
134
140
|
);
|
|
135
141
|
},
|
|
136
142
|
);
|
package/src/checkbox.tsx
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import
|
|
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:
|
|
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<
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
286
|
+
</GuiCheckbox.Indicator>
|
|
282
287
|
) }
|
|
283
|
-
</
|
|
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
|
+
};
|