@astryxdesign/cli 0.4.7 → 0.5.0-canary.009bcb3
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/CHANGELOG.md +64 -0
- package/README.md +53 -51
- package/api/docs/docs.doc.mjs +2 -2
- package/api/index.d.mts +1 -1
- package/api/index.mjs +1 -1
- package/api/search/search.mjs +50 -4
- package/api/search/search.test.mjs +71 -0
- package/api/template/data-token-fallbacks.test.mjs +85 -0
- package/api/template/template.doc.mjs +2 -2
- package/api/theme/build/build.mjs +43 -44
- package/api/theme/targets/targets.d.mts +18 -0
- package/api/theme/targets/targets.mjs +87 -0
- package/api/theme/targets/targets.test.mjs +66 -0
- package/api/theme/theme.d.mts +1 -0
- package/api/theme/theme.mjs +3 -1
- package/api/theme/theme.type.d.mts +23 -0
- package/api/theme/theme.type.mjs +21 -1
- package/api/theme/themeTargets.doc.d.mts +11 -0
- package/api/theme/themeTargets.doc.mjs +58 -0
- package/api/theme/themeTemplate.doc.mjs +3 -3
- package/assets/codemods/__tests__/registry.test.mjs +1 -0
- package/assets/codemods/registry.mjs +1 -0
- package/assets/codemods/transforms/v0.5.0/__tests__/next-codemods.test.mjs +127 -0
- package/assets/codemods/transforms/v0.5.0/banner-collapsible-content.mjs +171 -0
- package/assets/codemods/transforms/v0.5.0/index.mjs +20 -0
- package/assets/docs/README.md +50 -0
- package/assets/docs/cli-integrations.doc.mjs +4 -4
- package/assets/docs/theme.doc.dense.mjs +1 -1
- package/assets/docs/theme.doc.mjs +1 -1
- package/assets/docs/typography.doc.mjs +2 -2
- package/assets/templates/blocks/components/Banner/BannerCollapsibleContent.doc.mjs +1 -1
- package/assets/templates/blocks/components/Banner/BannerCollapsibleContent.tsx +1 -1
- package/assets/templates/blocks/components/DateInput/DateInputClearable.tsx +5 -1
- package/assets/templates/blocks/components/DateInput/DateInputDateRange.tsx +6 -1
- package/assets/templates/blocks/components/DateInput/DateInputFormats.tsx +5 -1
- package/assets/templates/blocks/components/DateInput/DateInputShowcase.tsx +4 -1
- package/assets/templates/blocks/components/DateInput/DateInputWithDescription.tsx +5 -1
- package/assets/templates/blocks/components/DateInput/DateInputWithValidation.tsx +5 -1
- package/assets/templates/blocks/components/Dialog/DialogAdaptivePresentation.doc.mjs +25 -0
- package/assets/templates/blocks/components/Dialog/DialogAdaptivePresentation.tsx +167 -0
- package/assets/templates/blocks/components/Dialog/DialogScrollingContent.tsx +1 -1
- package/assets/templates/blocks/components/HoverCard/HoverCardHookUsage.tsx +4 -1
- package/assets/templates/blocks/components/Step/StepContent.doc.mjs +14 -0
- package/assets/templates/blocks/components/Step/StepContent.tsx +32 -0
- package/assets/templates/blocks/components/Step/StepIndicator.doc.mjs +14 -0
- package/assets/templates/blocks/components/Step/StepIndicator.tsx +60 -0
- package/assets/templates/blocks/components/Step/StepShowcase.doc.mjs +15 -0
- package/assets/templates/blocks/components/Step/StepShowcase.tsx +26 -0
- package/assets/templates/blocks/components/Step/StepStates.doc.mjs +14 -0
- package/assets/templates/blocks/components/Step/StepStates.tsx +46 -0
- package/assets/templates/blocks/components/Stepper/StepperCustomContent.doc.mjs +22 -0
- package/assets/templates/blocks/components/Stepper/StepperCustomContent.tsx +126 -0
- package/assets/templates/blocks/components/Stepper/StepperIndicatorModes.doc.mjs +1 -1
- package/assets/templates/blocks/components/Stepper/StepperIndicatorModes.tsx +17 -5
- package/assets/templates/blocks/components/Stepper/StepperOnTrackHorizontal.doc.mjs +14 -0
- package/assets/templates/blocks/components/Stepper/StepperOnTrackHorizontal.tsx +25 -0
- package/assets/templates/blocks/components/Stepper/StepperOnTrackVertical.doc.mjs +2 -2
- package/assets/templates/blocks/components/Stepper/StepperOnTrackVertical.tsx +1 -1
- package/assets/templates/blocks/components/Stepper/StepperShowcase.doc.mjs +1 -1
- package/assets/templates/blocks/components/Stepper/StepperShowcase.tsx +6 -7
- package/assets/templates/blocks/components/Stepper/StepperStatus.tsx +1 -1
- package/assets/templates/pages/dashboard-cohort-funnel/page.tsx +1 -1
- package/assets/templates/pages/dashboard-data/page.tsx +1 -1
- package/assets/templates/pages/dashboard-portfolio/page.tsx +5 -5
- package/assets/templates/pages/dashboard-service-monitoring/page.tsx +1 -1
- package/assets/templates/pages/mixed-gallery/page.tsx +12 -3
- package/assets/templates/pages/settings-dialog/page.tsx +2543 -757
- package/assets/templates/pages/settings-dialog/template.doc.mjs +1 -2
- package/assets/templates/pages/table-filter/page.tsx +4093 -0
- package/assets/templates/pages/table-filter/template.doc.mjs +12 -0
- package/assets/templates/pages/table-grouped/page.tsx +151 -144
- package/assets/templates/pages/theme-showcase/page.tsx +36 -17
- package/assets/templates/themes/chocolate/chocolateTheme.ts +3 -1
- package/assets/templates/themes/matcha/matchaTheme.ts +3 -1
- package/assets/templates/themes/neutral/neutralTheme.ts +16 -9
- package/assets/templates/themes/stone/stoneTheme.ts +3 -1
- package/clients/cli/commands/build-theme.color-scheme.test.mjs +7 -6
- package/clients/cli/commands/build-theme.data-tokens.test.mjs +144 -0
- package/clients/cli/commands/build-theme.mjs +85 -0
- package/clients/cli/commands/build-theme.variants.test.mjs +3 -0
- package/clients/cli/commands/dialog-adaptive-template.test.mjs +24 -0
- package/clients/cli/commands/theme-targets.behavior.test.mjs +64 -0
- package/clients/cli/commands/theme-targets.doc.mjs +38 -0
- package/clients/cli/commands/theme-template.doc.mjs +2 -2
- package/clients/cli/commands/theme.doc.mjs +4 -2
- package/clients/cli/index.mjs +1 -0
- package/clients/cli/lib/component-format.mjs +2 -2
- package/clients/cli/lib/manifest.mjs +2 -0
- package/foundation/discovery/component-discovery.mjs +10 -9
- package/foundation/discovery/hook-discovery.mjs +2 -1
- package/foundation/discovery/hook-discovery.test.mjs +156 -37
- package/foundation/discovery/theming-targets.d.mts +86 -0
- package/foundation/discovery/theming-targets.mjs +202 -0
- package/foundation/discovery/theming-targets.test.mjs +245 -0
- package/foundation/fs/paths.d.mts +16 -0
- package/foundation/fs/paths.mjs +36 -0
- package/foundation/fs/paths.test.mjs +29 -1
- package/foundation/response/response-types.doc.mjs +7 -2
- package/package.json +9 -9
- package/assets/templates/blocks/components/Stepper/StepperHorizontal.doc.mjs +0 -14
- package/assets/templates/blocks/components/Stepper/StepperHorizontal.tsx +0 -24
- package/assets/templates/blocks/components/Stepper/StepperMultiStepForm.doc.mjs +0 -14
- package/assets/templates/blocks/components/Stepper/StepperMultiStepForm.tsx +0 -92
- package/assets/templates/blocks/components/Stepper/StepperVerticalOnboarding.doc.mjs +0 -14
- package/assets/templates/blocks/components/Stepper/StepperVerticalOnboarding.tsx +0 -40
|
@@ -2,816 +2,2602 @@
|
|
|
2
2
|
|
|
3
3
|
'use client';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
5
|
+
/**
|
|
6
|
+
* A settings dialog for a product with enough preferences to need navigation,
|
|
7
|
+
* search, grouping and responsive behavior—not just a short form.
|
|
8
|
+
*
|
|
9
|
+
* The side navigation and panel content read from one registry, so adding or
|
|
10
|
+
* renaming a destination updates both desktop and narrow layouts. Settings are
|
|
11
|
+
* grouped into muted cards of aligned rows; visual choices show live previews;
|
|
12
|
+
* every row switches to the same stacked layout when the panel runs out of
|
|
13
|
+
* width.
|
|
14
|
+
*
|
|
15
|
+
* The dialog is named by the visible Settings heading via `aria-labelledby`,
|
|
16
|
+
* the close control stays reachable while the panel scrolls, and the side rail
|
|
17
|
+
* becomes a horizontal tab strip below 640px rather than being squeezed into
|
|
18
|
+
* an unusable narrow column.
|
|
19
|
+
*
|
|
20
|
+
* ## Extending this template
|
|
21
|
+
*
|
|
22
|
+
* **Add a row before you add a panel.** A setting is one row in an existing
|
|
23
|
+
* subject. A panel is a navigation destination, so adding one is a hierarchy
|
|
24
|
+
* decision rather than a convenient place to put one control. The grouped
|
|
25
|
+
* registry below is the one list both shells read; add a panel there only when
|
|
26
|
+
* its settings cannot be named honestly by an existing destination.
|
|
27
|
+
*
|
|
28
|
+
* **Customise from the system outward.** Stop at the first level that works:
|
|
29
|
+
* (1) an Astryx component prop, (2) a theme token or component override when
|
|
30
|
+
* the value belongs across the surface, (3) local `xstyle` for layout the
|
|
31
|
+
* component cannot express. Anything after that needs a comment recording the
|
|
32
|
+
* missing system capability; otherwise one local fix becomes the next
|
|
33
|
+
* contributor's copy-paste pattern.
|
|
34
|
+
*
|
|
35
|
+
* **Choose a control from the value's shape, not taste:** on/off → `Switch`;
|
|
36
|
+
* 2–3 named choices → `SegmentedControl`; 4+ named choices → `Selector`;
|
|
37
|
+
* number → `NumberInput`; free text → `TextInput`; action rather than value →
|
|
38
|
+
* `Button`. A visual choice is the exception: use `SelectableCard` only when
|
|
39
|
+
* comparing the previews IS how someone decides. The row owns the visible
|
|
40
|
+
* label, so every control inside one keeps `isLabelHidden`.
|
|
41
|
+
*
|
|
42
|
+
* **Cards group one subject.** A heading that needs “and” names two cards.
|
|
43
|
+
* Cards carry no description: if a group needs a second sentence to explain
|
|
44
|
+
* why its rows belong together, the rows are not similar enough. Put the
|
|
45
|
+
* explanation on the setting it qualifies instead.
|
|
46
|
+
*
|
|
47
|
+
* **Separate settings by who they affect.** Most panels have one scope and need
|
|
48
|
+
* no extra heading. If a panel mixes settings that change something for a team
|
|
49
|
+
* with settings that change only the current person's experience, group the
|
|
50
|
+
* two scopes explicitly and state who is affected. Never interleave them for
|
|
51
|
+
* topical convenience: mistaking “only me” for “everyone” is a correctness and
|
|
52
|
+
* privacy bug, not a grouping preference.
|
|
53
|
+
*
|
|
54
|
+
* **Preview the result, not the control.** A few visual choices can show one
|
|
55
|
+
* preview per `SelectableCard`; many choices use one `Selector` and one live
|
|
56
|
+
* preview below it. Prefer the real component being configured so the sample
|
|
57
|
+
* cannot drift. For detail most readers do not need, expand a `Collapsible`
|
|
58
|
+
* inside the row. Never open a dialog from this dialog: nested focus traps and
|
|
59
|
+
* two overlapping dismiss paths are not progressive disclosure. If the detail
|
|
60
|
+
* is too large to expand in place, it belongs on a page.
|
|
61
|
+
*/
|
|
62
|
+
|
|
15
63
|
import {Button} from '@astryxdesign/core/Button';
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
64
|
+
import {AspectRatio} from '@astryxdesign/core/AspectRatio';
|
|
65
|
+
import {Card} from '@astryxdesign/core/Card';
|
|
66
|
+
import {Center} from '@astryxdesign/core/Center';
|
|
67
|
+
import {Dialog} from '@astryxdesign/core/Dialog';
|
|
18
68
|
import {Divider} from '@astryxdesign/core/Divider';
|
|
69
|
+
import {Grid} from '@astryxdesign/core/Grid';
|
|
70
|
+
import {HStack} from '@astryxdesign/core/HStack';
|
|
71
|
+
import {useMediaQuery} from '@astryxdesign/core/hooks';
|
|
72
|
+
import {Icon} from '@astryxdesign/core/Icon';
|
|
73
|
+
import {IconButton} from '@astryxdesign/core/IconButton';
|
|
74
|
+
import {Kbd} from '@astryxdesign/core/Kbd';
|
|
75
|
+
import {Layout, LayoutContent} from '@astryxdesign/core/Layout';
|
|
76
|
+
import {ListItem} from '@astryxdesign/core/List';
|
|
77
|
+
import {
|
|
78
|
+
SegmentedControl,
|
|
79
|
+
SegmentedControlItem,
|
|
80
|
+
} from '@astryxdesign/core/SegmentedControl';
|
|
81
|
+
import {SelectableCard} from '@astryxdesign/core/SelectableCard';
|
|
19
82
|
import {Selector} from '@astryxdesign/core/Selector';
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
83
|
+
import {SideNav, SideNavItem, SideNavSection} from '@astryxdesign/core/SideNav';
|
|
84
|
+
import {Stack, StackItem} from '@astryxdesign/core/Stack';
|
|
22
85
|
import {Switch} from '@astryxdesign/core/Switch';
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
86
|
+
import {Heading, Text} from '@astryxdesign/core/Text';
|
|
87
|
+
import {TextInput} from '@astryxdesign/core/TextInput';
|
|
88
|
+
import {Theme} from '@astryxdesign/core/theme';
|
|
89
|
+
import {neutralTheme} from '@astryxdesign/theme-neutral/built';
|
|
90
|
+
import {Token} from '@astryxdesign/core/Token';
|
|
91
|
+
import {Tooltip} from '@astryxdesign/core/Tooltip';
|
|
92
|
+
import {VStack} from '@astryxdesign/core/VStack';
|
|
28
93
|
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
94
|
+
ArrowDownOnSquareIcon,
|
|
95
|
+
ArrowDownTrayIcon,
|
|
96
|
+
ArrowUturnLeftIcon,
|
|
97
|
+
AtSymbolIcon,
|
|
98
|
+
Bars3BottomLeftIcon,
|
|
99
|
+
Bars3Icon,
|
|
100
|
+
BeakerIcon,
|
|
35
101
|
BellIcon,
|
|
36
|
-
DocumentTextIcon,
|
|
37
|
-
CreditCardIcon,
|
|
38
102
|
BriefcaseIcon,
|
|
103
|
+
CalendarDaysIcon,
|
|
104
|
+
ChatBubbleLeftRightIcon,
|
|
105
|
+
ChevronRightIcon,
|
|
106
|
+
ClockIcon,
|
|
107
|
+
CodeBracketIcon,
|
|
108
|
+
Cog6ToothIcon,
|
|
109
|
+
CommandLineIcon,
|
|
110
|
+
ComputerDesktopIcon,
|
|
111
|
+
CreditCardIcon,
|
|
112
|
+
CubeIcon,
|
|
113
|
+
DevicePhoneMobileIcon,
|
|
114
|
+
DocumentMagnifyingGlassIcon,
|
|
115
|
+
DocumentTextIcon,
|
|
116
|
+
EnvelopeIcon,
|
|
117
|
+
EyeIcon,
|
|
118
|
+
FingerPrintIcon,
|
|
119
|
+
FolderIcon,
|
|
120
|
+
GlobeAltIcon,
|
|
121
|
+
HandRaisedIcon,
|
|
122
|
+
KeyIcon,
|
|
123
|
+
LanguageIcon,
|
|
124
|
+
LinkIcon,
|
|
125
|
+
LockClosedIcon,
|
|
126
|
+
MagnifyingGlassIcon,
|
|
127
|
+
MapPinIcon,
|
|
128
|
+
MoonIcon,
|
|
129
|
+
PaintBrushIcon,
|
|
39
130
|
PencilSquareIcon,
|
|
40
|
-
|
|
131
|
+
PuzzlePieceIcon,
|
|
132
|
+
QuestionMarkCircleIcon,
|
|
133
|
+
ReceiptPercentIcon,
|
|
134
|
+
ShieldCheckIcon,
|
|
135
|
+
SignalIcon,
|
|
136
|
+
SparklesIcon,
|
|
137
|
+
SpeakerWaveIcon,
|
|
138
|
+
SunIcon,
|
|
139
|
+
SwatchIcon,
|
|
140
|
+
TagIcon,
|
|
141
|
+
TrashIcon,
|
|
142
|
+
UserCircleIcon,
|
|
143
|
+
UserIcon,
|
|
144
|
+
UsersIcon,
|
|
41
145
|
} from '@heroicons/react/24/outline';
|
|
146
|
+
import * as stylex from '@stylexjs/stylex';
|
|
147
|
+
import {
|
|
148
|
+
Children,
|
|
149
|
+
type ComponentType,
|
|
150
|
+
type CSSProperties,
|
|
151
|
+
useCallback,
|
|
152
|
+
useEffect,
|
|
153
|
+
useId,
|
|
154
|
+
useMemo,
|
|
155
|
+
useState,
|
|
156
|
+
type ComponentProps,
|
|
157
|
+
type ReactNode,
|
|
158
|
+
type SVGProps,
|
|
159
|
+
} from 'react';
|
|
42
160
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
zIndex: 1,
|
|
55
|
-
};
|
|
56
|
-
// No `maxWidth` prop on VStack — width only. Inline padding comes from
|
|
57
|
-
// the parent LayoutContent `padding`.
|
|
58
|
-
const contentMaxWidth: CSSProperties = {
|
|
59
|
-
maxWidth: 680,
|
|
60
|
-
};
|
|
61
|
-
// Aligns the sidebar heading with list-item label text. No heading margin prop.
|
|
62
|
-
const sideNavHeading: CSSProperties = {
|
|
63
|
-
marginInline: 'var(--spacing-4)',
|
|
64
|
-
};
|
|
65
|
-
const dialogHeight: CSSProperties = {
|
|
66
|
-
height: '85vh',
|
|
67
|
-
};
|
|
161
|
+
/**
|
|
162
|
+
* Where the hub swaps its two-column shell for the one-column phone layout.
|
|
163
|
+
*
|
|
164
|
+
* The SHELL's breakpoint only. Rows do not read it — they stack on the panel's
|
|
165
|
+
* own width instead (`ROW_STACK`), because the two questions have different
|
|
166
|
+
* answers: the pane is the dialog's width less a 260px rail, and the rail goes
|
|
167
|
+
* away below this line, so the panel is ~380px at a 700px viewport and ~390px
|
|
168
|
+
* at 420px. Keyed to the viewport, rows would stack hardest exactly where they
|
|
169
|
+
* had just been handed the most room.
|
|
170
|
+
*/
|
|
171
|
+
const NARROW_VIEWPORT = '(max-width: 640px)';
|
|
68
172
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
{label: 'Login & security', icon: LockClosedIcon},
|
|
72
|
-
{label: 'Privacy', icon: ShieldCheckIcon},
|
|
73
|
-
{label: 'Notifications', icon: BellIcon},
|
|
74
|
-
{label: 'Taxes', icon: DocumentTextIcon},
|
|
75
|
-
{label: 'Payments', icon: CreditCardIcon},
|
|
76
|
-
{label: 'Languages & currency', icon: GlobeAltIcon},
|
|
77
|
-
{label: 'Travel for work', icon: BriefcaseIcon},
|
|
78
|
-
];
|
|
173
|
+
/** The panel column rows measure themselves against. */
|
|
174
|
+
const PANEL_CONTAINER = 'settings-panel';
|
|
79
175
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
176
|
+
/**
|
|
177
|
+
* The ONE width at which every row on a panel goes from two columns to one.
|
|
178
|
+
*
|
|
179
|
+
* Deliberately one number for the whole panel rather than per-row wrapping.
|
|
180
|
+
* Wrapping each row on its own contents is more precise and reads as chaos:
|
|
181
|
+
* every row has a different control, so they cross over at half a dozen
|
|
182
|
+
* different widths and the panel appears to come apart a line at a time as it
|
|
183
|
+
* narrows. Rows that break together read as one layout changing; rows that
|
|
184
|
+
* break separately read as a bug.
|
|
185
|
+
*
|
|
186
|
+
* 480px is where the tightest row stops fitting — a ~260px name column, a 12px
|
|
187
|
+
* gutter, and the widest control on the surface. Rows with smaller controls
|
|
188
|
+
* have room to spare at that width and stack anyway, which is the point.
|
|
189
|
+
*/
|
|
190
|
+
const ROW_STACK = `@container ${PANEL_CONTAINER} (max-width: 480px)`;
|
|
83
191
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
192
|
+
/** The one inset for this surface: spacing step 4 === 16px. */
|
|
193
|
+
const ROW_PADDING = 4;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The close control is real and keyboard-reachable; it has nothing to close
|
|
197
|
+
* only because this page renders its dialog inline (see the default export).
|
|
198
|
+
* Point it at the state that owns `isOpen` when you make this modal.
|
|
199
|
+
*/
|
|
200
|
+
const noop = () => {};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The one width every selector and text input in a row takes, so a single
|
|
204
|
+
* control column runs down the panel instead of each control sizing to its own
|
|
205
|
+
* longest label — which is what makes the column scannable rather than ragged.
|
|
206
|
+
*/
|
|
207
|
+
const CONTROL_WIDTH = 192;
|
|
208
|
+
|
|
209
|
+
const styles = stylex.create({
|
|
210
|
+
// `SideNav`'s root sets a starting width but no `flex-shrink`, so as a flex
|
|
211
|
+
// child it gives that width back to whatever the content pane demands — the
|
|
212
|
+
// rail visibly narrows as you click through panels until labels ellipsize.
|
|
213
|
+
sideNav: {
|
|
214
|
+
flexShrink: 0,
|
|
215
|
+
},
|
|
216
|
+
// Neither `SideNav` nor `SideNavSection` sets a gap, so the only separation
|
|
217
|
+
// between two groups is each section's own block padding. The default reads
|
|
218
|
+
// as one long list rather than five groups.
|
|
219
|
+
sideNavSection: {
|
|
220
|
+
paddingBlock: 'var(--spacing-2)',
|
|
221
|
+
},
|
|
222
|
+
// The pinned close control. It sits first in the scrolling column and takes
|
|
223
|
+
// no height, so it overlays the heading's line instead of claiming a row —
|
|
224
|
+
// and, being a child of the whole column rather than of the heading, it
|
|
225
|
+
// stays pinned for the entire scroll (a sticky box is clamped by its
|
|
226
|
+
// containing block).
|
|
227
|
+
closeAnchor: {
|
|
228
|
+
position: 'sticky',
|
|
229
|
+
// 0 pins the control against the pane's CONTENT box — which is where it
|
|
230
|
+
// rests unscrolled, so it holds that corner exactly rather than jumping
|
|
231
|
+
// into the gutter the moment the body moves. Verify this after any change
|
|
232
|
+
// to the pane's padding: the pin line follows the content box, not the
|
|
233
|
+
// padding box, so the two only agree when this is 0.
|
|
234
|
+
insetBlockStart: 0,
|
|
235
|
+
zIndex: 1,
|
|
236
|
+
height: 0,
|
|
237
|
+
},
|
|
238
|
+
// Opaque, or the body scrolls visibly through the button. Only the control's
|
|
239
|
+
// own box is covered — a full-width band would wipe a strip of the body for
|
|
240
|
+
// no one's benefit.
|
|
241
|
+
closeBacking: {
|
|
242
|
+
display: 'flex',
|
|
243
|
+
// The dialog's own canvas, so the button covers the body scrolling under it.
|
|
244
|
+
backgroundColor: 'var(--color-background-surface)',
|
|
245
|
+
borderRadius: 'var(--radius-element)',
|
|
246
|
+
},
|
|
247
|
+
// ONE height for every panel. A dialog sizes to its content, so without this
|
|
248
|
+
// the frame grows and shrinks as you click through the navigation — the rail
|
|
249
|
+
// and the close button move under the cursor between one panel and the next.
|
|
250
|
+
// `Dialog maxHeight` cannot do it: a maximum only stops a tall panel, it does
|
|
251
|
+
// not hold a short one open. So the shell states the height and the panels
|
|
252
|
+
// scroll inside it.
|
|
253
|
+
shell: {
|
|
254
|
+
height: 'min(800px, calc(100dvh - 2rem))',
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
/** The panel column, and the settings row that measures itself against it. */
|
|
259
|
+
const row = stylex.create({
|
|
260
|
+
// The column every row asks its width question of. It is this element and
|
|
261
|
+
// not the scroll pane, because a row is as wide as the column, and the
|
|
262
|
+
// column is what a future `contentWidth` cap would narrow.
|
|
263
|
+
panel: {
|
|
264
|
+
containerType: 'inline-size',
|
|
265
|
+
containerName: PANEL_CONTAINER,
|
|
266
|
+
},
|
|
267
|
+
line: {
|
|
268
|
+
display: 'flex',
|
|
269
|
+
flexDirection: {default: 'row', [ROW_STACK]: 'column'},
|
|
270
|
+
// The one alignment that moves: centred against the control beside it,
|
|
271
|
+
// stretched to the full row above it — which is what puts a stacked control
|
|
272
|
+
// on the row's own inset, the edge the icon starts at.
|
|
273
|
+
alignItems: {default: 'center', [ROW_STACK]: 'stretch'},
|
|
274
|
+
// Stacked, the control drops into the same column as `detail`, which the
|
|
275
|
+
// row already spaces at 8px — so 8px here too, and a stacked row reads as
|
|
276
|
+
// one rhythm rather than a 12px step to the control and an 8px step to its
|
|
277
|
+
// detail.
|
|
278
|
+
gap: {
|
|
279
|
+
default: 'var(--spacing-3)',
|
|
280
|
+
[ROW_STACK]: 'var(--spacing-2)',
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
textColumn: {
|
|
284
|
+
// Lifted once stacked: holding 420px inside a ~400px column would just be a
|
|
285
|
+
// narrower column for no reason.
|
|
286
|
+
maxWidth: {default: 420, [ROW_STACK]: 'none'},
|
|
287
|
+
},
|
|
288
|
+
control: {
|
|
289
|
+
flexShrink: 0,
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
/** The miniature drawn inside each theme card. */
|
|
294
|
+
const preview = stylex.create({
|
|
295
|
+
// One bar of "text". Astryx has no primitive for a content placeholder at
|
|
296
|
+
// this scale — `Skeleton` is the near miss, and it animates.
|
|
297
|
+
bar: {
|
|
298
|
+
height: 4,
|
|
299
|
+
width: '50%',
|
|
300
|
+
borderRadius: 999,
|
|
301
|
+
backgroundColor: 'var(--color-border-emphasized)',
|
|
302
|
+
},
|
|
303
|
+
barWide: {
|
|
304
|
+
width: '66%',
|
|
305
|
+
},
|
|
306
|
+
clip: {
|
|
307
|
+
overflow: 'hidden',
|
|
308
|
+
borderRadius: 'var(--radius-element)',
|
|
309
|
+
},
|
|
310
|
+
// The two borderless regions of the miniature. `Theme` re-declares these
|
|
311
|
+
// tokens for the mode it is previewing, so Light stays light in a dark app.
|
|
312
|
+
mutedSurface: {
|
|
313
|
+
backgroundColor: 'var(--color-background-muted)',
|
|
314
|
+
},
|
|
315
|
+
contentSurface: {
|
|
316
|
+
backgroundColor: 'var(--color-background-surface)',
|
|
317
|
+
},
|
|
318
|
+
inert: {
|
|
319
|
+
pointerEvents: 'none',
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The chosen face, as a TOKEN override rather than a `font-family` on the
|
|
325
|
+
* words. Astryx's type rules resolve `var(--font-family-body)`, so
|
|
326
|
+
* re-declaring the token re-points every `Text` inside the preview whatever
|
|
327
|
+
* specificity those rules carry; setting `font-family` reaches only what
|
|
328
|
+
* inherits it, and a preview built that way silently keeps showing the system
|
|
329
|
+
* font. It rides on `style` because StyleX's `xstyle` types accept only known
|
|
330
|
+
* CSS properties, and a custom property is not one.
|
|
331
|
+
*/
|
|
332
|
+
function faceStyle(stack: string): CSSProperties {
|
|
333
|
+
const style: Record<string, string> = {'--font-family-body': stack};
|
|
334
|
+
return style;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
type PanelId =
|
|
338
|
+
| 'profile'
|
|
339
|
+
| 'security'
|
|
340
|
+
| 'billing'
|
|
341
|
+
| 'appearance'
|
|
342
|
+
| 'notifications'
|
|
343
|
+
| 'shortcuts'
|
|
344
|
+
| 'language'
|
|
345
|
+
| 'privacy'
|
|
346
|
+
| 'integrations'
|
|
347
|
+
| 'developer';
|
|
87
348
|
|
|
88
|
-
|
|
349
|
+
/**
|
|
350
|
+
* Icons are heroicons outline, and only heroicons outline.
|
|
351
|
+
*
|
|
352
|
+
* Not a style preference: Astryx's own semantic icons — what
|
|
353
|
+
* `<Icon icon="close" />` renders — are drawn at strokeWidth 1.5 to match
|
|
354
|
+
* heroicons, and this dialog uses one for its close control. lucide draws at
|
|
355
|
+
* 2, so a lucide row icon puts two stroke weights in one surface. One set
|
|
356
|
+
* means one weight, one optical size and one visual language down the column.
|
|
357
|
+
*/
|
|
358
|
+
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>;
|
|
359
|
+
|
|
360
|
+
interface PanelConfig {
|
|
361
|
+
id: PanelId;
|
|
89
362
|
label: string;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
363
|
+
description: string;
|
|
364
|
+
icon: IconComponent;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The one list both shells read. Groups are the navigation's sections and the
|
|
369
|
+
* tab strip's order, so a panel cannot drift out of one by being added to the
|
|
370
|
+
* other.
|
|
371
|
+
*/
|
|
372
|
+
const PANEL_GROUPS: {label: string; panels: PanelConfig[]}[] = [
|
|
373
|
+
{
|
|
374
|
+
label: 'Account',
|
|
375
|
+
panels: [
|
|
376
|
+
{
|
|
377
|
+
id: 'profile',
|
|
378
|
+
label: 'Profile',
|
|
379
|
+
description: 'How you appear to everyone else in the workspace.',
|
|
380
|
+
icon: UserCircleIcon,
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
id: 'security',
|
|
384
|
+
label: 'Login & security',
|
|
385
|
+
description: 'Sign-in methods and the devices signed in right now.',
|
|
386
|
+
icon: LockClosedIcon,
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
id: 'billing',
|
|
390
|
+
label: 'Billing',
|
|
391
|
+
description: 'Your plan, seats, and where invoices are sent.',
|
|
392
|
+
icon: CreditCardIcon,
|
|
393
|
+
},
|
|
394
|
+
],
|
|
395
|
+
},
|
|
94
396
|
{
|
|
95
|
-
label: '
|
|
96
|
-
|
|
97
|
-
|
|
397
|
+
label: 'Workspace',
|
|
398
|
+
panels: [
|
|
399
|
+
{
|
|
400
|
+
id: 'appearance',
|
|
401
|
+
label: 'Appearance',
|
|
402
|
+
description: 'Theme and density for this browser.',
|
|
403
|
+
icon: PaintBrushIcon,
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
id: 'notifications',
|
|
407
|
+
label: 'Notifications',
|
|
408
|
+
description: 'What reaches you, and where it arrives.',
|
|
409
|
+
icon: BellIcon,
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
id: 'shortcuts',
|
|
413
|
+
label: 'Keyboard shortcuts',
|
|
414
|
+
description: 'Rebind or switch off any shortcut on this surface.',
|
|
415
|
+
icon: CommandLineIcon,
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
id: 'language',
|
|
419
|
+
label: 'Language & region',
|
|
420
|
+
description: 'Language, formats, and time zone.',
|
|
421
|
+
icon: GlobeAltIcon,
|
|
422
|
+
},
|
|
423
|
+
],
|
|
98
424
|
},
|
|
99
|
-
{label: 'Session', location: 'August 9, 2023 at 04:19', action: 'Log out'},
|
|
100
425
|
{
|
|
101
|
-
label: '
|
|
102
|
-
|
|
103
|
-
|
|
426
|
+
label: 'Advanced',
|
|
427
|
+
panels: [
|
|
428
|
+
{
|
|
429
|
+
id: 'privacy',
|
|
430
|
+
label: 'Privacy',
|
|
431
|
+
description: 'What you share, and what we keep.',
|
|
432
|
+
icon: ShieldCheckIcon,
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
id: 'integrations',
|
|
436
|
+
label: 'Connected apps',
|
|
437
|
+
description: 'Services with access to this workspace.',
|
|
438
|
+
icon: PuzzlePieceIcon,
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
id: 'developer',
|
|
442
|
+
label: 'Developer',
|
|
443
|
+
description: 'API access and pre-release features.',
|
|
444
|
+
icon: CodeBracketIcon,
|
|
445
|
+
},
|
|
446
|
+
],
|
|
104
447
|
},
|
|
105
448
|
];
|
|
106
449
|
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
450
|
+
const PANELS: PanelConfig[] = PANEL_GROUPS.flatMap(group => group.panels);
|
|
451
|
+
const DEFAULT_PANEL: PanelId = 'profile';
|
|
452
|
+
|
|
453
|
+
function panelConfig(id: PanelId): PanelConfig {
|
|
454
|
+
return PANELS.find(panel => panel.id === id) ?? PANELS[0];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
interface SearchableSetting {
|
|
458
|
+
title: string;
|
|
459
|
+
description: string;
|
|
460
|
+
panel: PanelId;
|
|
461
|
+
icon: IconComponent;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* The search catalog. It is a list rather than something derived from the
|
|
466
|
+
* rendered rows on purpose: a setting stays findable by the words people
|
|
467
|
+
* actually reach for, which are rarely the words on its label.
|
|
468
|
+
*/
|
|
469
|
+
const SEARCHABLE_SETTINGS: SearchableSetting[] = [
|
|
470
|
+
{
|
|
471
|
+
title: 'Display name',
|
|
472
|
+
description: 'The name shown on your posts and comments',
|
|
473
|
+
panel: 'profile',
|
|
474
|
+
icon: UserCircleIcon,
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
title: 'Two-factor authentication',
|
|
478
|
+
description: 'Require a second step when signing in',
|
|
479
|
+
panel: 'security',
|
|
480
|
+
icon: KeyIcon,
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
title: 'Active sessions',
|
|
484
|
+
description: 'Devices currently signed in to this account',
|
|
485
|
+
panel: 'security',
|
|
486
|
+
icon: ComputerDesktopIcon,
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
title: 'Plan',
|
|
490
|
+
description: 'Your current subscription and seat count',
|
|
491
|
+
panel: 'billing',
|
|
492
|
+
icon: CreditCardIcon,
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
title: 'Color theme',
|
|
496
|
+
description: 'Light, dark, or follow the system',
|
|
497
|
+
panel: 'appearance',
|
|
498
|
+
icon: PaintBrushIcon,
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
title: 'Interface density',
|
|
502
|
+
description: 'How much breathing room rows and lists get',
|
|
503
|
+
panel: 'appearance',
|
|
504
|
+
icon: PaintBrushIcon,
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
title: 'Email notifications',
|
|
508
|
+
description: 'Mentions, comments, and the weekly digest',
|
|
509
|
+
panel: 'notifications',
|
|
510
|
+
icon: EnvelopeIcon,
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
title: 'Quiet hours',
|
|
514
|
+
description: 'Hold desktop notifications until the morning',
|
|
515
|
+
panel: 'notifications',
|
|
516
|
+
icon: ClockIcon,
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
title: 'Keyboard shortcuts',
|
|
520
|
+
description: 'Rebind a shortcut, or switch one off',
|
|
521
|
+
panel: 'shortcuts',
|
|
522
|
+
icon: CommandLineIcon,
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
title: 'Time zone',
|
|
526
|
+
description: 'Used for scheduling and timestamps',
|
|
527
|
+
panel: 'language',
|
|
528
|
+
icon: GlobeAltIcon,
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
title: 'Read receipts',
|
|
532
|
+
description: 'Let people see when you have read a message',
|
|
533
|
+
panel: 'privacy',
|
|
534
|
+
icon: ShieldCheckIcon,
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
title: 'Export my data',
|
|
538
|
+
description: 'Download everything this account holds',
|
|
539
|
+
panel: 'privacy',
|
|
540
|
+
icon: ArrowDownTrayIcon,
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
title: 'Connected apps',
|
|
544
|
+
description: 'Calendar, storage, and chat integrations',
|
|
545
|
+
panel: 'integrations',
|
|
546
|
+
icon: PuzzlePieceIcon,
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
title: 'API access',
|
|
550
|
+
description: 'Personal access tokens and webhooks',
|
|
551
|
+
panel: 'developer',
|
|
552
|
+
icon: CodeBracketIcon,
|
|
553
|
+
},
|
|
114
554
|
];
|
|
115
555
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
556
|
+
function settingMatchesSearch(
|
|
557
|
+
setting: SearchableSetting,
|
|
558
|
+
normalizedQuery: string,
|
|
559
|
+
): boolean {
|
|
560
|
+
return (
|
|
561
|
+
setting.title.toLowerCase().includes(normalizedQuery) ||
|
|
562
|
+
setting.description.toLowerCase().includes(normalizedQuery) ||
|
|
563
|
+
panelConfig(setting.panel).label.toLowerCase().includes(normalizedQuery)
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/* ------------------------------------------------------------------ *
|
|
568
|
+
* The settings row idiom
|
|
569
|
+
* ------------------------------------------------------------------ */
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* A filled, divided group of settings rows.
|
|
573
|
+
*
|
|
574
|
+
* `variant="muted"` does two jobs: it is the well the group reads as against
|
|
575
|
+
* the dialog canvas, and — because Astryx draws a border on the `default`
|
|
576
|
+
* variant only — it is also what removes the hairline, so the group is
|
|
577
|
+
* separated once rather than twice.
|
|
578
|
+
*
|
|
579
|
+
* `padding={0}` because the rows carry the inset themselves. A padded Card
|
|
580
|
+
* around padded rows double-pads every edge, and the rows' own padding is what
|
|
581
|
+
* keeps the dividers full-bleed.
|
|
582
|
+
*
|
|
583
|
+
* A card has a subject heading and no description. If the group needs a second
|
|
584
|
+
* sentence, put it on the row it qualifies or split the card — a reader should
|
|
585
|
+
* know why these settings belong together from the heading and row names.
|
|
586
|
+
*/
|
|
587
|
+
function SettingsCard({
|
|
588
|
+
title,
|
|
589
|
+
children,
|
|
590
|
+
}: {
|
|
591
|
+
title?: string;
|
|
592
|
+
children: ReactNode;
|
|
593
|
+
}) {
|
|
594
|
+
const rows = Children.toArray(children);
|
|
595
|
+
|
|
596
|
+
return (
|
|
597
|
+
<VStack gap={1.5}>
|
|
598
|
+
{title != null && (
|
|
599
|
+
// Flush with the card's edge, NOT inset to meet the row titles inside
|
|
600
|
+
// it: the heading labels the card as an object, and lining it up with
|
|
601
|
+
// the rows would read as the first row of content instead.
|
|
602
|
+
//
|
|
603
|
+
// Typed to match `SideNavSection`'s own title — the navigation and
|
|
604
|
+
// these cards are the same idea at two scales, so a reader meets one
|
|
605
|
+
// grouping label, not two.
|
|
606
|
+
<Text type="supporting" weight="semibold" color="secondary">
|
|
607
|
+
{title}
|
|
608
|
+
</Text>
|
|
609
|
+
)}
|
|
610
|
+
<Card padding={0} width="100%" variant="muted">
|
|
611
|
+
<VStack as="ul" role="list" gap={0}>
|
|
612
|
+
{rows.map((row, index) => (
|
|
613
|
+
<VStack key={index} as="li" gap={0}>
|
|
614
|
+
{index > 0 && <Divider variant="subtle" />}
|
|
615
|
+
{row}
|
|
616
|
+
</VStack>
|
|
617
|
+
))}
|
|
618
|
+
</VStack>
|
|
619
|
+
</Card>
|
|
620
|
+
</VStack>
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* One setting: name and explanation on the left, the control on the right —
|
|
626
|
+
* or, below the panel's stacking width, the control underneath and full width.
|
|
627
|
+
*
|
|
628
|
+
* Every row on a panel makes that switch at the same moment, on the panel's
|
|
629
|
+
* own width rather than the viewport's — see `ROW_STACK`. There is deliberately
|
|
630
|
+
* no per-row opt-out: a row that keeps two columns while its neighbours have
|
|
631
|
+
* given up reads as a bug rather than as a decision.
|
|
632
|
+
*
|
|
633
|
+
* Pass the control `isLabelHidden`. This row is its visible label, and a
|
|
634
|
+
* control that keeps its own prints every row's name twice.
|
|
635
|
+
*/
|
|
636
|
+
function SettingsRow({
|
|
637
|
+
title,
|
|
638
|
+
titleAccessory,
|
|
639
|
+
description,
|
|
640
|
+
icon,
|
|
641
|
+
control,
|
|
642
|
+
detail,
|
|
643
|
+
}: {
|
|
644
|
+
title: string;
|
|
645
|
+
/**
|
|
646
|
+
* A chip that is part of the setting's NAME rather than a fact about it — a
|
|
647
|
+
* plan tier, a "beta" tag. It sits on the title's own line: the supporting
|
|
648
|
+
* line below is prose the eye reads second, so a chip parked there stops
|
|
649
|
+
* reading as part of the name.
|
|
650
|
+
*/
|
|
651
|
+
titleAccessory?: ReactNode;
|
|
652
|
+
description?: ReactNode;
|
|
653
|
+
/**
|
|
654
|
+
* REQUIRED, and required on purpose. An icon shifts its row's title 24px
|
|
655
|
+
* right, so a card where only some rows carry one has its titles on two
|
|
656
|
+
* different vertical lines — the one-content-line rule broken inside a
|
|
657
|
+
* single group, and the most visible way a settings surface stops looking
|
|
658
|
+
* designed. Optional, it drifts: a row added later quietly leaves its icon
|
|
659
|
+
* out and the column goes ragged with no error. The type is the enforcement.
|
|
660
|
+
*/
|
|
661
|
+
icon: IconComponent;
|
|
662
|
+
control?: ReactNode;
|
|
663
|
+
/**
|
|
664
|
+
* Content belonging to this setting that cannot fit beside it — a live
|
|
665
|
+
* preview, a picker of tiles. It rides INSIDE the row's own box rather than
|
|
666
|
+
* as a following sibling, because Astryx stacks take only two padding axes:
|
|
667
|
+
* a sibling would pay a second full inset and land twice as far from the
|
|
668
|
+
* control it belongs to.
|
|
669
|
+
*
|
|
670
|
+
* The row is the one inset owner, so pass the block bare and its own edge
|
|
671
|
+
* lands on the panel's content line — the line the row titles sit on. A
|
|
672
|
+
* block that pays a second inset of its own puts its contents half a step
|
|
673
|
+
* past every title above it, which is the double-padding the layout docs
|
|
674
|
+
* name as an anti-pattern.
|
|
675
|
+
*/
|
|
676
|
+
detail?: ReactNode;
|
|
677
|
+
}) {
|
|
678
|
+
return (
|
|
679
|
+
<VStack padding={ROW_PADDING} gap={2}>
|
|
680
|
+
{/* `Stack` with the direction in `xstyle` rather than in `direction`:
|
|
681
|
+
the switch happens inside a container query, which a prop cannot
|
|
682
|
+
express. There is no cascade fight to lose here — `Stack` spreads
|
|
683
|
+
`xstyle` LAST into its own `stylex.props`, so StyleX resolves the two
|
|
684
|
+
by property rather than by stylesheet order.
|
|
685
|
+
|
|
686
|
+
The `data-` marks are for tests: "every row on the panel stacks at
|
|
687
|
+
the same width" is the invariant this layout is FOR, and it is only
|
|
688
|
+
checkable by reading the computed direction off every line at once. */}
|
|
689
|
+
<Stack xstyle={row.line} data-settings-line>
|
|
690
|
+
{/* `fill` means "take the width the control leaves", and carries the
|
|
691
|
+
flex `min-width: 0` reset that lets the text wrap instead of
|
|
692
|
+
widening the row. */}
|
|
693
|
+
<StackItem size="fill">
|
|
694
|
+
<HStack gap={2} align="start">
|
|
695
|
+
{/* 2px down, which is spacing step 0.5. The stack aligns the
|
|
696
|
+
icon's BOX to the title's box, but a glyph is optically centred
|
|
697
|
+
in its box while a cap-height sits low in its line box, so box
|
|
698
|
+
alignment leaves the icon reading high. */}
|
|
699
|
+
<VStack paddingBlockStart={0.5}>
|
|
700
|
+
<Icon icon={icon} size="sm" color="secondary" />
|
|
701
|
+
</VStack>
|
|
702
|
+
{/* Capped so a description wraps on its own terms instead of
|
|
703
|
+
against the control. `fill` hands the text every pixel the
|
|
704
|
+
control does not take, which on a switch row puts the last word
|
|
705
|
+
a hair from the thumb; the cap turns that surplus into the
|
|
706
|
+
gutter the layout reads by. Lifted once stacked — there is no
|
|
707
|
+
control beside the text, so no gutter to protect. */}
|
|
708
|
+
<VStack gap={0.5} xstyle={row.textColumn}>
|
|
709
|
+
{titleAccessory == null ? (
|
|
710
|
+
<Text type="label">{title}</Text>
|
|
711
|
+
) : (
|
|
712
|
+
<HStack gap={1.5} align="center">
|
|
713
|
+
<Text type="label">{title}</Text>
|
|
714
|
+
{titleAccessory}
|
|
715
|
+
</HStack>
|
|
716
|
+
)}
|
|
717
|
+
{description != null && (
|
|
718
|
+
<Text type="supporting" color="secondary">
|
|
719
|
+
{description}
|
|
720
|
+
</Text>
|
|
721
|
+
)}
|
|
722
|
+
</VStack>
|
|
723
|
+
</HStack>
|
|
724
|
+
</StackItem>
|
|
725
|
+
{control != null && (
|
|
726
|
+
// The control does not shrink: a flex item shrinks by default, and a
|
|
727
|
+
// shrinking `SegmentedControl` puts its longest label on two lines
|
|
728
|
+
// rather than admitting it has run out of room. Stacked, the wrapper
|
|
729
|
+
// stretches to the row and the control keeps the `CONTROL_WIDTH` it
|
|
730
|
+
// has above the breakpoint — so the control column reads the same
|
|
731
|
+
// either way, and a control with no width of its own (a switch, a
|
|
732
|
+
// button) stays hugged to its own size.
|
|
733
|
+
<VStack data-settings-control xstyle={row.control}>
|
|
734
|
+
{control}
|
|
735
|
+
</VStack>
|
|
736
|
+
)}
|
|
737
|
+
</Stack>
|
|
738
|
+
{detail}
|
|
739
|
+
</VStack>
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/* ------------------------------------------------------------------ *
|
|
744
|
+
* Panel bodies
|
|
745
|
+
* ------------------------------------------------------------------ */
|
|
746
|
+
|
|
747
|
+
interface SettingsState {
|
|
748
|
+
displayName: string;
|
|
749
|
+
jobTitle: string;
|
|
750
|
+
pronouns: string;
|
|
751
|
+
showLocalTime: boolean;
|
|
752
|
+
presence: string;
|
|
753
|
+
twoFactor: boolean;
|
|
754
|
+
billingEmail: string;
|
|
755
|
+
emailInvoices: boolean;
|
|
756
|
+
theme: string;
|
|
757
|
+
accent: string;
|
|
758
|
+
font: string;
|
|
759
|
+
density: string;
|
|
760
|
+
reduceMotion: boolean;
|
|
761
|
+
emailMentions: boolean;
|
|
762
|
+
emailComments: boolean;
|
|
763
|
+
weeklyDigest: boolean;
|
|
764
|
+
desktopAlerts: boolean;
|
|
765
|
+
notificationSounds: boolean;
|
|
766
|
+
quietHours: string;
|
|
767
|
+
language: string;
|
|
768
|
+
region: string;
|
|
769
|
+
timezone: string;
|
|
770
|
+
weekStart: string;
|
|
771
|
+
discoverable: boolean;
|
|
772
|
+
readReceipts: boolean;
|
|
773
|
+
activityStatus: boolean;
|
|
774
|
+
personalization: boolean;
|
|
775
|
+
apiAccess: boolean;
|
|
776
|
+
webhookUrl: string;
|
|
777
|
+
earlyAccess: boolean;
|
|
778
|
+
verboseLogging: boolean;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const INITIAL_SETTINGS: SettingsState = {
|
|
782
|
+
displayName: 'Alex Johnson',
|
|
783
|
+
jobTitle: 'Product designer',
|
|
784
|
+
pronouns: 'they',
|
|
785
|
+
showLocalTime: true,
|
|
786
|
+
presence: 'available',
|
|
787
|
+
twoFactor: true,
|
|
788
|
+
billingEmail: 'billing@example.com',
|
|
789
|
+
emailInvoices: true,
|
|
790
|
+
theme: 'system',
|
|
791
|
+
accent: 'blue',
|
|
792
|
+
font: 'system',
|
|
793
|
+
density: 'comfortable',
|
|
794
|
+
reduceMotion: false,
|
|
795
|
+
emailMentions: true,
|
|
796
|
+
emailComments: false,
|
|
797
|
+
weeklyDigest: true,
|
|
798
|
+
desktopAlerts: true,
|
|
799
|
+
notificationSounds: false,
|
|
800
|
+
quietHours: 'none',
|
|
801
|
+
language: 'en-US',
|
|
802
|
+
region: 'US',
|
|
803
|
+
timezone: 'PT',
|
|
804
|
+
weekStart: 'sun',
|
|
805
|
+
discoverable: true,
|
|
806
|
+
readReceipts: true,
|
|
807
|
+
activityStatus: false,
|
|
808
|
+
personalization: true,
|
|
809
|
+
apiAccess: false,
|
|
810
|
+
webhookUrl: '',
|
|
811
|
+
earlyAccess: false,
|
|
812
|
+
verboseLogging: false,
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
type UpdateSetting = <K extends keyof SettingsState>(
|
|
816
|
+
key: K,
|
|
817
|
+
value: SettingsState[K],
|
|
818
|
+
) => void;
|
|
819
|
+
|
|
820
|
+
interface PanelProps {
|
|
821
|
+
settings: SettingsState;
|
|
822
|
+
update: UpdateSetting;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function ProfilePanel({settings, update}: PanelProps) {
|
|
826
|
+
return (
|
|
827
|
+
<VStack gap={5}>
|
|
828
|
+
<SettingsCard title="Identity">
|
|
829
|
+
<SettingsRow
|
|
830
|
+
title="Display name"
|
|
831
|
+
icon={UserIcon}
|
|
832
|
+
description="Shown on your posts, comments, and mentions."
|
|
833
|
+
control={
|
|
834
|
+
<TextInput
|
|
835
|
+
label="Display name"
|
|
836
|
+
isLabelHidden
|
|
837
|
+
size="sm"
|
|
838
|
+
width={CONTROL_WIDTH}
|
|
839
|
+
value={settings.displayName}
|
|
840
|
+
onChange={value => update('displayName', value)}
|
|
841
|
+
/>
|
|
842
|
+
}
|
|
843
|
+
/>
|
|
844
|
+
<SettingsRow
|
|
845
|
+
title="Job title"
|
|
846
|
+
icon={BriefcaseIcon}
|
|
847
|
+
description="Appears under your name on your profile card."
|
|
848
|
+
control={
|
|
849
|
+
<TextInput
|
|
850
|
+
label="Job title"
|
|
851
|
+
isLabelHidden
|
|
852
|
+
size="sm"
|
|
853
|
+
width={CONTROL_WIDTH}
|
|
854
|
+
value={settings.jobTitle}
|
|
855
|
+
onChange={value => update('jobTitle', value)}
|
|
856
|
+
/>
|
|
857
|
+
}
|
|
858
|
+
/>
|
|
859
|
+
<SettingsRow
|
|
860
|
+
title="Pronouns"
|
|
861
|
+
icon={TagIcon}
|
|
862
|
+
control={
|
|
863
|
+
<Selector
|
|
864
|
+
label="Pronouns"
|
|
865
|
+
isLabelHidden
|
|
866
|
+
size="sm"
|
|
867
|
+
width={CONTROL_WIDTH}
|
|
868
|
+
value={settings.pronouns}
|
|
869
|
+
onChange={value => update('pronouns', value)}
|
|
870
|
+
options={[
|
|
871
|
+
{value: 'none', label: 'Not specified'},
|
|
872
|
+
{value: 'they', label: 'they/them'},
|
|
873
|
+
{value: 'she', label: 'she/her'},
|
|
874
|
+
{value: 'he', label: 'he/him'},
|
|
875
|
+
]}
|
|
876
|
+
/>
|
|
877
|
+
}
|
|
878
|
+
/>
|
|
879
|
+
</SettingsCard>
|
|
880
|
+
|
|
881
|
+
<SettingsCard title="Availability">
|
|
882
|
+
<SettingsRow
|
|
883
|
+
title="Show my local time"
|
|
884
|
+
icon={ClockIcon}
|
|
885
|
+
description="Helps people across time zones pick a sane hour."
|
|
886
|
+
control={
|
|
887
|
+
<Switch
|
|
888
|
+
label="Show my local time"
|
|
889
|
+
isLabelHidden
|
|
890
|
+
value={settings.showLocalTime}
|
|
891
|
+
onChange={value => update('showLocalTime', value)}
|
|
892
|
+
/>
|
|
893
|
+
}
|
|
894
|
+
/>
|
|
895
|
+
<SettingsRow
|
|
896
|
+
title="Presence"
|
|
897
|
+
icon={SignalIcon}
|
|
898
|
+
control={
|
|
899
|
+
<Selector
|
|
900
|
+
label="Presence"
|
|
901
|
+
isLabelHidden
|
|
902
|
+
size="sm"
|
|
903
|
+
width={CONTROL_WIDTH}
|
|
904
|
+
value={settings.presence}
|
|
905
|
+
onChange={value => update('presence', value)}
|
|
906
|
+
options={[
|
|
907
|
+
{value: 'available', label: 'Available'},
|
|
908
|
+
{value: 'busy', label: 'Busy'},
|
|
909
|
+
{value: 'away', label: 'Away'},
|
|
910
|
+
]}
|
|
911
|
+
/>
|
|
912
|
+
}
|
|
913
|
+
/>
|
|
914
|
+
</SettingsCard>
|
|
915
|
+
</VStack>
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function SecurityPanel({settings, update}: PanelProps) {
|
|
920
|
+
return (
|
|
921
|
+
<VStack gap={5}>
|
|
922
|
+
<SettingsCard title="Sign-in">
|
|
923
|
+
<SettingsRow
|
|
924
|
+
title="Password"
|
|
925
|
+
description="Last changed 4 months ago."
|
|
926
|
+
icon={KeyIcon}
|
|
927
|
+
control={<Button label="Change" variant="secondary" size="sm" />}
|
|
928
|
+
/>
|
|
929
|
+
<SettingsRow
|
|
930
|
+
title="Two-factor authentication"
|
|
931
|
+
titleAccessory={<Token label="Recommended" size="sm" color="green" />}
|
|
932
|
+
description="Ask for a code from your authenticator app at sign-in."
|
|
933
|
+
icon={ShieldCheckIcon}
|
|
934
|
+
control={
|
|
935
|
+
<Switch
|
|
936
|
+
label="Two-factor authentication"
|
|
937
|
+
isLabelHidden
|
|
938
|
+
value={settings.twoFactor}
|
|
939
|
+
onChange={value => update('twoFactor', value)}
|
|
940
|
+
/>
|
|
941
|
+
}
|
|
942
|
+
/>
|
|
943
|
+
<SettingsRow
|
|
944
|
+
title="Passkeys"
|
|
945
|
+
icon={FingerPrintIcon}
|
|
946
|
+
description="No passkeys yet."
|
|
947
|
+
control={<Button label="Add passkey" variant="secondary" size="sm" />}
|
|
948
|
+
/>
|
|
949
|
+
</SettingsCard>
|
|
950
|
+
|
|
951
|
+
<SettingsCard title="Active sessions">
|
|
952
|
+
<SettingsRow
|
|
953
|
+
title="MacBook Pro · Chrome"
|
|
954
|
+
titleAccessory={<Token label="This device" size="sm" />}
|
|
955
|
+
description="San Francisco, California · Active now"
|
|
956
|
+
icon={ComputerDesktopIcon}
|
|
957
|
+
/>
|
|
958
|
+
<SettingsRow
|
|
959
|
+
title="iPhone 15 · Safari"
|
|
960
|
+
description="San Francisco, California · 2 hours ago"
|
|
961
|
+
icon={DevicePhoneMobileIcon}
|
|
962
|
+
control={<Button label="Sign out" variant="ghost" size="sm" />}
|
|
963
|
+
/>
|
|
964
|
+
<SettingsRow
|
|
965
|
+
title="Windows · Edge"
|
|
966
|
+
description="Austin, Texas · March 30 at 19:31"
|
|
967
|
+
icon={ComputerDesktopIcon}
|
|
968
|
+
control={<Button label="Sign out" variant="ghost" size="sm" />}
|
|
969
|
+
/>
|
|
970
|
+
</SettingsCard>
|
|
971
|
+
</VStack>
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function BillingPanel({settings, update}: PanelProps) {
|
|
976
|
+
return (
|
|
977
|
+
<VStack gap={5}>
|
|
978
|
+
<SettingsCard title="Plan">
|
|
979
|
+
<SettingsRow
|
|
980
|
+
title="Current plan"
|
|
981
|
+
icon={CubeIcon}
|
|
982
|
+
titleAccessory={<Token label="Team" size="sm" color="blue" />}
|
|
983
|
+
description="$12 per seat, per month. Renews on April 1."
|
|
984
|
+
control={<Button label="Upgrade" variant="primary" size="sm" />}
|
|
985
|
+
/>
|
|
986
|
+
<SettingsRow
|
|
987
|
+
title="Seats"
|
|
988
|
+
icon={UsersIcon}
|
|
989
|
+
description="18 of 25 seats in use."
|
|
990
|
+
control={
|
|
991
|
+
<Button label="Manage seats" variant="secondary" size="sm" />
|
|
992
|
+
}
|
|
993
|
+
/>
|
|
994
|
+
</SettingsCard>
|
|
995
|
+
|
|
996
|
+
<SettingsCard title="Invoices">
|
|
997
|
+
<SettingsRow
|
|
998
|
+
title="Billing email"
|
|
999
|
+
icon={EnvelopeIcon}
|
|
1000
|
+
description="Where receipts and payment failures are sent."
|
|
1001
|
+
control={
|
|
1002
|
+
<TextInput
|
|
1003
|
+
label="Billing email"
|
|
1004
|
+
isLabelHidden
|
|
1005
|
+
type="email"
|
|
1006
|
+
size="sm"
|
|
1007
|
+
width={CONTROL_WIDTH}
|
|
1008
|
+
value={settings.billingEmail}
|
|
1009
|
+
onChange={value => update('billingEmail', value)}
|
|
1010
|
+
/>
|
|
1011
|
+
}
|
|
1012
|
+
/>
|
|
1013
|
+
<SettingsRow
|
|
1014
|
+
title="Email invoices"
|
|
1015
|
+
icon={ReceiptPercentIcon}
|
|
1016
|
+
description="Send a PDF copy every time we charge the card."
|
|
1017
|
+
control={
|
|
1018
|
+
<Switch
|
|
1019
|
+
label="Email invoices"
|
|
1020
|
+
isLabelHidden
|
|
1021
|
+
value={settings.emailInvoices}
|
|
1022
|
+
onChange={value => update('emailInvoices', value)}
|
|
1023
|
+
/>
|
|
1024
|
+
}
|
|
1025
|
+
/>
|
|
1026
|
+
<SettingsRow
|
|
1027
|
+
title="Payment method"
|
|
1028
|
+
description="Visa ending 4242 · Expires 09/28"
|
|
1029
|
+
icon={CreditCardIcon}
|
|
1030
|
+
control={<Button label="Update" variant="secondary" size="sm" />}
|
|
1031
|
+
/>
|
|
1032
|
+
</SettingsCard>
|
|
1033
|
+
</VStack>
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/* ------------------------------------------------------------------ *
|
|
1038
|
+
* The theme picker: a radio group of pictures
|
|
1039
|
+
* ------------------------------------------------------------------ */
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* A miniature of the app used as a theme swatch — a title bar and a card of
|
|
1043
|
+
* rows, i.e. this dialog, drawn small.
|
|
1044
|
+
*
|
|
1045
|
+
* ## How a Light swatch survives inside a dark app
|
|
1046
|
+
*
|
|
1047
|
+
* It cannot be drawn with `--color-background-card` and friends. Every Astryx
|
|
1048
|
+
* colour ships as a `light-dark()` pair, and `light-dark()` resolves where the
|
|
1049
|
+
* custom property is DECLARED, not where it is used: by the time a token
|
|
1050
|
+
* inherits down from the theme root it is already the flat dark value, and no
|
|
1051
|
+
* local `color-scheme` reopens it.
|
|
1052
|
+
*
|
|
1053
|
+
* The supported answer is a nested `Theme`: it re-declares semantic tokens for
|
|
1054
|
+
* the mode it is drawing. A Light swatch therefore stays light in a dark app
|
|
1055
|
+
* with no copied colours. This template uses the bundled neutral theme—the
|
|
1056
|
+
* same stable composition used by the docsite and generated applications.
|
|
1057
|
+
*/
|
|
1058
|
+
function ThemePreviewCanvas({mode}: {mode: 'light' | 'dark'}) {
|
|
1059
|
+
const canvas = (
|
|
1060
|
+
// No Cards here. A Card's default variant owns a border, and nesting one
|
|
1061
|
+
// inside a selected SelectableCard produced three competing outlines: the
|
|
1062
|
+
// selection ring, a frame around the preview, and another around its body.
|
|
1063
|
+
// These are decorative regions, not content containers, so flat token
|
|
1064
|
+
// surfaces are the weaker and correct grouping primitive.
|
|
1065
|
+
<VStack padding={2} gap={1.5} height="100%" xstyle={preview.mutedSurface}>
|
|
1066
|
+
<VStack gap={1} align="center">
|
|
1067
|
+
<VStack xstyle={[preview.bar, preview.barWide]} />
|
|
1068
|
+
<VStack xstyle={preview.bar} />
|
|
1069
|
+
</VStack>
|
|
1070
|
+
<StackItem size="fill">
|
|
1071
|
+
<VStack
|
|
1072
|
+
padding={2}
|
|
1073
|
+
gap={1}
|
|
1074
|
+
justify="center"
|
|
1075
|
+
height="100%"
|
|
1076
|
+
xstyle={preview.contentSurface}>
|
|
1077
|
+
{[0, 1, 2].map(row => (
|
|
1078
|
+
<VStack key={row} xstyle={preview.bar} />
|
|
1079
|
+
))}
|
|
1080
|
+
</VStack>
|
|
1081
|
+
</StackItem>
|
|
1082
|
+
</VStack>
|
|
1083
|
+
);
|
|
1084
|
+
|
|
1085
|
+
return (
|
|
1086
|
+
// Use the bundled theme directly rather than discovering the ambient theme
|
|
1087
|
+
// through registry hooks. Template previews run against the installed
|
|
1088
|
+
// package's runtime surface, which can lag this repository's source types;
|
|
1089
|
+
// `Theme` + `neutralTheme` is the stable public composition the docsite and
|
|
1090
|
+
// generated applications already use.
|
|
1091
|
+
<Theme theme={neutralTheme} mode={mode}>
|
|
1092
|
+
{canvas}
|
|
1093
|
+
</Theme>
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function ThemePreviewMock({scheme}: {scheme: 'light' | 'dark' | 'system'}) {
|
|
1098
|
+
return (
|
|
1099
|
+
// One AspectRatio path for every option. System splits that same 3:2 frame
|
|
1100
|
+
// into two equal regions rather than drawing two full windows on top of one
|
|
1101
|
+
// another, so there are no absolute layers and no seams with stray borders.
|
|
1102
|
+
<AspectRatio ratio={3 / 2} xstyle={preview.inert}>
|
|
1103
|
+
<VStack aria-hidden="true" height="100%" xstyle={preview.clip}>
|
|
1104
|
+
{scheme === 'system' ? (
|
|
1105
|
+
<HStack gap={0} height="100%">
|
|
1106
|
+
<StackItem size="fill">
|
|
1107
|
+
<ThemePreviewCanvas mode="light" />
|
|
1108
|
+
</StackItem>
|
|
1109
|
+
<StackItem size="fill">
|
|
1110
|
+
<ThemePreviewCanvas mode="dark" />
|
|
1111
|
+
</StackItem>
|
|
1112
|
+
</HStack>
|
|
1113
|
+
) : (
|
|
1114
|
+
<ThemePreviewCanvas mode={scheme} />
|
|
1115
|
+
)}
|
|
1116
|
+
</VStack>
|
|
1117
|
+
</AspectRatio>
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
const THEME_CHOICES: {
|
|
1122
|
+
value: string;
|
|
1123
|
+
label: string;
|
|
1124
|
+
scheme: 'light' | 'dark' | 'system';
|
|
1125
|
+
icon: IconComponent;
|
|
1126
|
+
}[] = [
|
|
1127
|
+
{value: 'light', label: 'Light', scheme: 'light', icon: SunIcon},
|
|
1128
|
+
{value: 'dark', label: 'Dark', scheme: 'dark', icon: MoonIcon},
|
|
1129
|
+
{
|
|
1130
|
+
value: 'system',
|
|
1131
|
+
label: 'System',
|
|
1132
|
+
scheme: 'system',
|
|
1133
|
+
icon: ComputerDesktopIcon,
|
|
1134
|
+
},
|
|
122
1135
|
];
|
|
123
1136
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
1137
|
+
/**
|
|
1138
|
+
* The choices as pictures of what they do, the way macOS and VS Code put
|
|
1139
|
+
* appearance settings — a segmented control names the options, a card shows
|
|
1140
|
+
* them.
|
|
1141
|
+
*
|
|
1142
|
+
* `SelectableCard` is the Astryx card that carries selection: it owns the
|
|
1143
|
+
* accent border, the pressed semantics, the hover and the focus ring, so a
|
|
1144
|
+
* tile here is a picture and a name and nothing else. Keep the preview inset,
|
|
1145
|
+
* as this example does with `padding={2}`: the component draws its selection
|
|
1146
|
+
* ring as an inset shadow, underneath its children, so a full-bleed child with
|
|
1147
|
+
* an opaque background paints over the selected state.
|
|
1148
|
+
*
|
|
1149
|
+
* Note what it is NOT: its control is a checkbox, so three mutually exclusive
|
|
1150
|
+
* choices are three independent tab stops rather than one arrow-key group.
|
|
1151
|
+
* Single-select is enforced here instead — `onChange` only ever selects, so
|
|
1152
|
+
* re-picking the current theme is the no-op it should be — and the group is
|
|
1153
|
+
* named for a screen reader by the `radiogroup` around it. If you need the
|
|
1154
|
+
* roving tab stop, that is a native `<input type="radio">` group, not this.
|
|
1155
|
+
*
|
|
1156
|
+
* `RadioList` is out for a different reason: it lays a radio, a label and a
|
|
1157
|
+
* description on one line, and each choice here is a picture with a name under
|
|
1158
|
+
* it.
|
|
1159
|
+
*
|
|
1160
|
+
* Driving this from a test: click the CARD, not the checkbox. `SelectableCard`
|
|
1161
|
+
* puts the click handler on the card surface and keeps a visually-hidden
|
|
1162
|
+
* `<input type="checkbox">` for the role, name and checked state — so
|
|
1163
|
+
* `getByRole('checkbox').click()` aims at an element the card's own content
|
|
1164
|
+
* covers, and times out on a control a user can click perfectly well.
|
|
1165
|
+
*/
|
|
1166
|
+
function ThemeChoiceCards({
|
|
1167
|
+
value,
|
|
1168
|
+
onChange,
|
|
1169
|
+
}: {
|
|
1170
|
+
value: string;
|
|
1171
|
+
onChange: (value: string) => void;
|
|
1172
|
+
}) {
|
|
1173
|
+
return (
|
|
1174
|
+
<Grid
|
|
1175
|
+
role="radiogroup"
|
|
1176
|
+
aria-label="Color theme"
|
|
1177
|
+
// Three across wherever three fit; below that the icon and the name stop
|
|
1178
|
+
// fitting the card, so the grid folds rather than drawing slivers.
|
|
1179
|
+
columns={{minWidth: 96, max: 3}}
|
|
1180
|
+
gap={2}
|
|
1181
|
+
// Capped, or the tiles grow with the dialog: three stretched tracks in a
|
|
1182
|
+
// 1120px shell make a ~250px picture of a window per option, which is a
|
|
1183
|
+
// hero image rather than a control. The cap holds them at a size the row
|
|
1184
|
+
// can carry, and the grid still folds to two-then-one below it.
|
|
1185
|
+
maxWidth={480}>
|
|
1186
|
+
{THEME_CHOICES.map(choice => {
|
|
1187
|
+
const isSelected = choice.value === value;
|
|
1188
|
+
return (
|
|
1189
|
+
<SelectableCard
|
|
1190
|
+
key={choice.value}
|
|
1191
|
+
// Pinned here rather than taken from the card's contents, so the
|
|
1192
|
+
// accessible name cannot drift with the picture.
|
|
1193
|
+
label={choice.label}
|
|
1194
|
+
isSelected={isSelected}
|
|
1195
|
+
padding={2}
|
|
1196
|
+
onChange={() => {
|
|
1197
|
+
// Select-only: a card cannot deselect itself into "no theme".
|
|
1198
|
+
if (!isSelected) {
|
|
1199
|
+
onChange(choice.value);
|
|
1200
|
+
}
|
|
1201
|
+
}}>
|
|
1202
|
+
<VStack gap={1.5}>
|
|
1203
|
+
<ThemePreviewMock scheme={choice.scheme} />
|
|
1204
|
+
<HStack gap={1} align="center" justify="center">
|
|
1205
|
+
<Icon icon={choice.icon} size="sm" color="secondary" />
|
|
1206
|
+
<Text
|
|
1207
|
+
type="supporting"
|
|
1208
|
+
weight={isSelected ? 'semibold' : undefined}>
|
|
1209
|
+
{choice.label}
|
|
1210
|
+
</Text>
|
|
1211
|
+
</HStack>
|
|
1212
|
+
</VStack>
|
|
1213
|
+
</SelectableCard>
|
|
1214
|
+
);
|
|
1215
|
+
})}
|
|
1216
|
+
</Grid>
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
const INTERFACE_FONTS: {value: string; label: string; stack: string}[] = [
|
|
1221
|
+
{value: 'system', label: 'System', stack: 'var(--font-family-body)'},
|
|
1222
|
+
{value: 'serif', label: 'Serif', stack: 'Georgia, "Times New Roman", serif'},
|
|
1223
|
+
{
|
|
1224
|
+
value: 'mono',
|
|
1225
|
+
label: 'Monospace',
|
|
1226
|
+
stack: 'var(--font-family-code)',
|
|
1227
|
+
},
|
|
131
1228
|
];
|
|
132
1229
|
|
|
133
|
-
|
|
1230
|
+
function interfaceFont(value: string) {
|
|
1231
|
+
return (
|
|
1232
|
+
INTERFACE_FONTS.find(font => font.value === value) ?? INTERFACE_FONTS[0]
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* The selector-plus-preview pattern: a control that changes how something
|
|
1238
|
+
* READS gets a sample of the thing itself, not a description of it.
|
|
1239
|
+
*
|
|
1240
|
+
* The face is applied by re-declaring `--font-family-body` on the surface, not
|
|
1241
|
+
* by setting `font-family` on the words. Astryx's own type rules resolve that
|
|
1242
|
+
* token, so overriding it re-points every `Text` inside; setting `font-family`
|
|
1243
|
+
* instead reaches only what inherits it, and a preview built that way silently
|
|
1244
|
+
* keeps showing the system font whatever the selector says.
|
|
1245
|
+
*
|
|
1246
|
+
* It sits on the dialog's canvas inside the card's muted fill, so it reads as a
|
|
1247
|
+
* window onto another surface rather than as more of the settings form.
|
|
1248
|
+
*/
|
|
1249
|
+
function FontPreview({value}: {value: string}) {
|
|
1250
|
+
const font = interfaceFont(value);
|
|
1251
|
+
return (
|
|
1252
|
+
// A `default` Card — the white one — inside the group's muted fill. The
|
|
1253
|
+
// sample is a window onto another surface, and the two variants are what
|
|
1254
|
+
// says so: muted inside muted has no contrast and reads as more of the
|
|
1255
|
+
// settings form. The card's own EDGE sits on the panel's content line, the
|
|
1256
|
+
// same line the theme tiles' edges sit on, so the previews in this panel
|
|
1257
|
+
// line up with each other and with the row titles above them.
|
|
1258
|
+
<Card padding={3} width="100%">
|
|
1259
|
+
<VStack gap={1} style={faceStyle(font.stack)}>
|
|
1260
|
+
<Text type="label">Weekly digest</Text>
|
|
1261
|
+
<Text type="supporting" color="secondary">
|
|
1262
|
+
Eight threads moved while you were away, and three are waiting on you.
|
|
1263
|
+
</Text>
|
|
1264
|
+
</VStack>
|
|
1265
|
+
</Card>
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function AppearancePanel({settings, update}: PanelProps) {
|
|
1270
|
+
return (
|
|
1271
|
+
<VStack gap={5}>
|
|
1272
|
+
{/* Unlabelled on purpose: a "Theme" heading under an "Appearance" title
|
|
1273
|
+
says the same word twice. */}
|
|
1274
|
+
<SettingsCard>
|
|
1275
|
+
<SettingsRow
|
|
1276
|
+
title="Color theme"
|
|
1277
|
+
icon={PaintBrushIcon}
|
|
1278
|
+
description="Applies to this browser only."
|
|
1279
|
+
detail={
|
|
1280
|
+
<ThemeChoiceCards
|
|
1281
|
+
value={settings.theme}
|
|
1282
|
+
onChange={value => update('theme', value)}
|
|
1283
|
+
/>
|
|
1284
|
+
}
|
|
1285
|
+
/>
|
|
1286
|
+
<SettingsRow
|
|
1287
|
+
title="Accent color"
|
|
1288
|
+
icon={SwatchIcon}
|
|
1289
|
+
description="Used for links, selection, and primary buttons."
|
|
1290
|
+
control={
|
|
1291
|
+
<Selector
|
|
1292
|
+
label="Accent color"
|
|
1293
|
+
isLabelHidden
|
|
1294
|
+
size="sm"
|
|
1295
|
+
width={CONTROL_WIDTH}
|
|
1296
|
+
value={settings.accent}
|
|
1297
|
+
onChange={value => update('accent', value)}
|
|
1298
|
+
options={[
|
|
1299
|
+
{value: 'blue', label: 'Blue'},
|
|
1300
|
+
{value: 'teal', label: 'Teal'},
|
|
1301
|
+
{value: 'purple', label: 'Purple'},
|
|
1302
|
+
{value: 'orange', label: 'Orange'},
|
|
1303
|
+
]}
|
|
1304
|
+
/>
|
|
1305
|
+
}
|
|
1306
|
+
/>
|
|
1307
|
+
</SettingsCard>
|
|
1308
|
+
|
|
1309
|
+
<SettingsCard title="Typography">
|
|
1310
|
+
<SettingsRow
|
|
1311
|
+
title="Interface font"
|
|
1312
|
+
icon={DocumentTextIcon}
|
|
1313
|
+
description="Menus, labels, and message text."
|
|
1314
|
+
control={
|
|
1315
|
+
<Selector
|
|
1316
|
+
label="Interface font"
|
|
1317
|
+
isLabelHidden
|
|
1318
|
+
size="sm"
|
|
1319
|
+
width={CONTROL_WIDTH}
|
|
1320
|
+
value={settings.font}
|
|
1321
|
+
onChange={value => update('font', value)}
|
|
1322
|
+
options={INTERFACE_FONTS.map(font => ({
|
|
1323
|
+
value: font.value,
|
|
1324
|
+
label: font.label,
|
|
1325
|
+
}))}
|
|
1326
|
+
/>
|
|
1327
|
+
}
|
|
1328
|
+
detail={<FontPreview value={settings.font} />}
|
|
1329
|
+
/>
|
|
1330
|
+
</SettingsCard>
|
|
1331
|
+
|
|
1332
|
+
<SettingsCard title="Layout">
|
|
1333
|
+
<SettingsRow
|
|
1334
|
+
title="Interface density"
|
|
1335
|
+
icon={Bars3Icon}
|
|
1336
|
+
description="How much breathing room rows and lists get."
|
|
1337
|
+
control={
|
|
1338
|
+
<SegmentedControl
|
|
1339
|
+
label="Interface density"
|
|
1340
|
+
size="sm"
|
|
1341
|
+
value={settings.density}
|
|
1342
|
+
onChange={value => update('density', value)}>
|
|
1343
|
+
<SegmentedControlItem value="comfortable" label="Comfortable" />
|
|
1344
|
+
<SegmentedControlItem value="compact" label="Compact" />
|
|
1345
|
+
</SegmentedControl>
|
|
1346
|
+
}
|
|
1347
|
+
/>
|
|
1348
|
+
<SettingsRow
|
|
1349
|
+
title="Reduce motion"
|
|
1350
|
+
icon={HandRaisedIcon}
|
|
1351
|
+
description="Skip transitions and animated transforms."
|
|
1352
|
+
control={
|
|
1353
|
+
<Switch
|
|
1354
|
+
label="Reduce motion"
|
|
1355
|
+
isLabelHidden
|
|
1356
|
+
value={settings.reduceMotion}
|
|
1357
|
+
onChange={value => update('reduceMotion', value)}
|
|
1358
|
+
/>
|
|
1359
|
+
}
|
|
1360
|
+
/>
|
|
1361
|
+
</SettingsCard>
|
|
1362
|
+
</VStack>
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function NotificationsPanel({settings, update}: PanelProps) {
|
|
1367
|
+
return (
|
|
1368
|
+
<VStack gap={5}>
|
|
1369
|
+
<SettingsCard title="Email">
|
|
1370
|
+
<SettingsRow
|
|
1371
|
+
title="Mentions"
|
|
1372
|
+
description="Someone types your name."
|
|
1373
|
+
icon={AtSymbolIcon}
|
|
1374
|
+
control={
|
|
1375
|
+
<Switch
|
|
1376
|
+
label="Email me about mentions"
|
|
1377
|
+
isLabelHidden
|
|
1378
|
+
value={settings.emailMentions}
|
|
1379
|
+
onChange={value => update('emailMentions', value)}
|
|
1380
|
+
/>
|
|
1381
|
+
}
|
|
1382
|
+
/>
|
|
1383
|
+
<SettingsRow
|
|
1384
|
+
title="Comments"
|
|
1385
|
+
description="Replies on threads you are part of."
|
|
1386
|
+
icon={ChatBubbleLeftRightIcon}
|
|
1387
|
+
control={
|
|
1388
|
+
<Switch
|
|
1389
|
+
label="Email me about comments"
|
|
1390
|
+
isLabelHidden
|
|
1391
|
+
value={settings.emailComments}
|
|
1392
|
+
onChange={value => update('emailComments', value)}
|
|
1393
|
+
/>
|
|
1394
|
+
}
|
|
1395
|
+
/>
|
|
1396
|
+
<SettingsRow
|
|
1397
|
+
title="Weekly digest"
|
|
1398
|
+
description="One summary every Monday morning."
|
|
1399
|
+
icon={EnvelopeIcon}
|
|
1400
|
+
control={
|
|
1401
|
+
<Switch
|
|
1402
|
+
label="Send the weekly digest"
|
|
1403
|
+
isLabelHidden
|
|
1404
|
+
value={settings.weeklyDigest}
|
|
1405
|
+
onChange={value => update('weeklyDigest', value)}
|
|
1406
|
+
/>
|
|
1407
|
+
}
|
|
1408
|
+
/>
|
|
1409
|
+
</SettingsCard>
|
|
1410
|
+
|
|
1411
|
+
<SettingsCard title="Desktop">
|
|
1412
|
+
<SettingsRow
|
|
1413
|
+
title="Desktop notifications"
|
|
1414
|
+
icon={ComputerDesktopIcon}
|
|
1415
|
+
description="Requires permission from your browser."
|
|
1416
|
+
control={
|
|
1417
|
+
<Switch
|
|
1418
|
+
label="Desktop notifications"
|
|
1419
|
+
isLabelHidden
|
|
1420
|
+
value={settings.desktopAlerts}
|
|
1421
|
+
onChange={value => update('desktopAlerts', value)}
|
|
1422
|
+
/>
|
|
1423
|
+
}
|
|
1424
|
+
/>
|
|
1425
|
+
<SettingsRow
|
|
1426
|
+
title="Play a sound"
|
|
1427
|
+
icon={SpeakerWaveIcon}
|
|
1428
|
+
control={
|
|
1429
|
+
<Switch
|
|
1430
|
+
label="Play a sound"
|
|
1431
|
+
isLabelHidden
|
|
1432
|
+
value={settings.notificationSounds}
|
|
1433
|
+
onChange={value => update('notificationSounds', value)}
|
|
1434
|
+
/>
|
|
1435
|
+
}
|
|
1436
|
+
/>
|
|
1437
|
+
<SettingsRow
|
|
1438
|
+
title="Quiet hours"
|
|
1439
|
+
description="Hold notifications and deliver them in the morning."
|
|
1440
|
+
icon={ClockIcon}
|
|
1441
|
+
control={
|
|
1442
|
+
<Selector
|
|
1443
|
+
label="Quiet hours"
|
|
1444
|
+
isLabelHidden
|
|
1445
|
+
size="sm"
|
|
1446
|
+
width={CONTROL_WIDTH}
|
|
1447
|
+
value={settings.quietHours}
|
|
1448
|
+
onChange={value => update('quietHours', value)}
|
|
1449
|
+
options={[
|
|
1450
|
+
{value: 'none', label: 'Off'},
|
|
1451
|
+
{value: 'evening', label: '18:00 – 09:00'},
|
|
1452
|
+
{value: 'night', label: '22:00 – 07:00'},
|
|
1453
|
+
]}
|
|
1454
|
+
/>
|
|
1455
|
+
}
|
|
1456
|
+
/>
|
|
1457
|
+
</SettingsCard>
|
|
1458
|
+
</VStack>
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/* ------------------------------------------------------------------ *
|
|
1463
|
+
* Keyboard shortcuts
|
|
1464
|
+
* ------------------------------------------------------------------ */
|
|
1465
|
+
|
|
1466
|
+
interface Chord {
|
|
1467
|
+
/** The platform's command key — ⌘ on a Mac, Ctrl elsewhere. `Kbd` decides. */
|
|
1468
|
+
mod?: boolean;
|
|
1469
|
+
shift?: boolean;
|
|
1470
|
+
alt?: boolean;
|
|
1471
|
+
key: string;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
interface ShortcutDef {
|
|
1475
|
+
id: string;
|
|
134
1476
|
label: string;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
isExpanded: boolean;
|
|
138
|
-
onEdit: () => void;
|
|
139
|
-
onCancel: () => void;
|
|
140
|
-
onSave: () => void;
|
|
1477
|
+
icon: IconComponent;
|
|
1478
|
+
binding: Chord;
|
|
141
1479
|
}
|
|
142
1480
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
1481
|
+
const SHORTCUTS: ShortcutDef[] = [
|
|
1482
|
+
{
|
|
1483
|
+
id: 'search',
|
|
1484
|
+
label: 'Search everything',
|
|
1485
|
+
icon: MagnifyingGlassIcon,
|
|
1486
|
+
binding: {mod: true, key: 'k'},
|
|
1487
|
+
},
|
|
1488
|
+
{
|
|
1489
|
+
id: 'new',
|
|
1490
|
+
label: 'New item',
|
|
1491
|
+
icon: PencilSquareIcon,
|
|
1492
|
+
binding: {mod: true, key: 'n'},
|
|
1493
|
+
},
|
|
1494
|
+
{
|
|
1495
|
+
id: 'save',
|
|
1496
|
+
label: 'Save',
|
|
1497
|
+
icon: ArrowDownOnSquareIcon,
|
|
1498
|
+
binding: {mod: true, key: 's'},
|
|
1499
|
+
},
|
|
1500
|
+
{
|
|
1501
|
+
id: 'settings',
|
|
1502
|
+
label: 'Open settings',
|
|
1503
|
+
icon: Cog6ToothIcon,
|
|
1504
|
+
binding: {mod: true, key: ','},
|
|
1505
|
+
},
|
|
1506
|
+
{
|
|
1507
|
+
id: 'sidebar',
|
|
1508
|
+
label: 'Toggle the sidebar',
|
|
1509
|
+
icon: Bars3BottomLeftIcon,
|
|
1510
|
+
binding: {mod: true, shift: true, key: 'b'},
|
|
1511
|
+
},
|
|
1512
|
+
{
|
|
1513
|
+
id: 'help',
|
|
1514
|
+
label: 'Shortcut cheat sheet',
|
|
1515
|
+
icon: QuestionMarkCircleIcon,
|
|
1516
|
+
binding: {shift: true, key: '?'},
|
|
1517
|
+
},
|
|
1518
|
+
];
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* `KeyboardEvent.key` names `Kbd` does not already understand.
|
|
1522
|
+
*
|
|
1523
|
+
* `Kbd` lowercases each part and falls back to `key.toUpperCase()`, so "k",
|
|
1524
|
+
* "F2" and "?" need no entry. These do: the arrows have short names in `Kbd`'s
|
|
1525
|
+
* own map, and "+" is the character it SPLITS `keys` on — a literal plus has to
|
|
1526
|
+
* take the "plus" escape or the chord renders as two blank keycaps.
|
|
1527
|
+
*/
|
|
1528
|
+
const KBD_KEY_NAMES: Record<string, string> = {
|
|
1529
|
+
ArrowUp: 'up',
|
|
1530
|
+
ArrowDown: 'down',
|
|
1531
|
+
ArrowLeft: 'left',
|
|
1532
|
+
ArrowRight: 'right',
|
|
1533
|
+
'+': 'plus',
|
|
1534
|
+
' ': 'space',
|
|
1535
|
+
};
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* A chord as `Kbd`'s `keys` string — the whole thing in one component,
|
|
1539
|
+
* "+"-separated, in modifier order so the chip reads left to right.
|
|
1540
|
+
*
|
|
1541
|
+
* `mod` is handed over UNRESOLVED, because resolving it is `Kbd`'s job and it
|
|
1542
|
+
* does that through `useSyncExternalStore` — so unlike a hand-rolled
|
|
1543
|
+
* `navigator.platform` check it agrees with its own server render.
|
|
1544
|
+
*/
|
|
1545
|
+
function chordKeys(chord: Chord): string {
|
|
1546
|
+
const parts: string[] = [];
|
|
1547
|
+
if (chord.mod) {
|
|
1548
|
+
parts.push('mod');
|
|
1549
|
+
}
|
|
1550
|
+
if (chord.shift) {
|
|
1551
|
+
parts.push('shift');
|
|
1552
|
+
}
|
|
1553
|
+
if (chord.alt) {
|
|
1554
|
+
parts.push('alt');
|
|
1555
|
+
}
|
|
1556
|
+
parts.push(KBD_KEY_NAMES[chord.key] ?? chord.key);
|
|
1557
|
+
return parts.join('+');
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/** The chord as words, for the accessible name behind the keycaps. */
|
|
1561
|
+
function chordLabel(chord: Chord): string {
|
|
1562
|
+
const parts: string[] = [];
|
|
1563
|
+
if (chord.mod) {
|
|
1564
|
+
parts.push('Mod');
|
|
1565
|
+
}
|
|
1566
|
+
if (chord.shift) {
|
|
1567
|
+
parts.push('Shift');
|
|
1568
|
+
}
|
|
1569
|
+
if (chord.alt) {
|
|
1570
|
+
parts.push('Alt');
|
|
1571
|
+
}
|
|
1572
|
+
parts.push(chord.key === ' ' ? 'Space' : chord.key.toUpperCase());
|
|
1573
|
+
return parts.join(' + ');
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/** Wide enough to hold a chord like ⌘⇧F without the row jumping. */
|
|
1577
|
+
const RECORDER_WIDTH = 96;
|
|
1578
|
+
|
|
1579
|
+
/**
|
|
1580
|
+
* The rebinding capture: an input that swallows every keystroke and reports the
|
|
1581
|
+
* chord instead of typing it.
|
|
1582
|
+
*
|
|
1583
|
+
* It stays an `<input>` on purpose. Shortcut dispatchers skip events whose
|
|
1584
|
+
* target is editable, so a recorder built out of a `div` with a key handler
|
|
1585
|
+
* fires the very shortcut it is trying to record. `TextInput` has no
|
|
1586
|
+
* `isReadOnly` equivalent that keeps focus, so the value is pinned to `""` and
|
|
1587
|
+
* every keydown is `preventDefault`ed — which is what keeps it empty.
|
|
1588
|
+
*/
|
|
1589
|
+
function ShortcutRecorder({
|
|
1590
|
+
onCapture,
|
|
149
1591
|
onCancel,
|
|
150
|
-
|
|
151
|
-
|
|
1592
|
+
}: {
|
|
1593
|
+
onCapture: (chord: Chord) => void;
|
|
1594
|
+
onCancel: () => void;
|
|
1595
|
+
}) {
|
|
1596
|
+
const handleKeyDown = useCallback(
|
|
1597
|
+
(event: React.KeyboardEvent) => {
|
|
1598
|
+
event.preventDefault();
|
|
1599
|
+
event.stopPropagation();
|
|
1600
|
+
|
|
1601
|
+
if (event.key === 'Escape') {
|
|
1602
|
+
onCancel();
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
// A modifier alone is not a chord — wait for the key it modifies.
|
|
1606
|
+
if (['Control', 'Meta', 'Alt', 'Shift'].includes(event.key)) {
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
onCapture({
|
|
1611
|
+
mod: event.metaKey || event.ctrlKey,
|
|
1612
|
+
shift: event.shiftKey,
|
|
1613
|
+
alt: event.altKey,
|
|
1614
|
+
key: event.key,
|
|
1615
|
+
});
|
|
1616
|
+
},
|
|
1617
|
+
[onCapture, onCancel],
|
|
1618
|
+
);
|
|
1619
|
+
|
|
152
1620
|
return (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
1621
|
+
<TextInput
|
|
1622
|
+
// A real label rather than leaning on the placeholder: the placeholder is
|
|
1623
|
+
// not an accessible name, and this control has no visible one of its own.
|
|
1624
|
+
label="Type a shortcut"
|
|
1625
|
+
isLabelHidden
|
|
1626
|
+
placeholder="Type it"
|
|
1627
|
+
size="sm"
|
|
1628
|
+
width={RECORDER_WIDTH}
|
|
1629
|
+
value=""
|
|
1630
|
+
hasAutoFocus
|
|
1631
|
+
onKeyDown={handleKeyDown}
|
|
1632
|
+
onBlur={onCancel}
|
|
1633
|
+
/>
|
|
1634
|
+
);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
function ShortcutRow({
|
|
1638
|
+
def,
|
|
1639
|
+
chord,
|
|
1640
|
+
isEnabled,
|
|
1641
|
+
isCustom,
|
|
1642
|
+
onRebind,
|
|
1643
|
+
onToggle,
|
|
1644
|
+
onReset,
|
|
1645
|
+
}: {
|
|
1646
|
+
def: ShortcutDef;
|
|
1647
|
+
chord: Chord;
|
|
1648
|
+
isEnabled: boolean;
|
|
1649
|
+
isCustom: boolean;
|
|
1650
|
+
onRebind: (chord: Chord) => void;
|
|
1651
|
+
onToggle: () => void;
|
|
1652
|
+
onReset: () => void;
|
|
1653
|
+
}) {
|
|
1654
|
+
const [isRecording, setIsRecording] = useState(false);
|
|
1655
|
+
|
|
1656
|
+
return (
|
|
1657
|
+
<SettingsRow
|
|
1658
|
+
title={def.label}
|
|
1659
|
+
icon={def.icon}
|
|
1660
|
+
control={
|
|
1661
|
+
<HStack gap={2} align="center">
|
|
1662
|
+
{isCustom && (
|
|
1663
|
+
<IconButton
|
|
1664
|
+
label={`Reset ${def.label} to its default`}
|
|
1665
|
+
icon={<Icon icon={ArrowUturnLeftIcon} size="sm" />}
|
|
1666
|
+
variant="ghost"
|
|
1667
|
+
size="sm"
|
|
1668
|
+
onClick={onReset}
|
|
1669
|
+
/>
|
|
1670
|
+
)}
|
|
1671
|
+
{isRecording ? (
|
|
1672
|
+
<ShortcutRecorder
|
|
1673
|
+
onCapture={next => {
|
|
1674
|
+
onRebind(next);
|
|
1675
|
+
setIsRecording(false);
|
|
1676
|
+
}}
|
|
1677
|
+
onCancel={() => {
|
|
1678
|
+
setIsRecording(false);
|
|
1679
|
+
}}
|
|
1680
|
+
/>
|
|
1681
|
+
) : (
|
|
1682
|
+
<Button
|
|
1683
|
+
// `Kbd` goes in as CHILDREN and `label` stays the words: Astryx
|
|
1684
|
+
// makes `label` the `aria-label` as soon as there are children,
|
|
1685
|
+
// so the keycaps are what you see and the chord is what a screen
|
|
1686
|
+
// reader says.
|
|
1687
|
+
label={isEnabled ? chordLabel(chord) : 'No shortcut'}
|
|
1688
|
+
// Ghost, not secondary: `Kbd` fills its own keycaps from the same
|
|
1689
|
+
// neutral token a secondary button fills with, so a filled button
|
|
1690
|
+
// around them is a chip inside a chip in one flat grey. The
|
|
1691
|
+
// keycaps are the chip; the button is the hit target.
|
|
1692
|
+
variant="ghost"
|
|
1693
|
+
size="sm"
|
|
1694
|
+
isDisabled={!isEnabled}
|
|
1695
|
+
tooltip={
|
|
1696
|
+
isEnabled ? 'Click to change' : 'Switch it on to rebind it'
|
|
1697
|
+
}
|
|
1698
|
+
onClick={() => {
|
|
1699
|
+
setIsRecording(true);
|
|
1700
|
+
}}>
|
|
1701
|
+
{isEnabled ? <Kbd keys={chordKeys(chord)} /> : undefined}
|
|
1702
|
+
</Button>
|
|
1703
|
+
)}
|
|
1704
|
+
<Tooltip content={isEnabled ? 'Disable' : 'Enable'}>
|
|
1705
|
+
<Switch
|
|
1706
|
+
size="sm"
|
|
1707
|
+
label={`${def.label} shortcut`}
|
|
1708
|
+
isLabelHidden
|
|
1709
|
+
value={isEnabled}
|
|
1710
|
+
onChange={onToggle}
|
|
1711
|
+
/>
|
|
1712
|
+
</Tooltip>
|
|
1713
|
+
</HStack>
|
|
1714
|
+
}
|
|
1715
|
+
/>
|
|
1716
|
+
);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
/**
|
|
1720
|
+
* Every shortcut on one card: the binding as keycaps you can click to rebind,
|
|
1721
|
+
* and a switch to turn it off entirely.
|
|
1722
|
+
*
|
|
1723
|
+
* Overrides are held as a sparse map rather than as a copy of the list, so
|
|
1724
|
+
* "custom" is a fact about the map (the key is present) rather than a
|
|
1725
|
+
* comparison against defaults that has to be kept honest.
|
|
1726
|
+
*/
|
|
1727
|
+
function ShortcutsPanel() {
|
|
1728
|
+
const [overrides, setOverrides] = useState<Record<string, Chord | false>>({});
|
|
1729
|
+
|
|
1730
|
+
const resolve = (def: ShortcutDef) => overrides[def.id] ?? def.binding;
|
|
1731
|
+
const hasOverrides = Object.keys(overrides).length > 0;
|
|
1732
|
+
|
|
1733
|
+
const setOverride = (id: string, value: Chord | false) => {
|
|
1734
|
+
setOverrides(current => ({...current, [id]: value}));
|
|
1735
|
+
};
|
|
1736
|
+
const clearOverride = (id: string) => {
|
|
1737
|
+
setOverrides(current => {
|
|
1738
|
+
const next = {...current};
|
|
1739
|
+
Reflect.deleteProperty(next, id);
|
|
1740
|
+
return next;
|
|
1741
|
+
});
|
|
1742
|
+
};
|
|
1743
|
+
|
|
1744
|
+
return (
|
|
1745
|
+
<SettingsCard title="Shortcuts">
|
|
1746
|
+
{SHORTCUTS.map(def => {
|
|
1747
|
+
const resolved = resolve(def);
|
|
1748
|
+
const isEnabled = resolved !== false;
|
|
1749
|
+
return (
|
|
1750
|
+
<ShortcutRow
|
|
1751
|
+
key={def.id}
|
|
1752
|
+
def={def}
|
|
1753
|
+
chord={isEnabled ? resolved : def.binding}
|
|
1754
|
+
isEnabled={isEnabled}
|
|
1755
|
+
// A disabled shortcut is an override too, but its switch already
|
|
1756
|
+
// restores the default — a reset button beside it would be a
|
|
1757
|
+
// second control for the same act.
|
|
1758
|
+
isCustom={overrides[def.id] != null && overrides[def.id] !== false}
|
|
1759
|
+
onRebind={chord => {
|
|
1760
|
+
setOverride(def.id, chord);
|
|
1761
|
+
}}
|
|
1762
|
+
onToggle={() => {
|
|
1763
|
+
if (isEnabled) {
|
|
1764
|
+
setOverride(def.id, false);
|
|
1765
|
+
} else {
|
|
1766
|
+
clearOverride(def.id);
|
|
1767
|
+
}
|
|
1768
|
+
}}
|
|
1769
|
+
onReset={() => {
|
|
1770
|
+
clearOverride(def.id);
|
|
1771
|
+
}}
|
|
1772
|
+
/>
|
|
1773
|
+
);
|
|
1774
|
+
})}
|
|
1775
|
+
{hasOverrides && (
|
|
1776
|
+
// A row of its own at the foot of the group, so it takes the card's
|
|
1777
|
+
// divider above it like any other row — the action belongs to the whole
|
|
1778
|
+
// group rather than to the last shortcut in it.
|
|
1779
|
+
<VStack padding={ROW_PADDING}>
|
|
1780
|
+
<HStack>
|
|
1781
|
+
<Button
|
|
1782
|
+
label="Reset all to defaults"
|
|
1783
|
+
variant="ghost"
|
|
1784
|
+
size="sm"
|
|
1785
|
+
icon={<Icon icon={ArrowUturnLeftIcon} size="sm" />}
|
|
1786
|
+
onClick={() => {
|
|
1787
|
+
setOverrides({});
|
|
1788
|
+
}}
|
|
1789
|
+
/>
|
|
163
1790
|
</HStack>
|
|
164
1791
|
</VStack>
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
1792
|
+
)}
|
|
1793
|
+
</SettingsCard>
|
|
1794
|
+
);
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function LanguagePanel({settings, update}: PanelProps) {
|
|
1798
|
+
return (
|
|
1799
|
+
<SettingsCard title="Language & region">
|
|
1800
|
+
<SettingsRow
|
|
1801
|
+
title="Language"
|
|
1802
|
+
icon={LanguageIcon}
|
|
1803
|
+
description="Used across menus, buttons, and email."
|
|
1804
|
+
control={
|
|
1805
|
+
<Selector
|
|
1806
|
+
label="Language"
|
|
1807
|
+
isLabelHidden
|
|
1808
|
+
size="sm"
|
|
1809
|
+
width={CONTROL_WIDTH}
|
|
1810
|
+
value={settings.language}
|
|
1811
|
+
onChange={value => update('language', value)}
|
|
1812
|
+
options={[
|
|
1813
|
+
{value: 'en-US', label: 'English (US)'},
|
|
1814
|
+
{value: 'en-GB', label: 'English (UK)'},
|
|
1815
|
+
{value: 'fr', label: 'Français'},
|
|
1816
|
+
{value: 'de', label: 'Deutsch'},
|
|
1817
|
+
{value: 'ja', label: '日本語'},
|
|
1818
|
+
]}
|
|
1819
|
+
/>
|
|
1820
|
+
}
|
|
1821
|
+
/>
|
|
1822
|
+
<SettingsRow
|
|
1823
|
+
title="Region format"
|
|
1824
|
+
icon={MapPinIcon}
|
|
1825
|
+
description="Dates, numbers, and currency."
|
|
1826
|
+
control={
|
|
1827
|
+
<Selector
|
|
1828
|
+
label="Region format"
|
|
1829
|
+
isLabelHidden
|
|
1830
|
+
size="sm"
|
|
1831
|
+
width={CONTROL_WIDTH}
|
|
1832
|
+
value={settings.region}
|
|
1833
|
+
onChange={value => update('region', value)}
|
|
1834
|
+
options={[
|
|
1835
|
+
{value: 'US', label: 'United States'},
|
|
1836
|
+
{value: 'GB', label: 'United Kingdom'},
|
|
1837
|
+
{value: 'DE', label: 'Germany'},
|
|
1838
|
+
{value: 'JP', label: 'Japan'},
|
|
1839
|
+
]}
|
|
1840
|
+
/>
|
|
1841
|
+
}
|
|
1842
|
+
/>
|
|
1843
|
+
<SettingsRow
|
|
1844
|
+
title="Time zone"
|
|
1845
|
+
icon={GlobeAltIcon}
|
|
1846
|
+
description="Used for scheduling and every timestamp you see."
|
|
1847
|
+
control={
|
|
1848
|
+
<Selector
|
|
1849
|
+
label="Time zone"
|
|
1850
|
+
isLabelHidden
|
|
1851
|
+
size="sm"
|
|
1852
|
+
width={CONTROL_WIDTH}
|
|
1853
|
+
value={settings.timezone}
|
|
1854
|
+
onChange={value => update('timezone', value)}
|
|
1855
|
+
options={[
|
|
1856
|
+
// Zone first, offset second. Led by the offset, every option
|
|
1857
|
+
// starts with the same five characters and the list can only be
|
|
1858
|
+
// read to its end — and the longest of them then truncates
|
|
1859
|
+
// inside the control column.
|
|
1860
|
+
{value: 'PT', label: 'Pacific (GMT-08:00)'},
|
|
1861
|
+
{value: 'MT', label: 'Mountain (GMT-07:00)'},
|
|
1862
|
+
{value: 'CT', label: 'Central (GMT-06:00)'},
|
|
1863
|
+
{value: 'ET', label: 'Eastern (GMT-05:00)'},
|
|
1864
|
+
{value: 'UTC', label: 'UTC (GMT+00:00)'},
|
|
1865
|
+
]}
|
|
1866
|
+
/>
|
|
1867
|
+
}
|
|
1868
|
+
/>
|
|
1869
|
+
<SettingsRow
|
|
1870
|
+
title="Start the week on"
|
|
1871
|
+
icon={CalendarDaysIcon}
|
|
1872
|
+
control={
|
|
1873
|
+
<SegmentedControl
|
|
1874
|
+
label="Start the week on"
|
|
1875
|
+
size="sm"
|
|
1876
|
+
value={settings.weekStart}
|
|
1877
|
+
onChange={value => update('weekStart', value)}>
|
|
1878
|
+
<SegmentedControlItem value="sun" label="Sunday" />
|
|
1879
|
+
<SegmentedControlItem value="mon" label="Monday" />
|
|
1880
|
+
</SegmentedControl>
|
|
1881
|
+
}
|
|
1882
|
+
/>
|
|
1883
|
+
</SettingsCard>
|
|
1884
|
+
);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
function PrivacyPanel({settings, update}: PanelProps) {
|
|
1888
|
+
return (
|
|
1889
|
+
<VStack gap={5}>
|
|
1890
|
+
<SettingsCard title="Visibility">
|
|
1891
|
+
<SettingsRow
|
|
1892
|
+
title="Discoverable by email"
|
|
1893
|
+
icon={MagnifyingGlassIcon}
|
|
1894
|
+
description="Let people find your profile by searching your address."
|
|
1895
|
+
control={
|
|
1896
|
+
<Switch
|
|
1897
|
+
label="Discoverable by email"
|
|
1898
|
+
isLabelHidden
|
|
1899
|
+
value={settings.discoverable}
|
|
1900
|
+
onChange={value => update('discoverable', value)}
|
|
1901
|
+
/>
|
|
1902
|
+
}
|
|
1903
|
+
/>
|
|
1904
|
+
<SettingsRow
|
|
1905
|
+
title="Read receipts"
|
|
1906
|
+
icon={EyeIcon}
|
|
1907
|
+
description="Show people when you have read their message."
|
|
1908
|
+
control={
|
|
1909
|
+
<Switch
|
|
1910
|
+
label="Read receipts"
|
|
1911
|
+
isLabelHidden
|
|
1912
|
+
value={settings.readReceipts}
|
|
1913
|
+
onChange={value => update('readReceipts', value)}
|
|
1914
|
+
/>
|
|
1915
|
+
}
|
|
1916
|
+
/>
|
|
1917
|
+
<SettingsRow
|
|
1918
|
+
title="Activity status"
|
|
1919
|
+
icon={SignalIcon}
|
|
1920
|
+
description="Show a green dot while you are online."
|
|
1921
|
+
control={
|
|
1922
|
+
<Switch
|
|
1923
|
+
label="Activity status"
|
|
1924
|
+
isLabelHidden
|
|
1925
|
+
value={settings.activityStatus}
|
|
1926
|
+
onChange={value => update('activityStatus', value)}
|
|
1927
|
+
/>
|
|
1928
|
+
}
|
|
1929
|
+
/>
|
|
1930
|
+
</SettingsCard>
|
|
1931
|
+
|
|
1932
|
+
<SettingsCard title="Your data">
|
|
1933
|
+
<SettingsRow
|
|
1934
|
+
title="Personalized suggestions"
|
|
1935
|
+
description="Use your activity to rank what you see first."
|
|
1936
|
+
icon={SparklesIcon}
|
|
1937
|
+
control={
|
|
1938
|
+
<Switch
|
|
1939
|
+
label="Personalized suggestions"
|
|
1940
|
+
isLabelHidden
|
|
1941
|
+
value={settings.personalization}
|
|
1942
|
+
onChange={value => update('personalization', value)}
|
|
1943
|
+
/>
|
|
1944
|
+
}
|
|
1945
|
+
/>
|
|
1946
|
+
<SettingsRow
|
|
1947
|
+
title="Export my data"
|
|
1948
|
+
description="We will email a download link within 24 hours."
|
|
1949
|
+
icon={ArrowDownTrayIcon}
|
|
1950
|
+
control={
|
|
1951
|
+
<Button label="Request export" variant="secondary" size="sm" />
|
|
1952
|
+
}
|
|
1953
|
+
/>
|
|
1954
|
+
<SettingsRow
|
|
1955
|
+
title="Delete my account"
|
|
1956
|
+
description="Permanent, and it takes the workspace with it."
|
|
1957
|
+
icon={TrashIcon}
|
|
1958
|
+
control={<Button label="Delete" variant="destructive" size="sm" />}
|
|
1959
|
+
/>
|
|
1960
|
+
</SettingsCard>
|
|
1961
|
+
</VStack>
|
|
1962
|
+
);
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
function IntegrationsPanel() {
|
|
1966
|
+
return (
|
|
1967
|
+
<SettingsCard title="Connected apps">
|
|
1968
|
+
<SettingsRow
|
|
1969
|
+
title="Calendar"
|
|
1970
|
+
description="Connected as alex@example.com"
|
|
1971
|
+
icon={CalendarDaysIcon}
|
|
1972
|
+
control={<Button label="Disconnect" variant="ghost" size="sm" />}
|
|
1973
|
+
/>
|
|
1974
|
+
<SettingsRow
|
|
1975
|
+
title="File storage"
|
|
1976
|
+
description="Attach files straight from your drive."
|
|
1977
|
+
icon={FolderIcon}
|
|
1978
|
+
control={<Button label="Connect" variant="secondary" size="sm" />}
|
|
1979
|
+
/>
|
|
1980
|
+
<SettingsRow
|
|
1981
|
+
title="Chat"
|
|
1982
|
+
description="Post updates to a channel when work ships."
|
|
1983
|
+
icon={ChatBubbleLeftRightIcon}
|
|
1984
|
+
control={<Button label="Connect" variant="secondary" size="sm" />}
|
|
1985
|
+
/>
|
|
1986
|
+
</SettingsCard>
|
|
1987
|
+
);
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
function DeveloperPanel({settings, update}: PanelProps) {
|
|
1991
|
+
return (
|
|
1992
|
+
<VStack gap={5}>
|
|
1993
|
+
<SettingsCard title="API">
|
|
1994
|
+
<SettingsRow
|
|
1995
|
+
title="API access"
|
|
1996
|
+
description="Issue tokens that act on your behalf."
|
|
1997
|
+
icon={CodeBracketIcon}
|
|
1998
|
+
control={
|
|
1999
|
+
<Switch
|
|
2000
|
+
label="API access"
|
|
2001
|
+
isLabelHidden
|
|
2002
|
+
value={settings.apiAccess}
|
|
2003
|
+
onChange={value => update('apiAccess', value)}
|
|
2004
|
+
/>
|
|
2005
|
+
}
|
|
2006
|
+
/>
|
|
2007
|
+
<SettingsRow
|
|
2008
|
+
title="Webhook URL"
|
|
2009
|
+
icon={LinkIcon}
|
|
2010
|
+
description="We POST an event here whenever something changes."
|
|
2011
|
+
control={
|
|
2012
|
+
<TextInput
|
|
2013
|
+
label="Webhook URL"
|
|
2014
|
+
isLabelHidden
|
|
2015
|
+
size="sm"
|
|
2016
|
+
width={CONTROL_WIDTH}
|
|
2017
|
+
placeholder="https://example.com/hooks"
|
|
2018
|
+
isDisabled={!settings.apiAccess}
|
|
2019
|
+
value={settings.webhookUrl}
|
|
2020
|
+
onChange={value => update('webhookUrl', value)}
|
|
2021
|
+
/>
|
|
2022
|
+
}
|
|
2023
|
+
/>
|
|
2024
|
+
</SettingsCard>
|
|
2025
|
+
|
|
2026
|
+
<SettingsCard title="Pre-release">
|
|
2027
|
+
<SettingsRow
|
|
2028
|
+
title="Early access features"
|
|
2029
|
+
icon={BeakerIcon}
|
|
2030
|
+
titleAccessory={<Token label="Beta" size="sm" color="purple" />}
|
|
2031
|
+
description="Unfinished work, turned on for your account only."
|
|
2032
|
+
control={
|
|
2033
|
+
<Switch
|
|
2034
|
+
label="Early access features"
|
|
2035
|
+
isLabelHidden
|
|
2036
|
+
value={settings.earlyAccess}
|
|
2037
|
+
onChange={value => update('earlyAccess', value)}
|
|
2038
|
+
/>
|
|
2039
|
+
}
|
|
2040
|
+
/>
|
|
2041
|
+
<SettingsRow
|
|
2042
|
+
title="Verbose logging"
|
|
2043
|
+
icon={DocumentMagnifyingGlassIcon}
|
|
2044
|
+
description="Write a detailed trace to the browser console."
|
|
2045
|
+
control={
|
|
2046
|
+
<Switch
|
|
2047
|
+
label="Verbose logging"
|
|
2048
|
+
isLabelHidden
|
|
2049
|
+
value={settings.verboseLogging}
|
|
2050
|
+
onChange={value => update('verboseLogging', value)}
|
|
2051
|
+
/>
|
|
2052
|
+
}
|
|
2053
|
+
/>
|
|
2054
|
+
</SettingsCard>
|
|
2055
|
+
</VStack>
|
|
2056
|
+
);
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
/**
|
|
2060
|
+
* The body for a panel. Every entry is reachable from the same registry, so a
|
|
2061
|
+
* panel cannot be dropped from the hub by being forgotten here.
|
|
2062
|
+
*/
|
|
2063
|
+
function SettingsPanelBody({
|
|
2064
|
+
panel,
|
|
2065
|
+
settings,
|
|
2066
|
+
update,
|
|
2067
|
+
}: {
|
|
2068
|
+
panel: PanelId;
|
|
2069
|
+
} & PanelProps) {
|
|
2070
|
+
switch (panel) {
|
|
2071
|
+
case 'profile':
|
|
2072
|
+
return <ProfilePanel settings={settings} update={update} />;
|
|
2073
|
+
case 'security':
|
|
2074
|
+
return <SecurityPanel settings={settings} update={update} />;
|
|
2075
|
+
case 'billing':
|
|
2076
|
+
return <BillingPanel settings={settings} update={update} />;
|
|
2077
|
+
case 'appearance':
|
|
2078
|
+
return <AppearancePanel settings={settings} update={update} />;
|
|
2079
|
+
case 'notifications':
|
|
2080
|
+
return <NotificationsPanel settings={settings} update={update} />;
|
|
2081
|
+
case 'shortcuts':
|
|
2082
|
+
return <ShortcutsPanel />;
|
|
2083
|
+
case 'language':
|
|
2084
|
+
return <LanguagePanel settings={settings} update={update} />;
|
|
2085
|
+
case 'privacy':
|
|
2086
|
+
return <PrivacyPanel settings={settings} update={update} />;
|
|
2087
|
+
case 'integrations':
|
|
2088
|
+
return <IntegrationsPanel />;
|
|
2089
|
+
case 'developer':
|
|
2090
|
+
return <DeveloperPanel settings={settings} update={update} />;
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
/* ------------------------------------------------------------------ *
|
|
2095
|
+
* Search
|
|
2096
|
+
* ------------------------------------------------------------------ */
|
|
2097
|
+
|
|
2098
|
+
/**
|
|
2099
|
+
* The query, the filtered results, and the keyboard traversal that drives
|
|
2100
|
+
* `aria-activedescendant`. Split out of the dialog so the hub reads as wiring.
|
|
2101
|
+
*/
|
|
2102
|
+
function useSettingsSearch(onSelectPanel: (panel: PanelId) => void) {
|
|
2103
|
+
const [query, setQuery] = useState('');
|
|
2104
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
2105
|
+
|
|
2106
|
+
const normalized = query.trim().toLowerCase();
|
|
2107
|
+
const isActive = normalized.length > 0;
|
|
2108
|
+
|
|
2109
|
+
const results = useMemo(
|
|
2110
|
+
() =>
|
|
2111
|
+
isActive
|
|
2112
|
+
? SEARCHABLE_SETTINGS.filter(setting =>
|
|
2113
|
+
settingMatchesSearch(setting, normalized),
|
|
2114
|
+
)
|
|
2115
|
+
: [],
|
|
2116
|
+
[isActive, normalized],
|
|
2117
|
+
);
|
|
2118
|
+
|
|
2119
|
+
useEffect(() => {
|
|
2120
|
+
setActiveIndex(0);
|
|
2121
|
+
}, [normalized]);
|
|
2122
|
+
|
|
2123
|
+
const onKeyDown: ComponentProps<'input'>['onKeyDown'] = event => {
|
|
2124
|
+
if (event.key === 'Escape' && query) {
|
|
2125
|
+
// Clear before Escape reaches the Dialog, so the first press empties the
|
|
2126
|
+
// box and only the second closes the hub.
|
|
2127
|
+
event.preventDefault();
|
|
2128
|
+
setQuery('');
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
if (!isActive || results.length === 0) {
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
if (event.key === 'ArrowDown') {
|
|
2135
|
+
event.preventDefault();
|
|
2136
|
+
setActiveIndex(index => (index + 1) % results.length);
|
|
2137
|
+
} else if (event.key === 'ArrowUp') {
|
|
2138
|
+
event.preventDefault();
|
|
2139
|
+
setActiveIndex(index => (index - 1 + results.length) % results.length);
|
|
2140
|
+
} else if (event.key === 'Enter') {
|
|
2141
|
+
event.preventDefault();
|
|
2142
|
+
const result = results[activeIndex];
|
|
2143
|
+
if (result) {
|
|
2144
|
+
onSelectPanel(result.panel);
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
};
|
|
2148
|
+
|
|
2149
|
+
return {
|
|
2150
|
+
query,
|
|
2151
|
+
setQuery,
|
|
2152
|
+
isActive,
|
|
2153
|
+
results,
|
|
2154
|
+
activeIndex,
|
|
2155
|
+
setActiveIndex,
|
|
2156
|
+
onKeyDown,
|
|
2157
|
+
activeResultId:
|
|
2158
|
+
isActive && results.length > 0
|
|
2159
|
+
? `settings-search-result-${activeIndex}`
|
|
2160
|
+
: undefined,
|
|
2161
|
+
};
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
type SettingsSearch = ReturnType<typeof useSettingsSearch>;
|
|
2165
|
+
|
|
2166
|
+
function SettingsSearchResults({
|
|
2167
|
+
results,
|
|
2168
|
+
activeIndex,
|
|
2169
|
+
onActiveIndexChange,
|
|
2170
|
+
onSelect,
|
|
2171
|
+
}: {
|
|
2172
|
+
results: SearchableSetting[];
|
|
2173
|
+
activeIndex: number;
|
|
2174
|
+
onActiveIndexChange: (index: number) => void;
|
|
2175
|
+
onSelect: (setting: SearchableSetting) => void;
|
|
2176
|
+
}) {
|
|
2177
|
+
if (results.length === 0) {
|
|
2178
|
+
return (
|
|
2179
|
+
<VStack id="settings-search-results" gap={0.5} padding={2}>
|
|
2180
|
+
<Text type="label">No settings found</Text>
|
|
2181
|
+
<Text type="supporting" color="secondary">
|
|
2182
|
+
Try theme, notifications, or sessions.
|
|
2183
|
+
</Text>
|
|
2184
|
+
</VStack>
|
|
2185
|
+
);
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
// `ListItem` rather than `SideNavItem`: these are listbox options, not
|
|
2189
|
+
// navigation, and a result has to say which panel it lives on — which
|
|
2190
|
+
// `SideNavItem` has no slot for.
|
|
2191
|
+
//
|
|
2192
|
+
// The container is a plain `VStack as="ul"` and NOT `List`, which destructures
|
|
2193
|
+
// a fixed prop set and never spreads the rest: `id`, `role="listbox"` and
|
|
2194
|
+
// `aria-label` would vanish with no type error and no runtime warning, and
|
|
2195
|
+
// the input's `aria-controls` would point at nothing.
|
|
2196
|
+
return (
|
|
2197
|
+
<VStack
|
|
2198
|
+
as="ul"
|
|
2199
|
+
gap={0}
|
|
2200
|
+
id="settings-search-results"
|
|
2201
|
+
role="listbox"
|
|
2202
|
+
aria-label="Search results"
|
|
2203
|
+
isScrollable>
|
|
2204
|
+
{results.map((setting, index) => (
|
|
2205
|
+
<ListItem
|
|
2206
|
+
key={`${setting.panel}-${setting.title}`}
|
|
2207
|
+
id={`settings-search-result-${index}`}
|
|
2208
|
+
role="option"
|
|
2209
|
+
aria-selected={index === activeIndex}
|
|
2210
|
+
label={setting.title}
|
|
2211
|
+
description={`${panelConfig(setting.panel).label} · ${
|
|
2212
|
+
setting.description
|
|
2213
|
+
}`}
|
|
2214
|
+
startContent={
|
|
2215
|
+
<Icon icon={setting.icon} size="sm" color="secondary" />
|
|
2216
|
+
}
|
|
2217
|
+
endContent={
|
|
2218
|
+
<Icon icon={ChevronRightIcon} size="sm" color="secondary" />
|
|
2219
|
+
}
|
|
2220
|
+
isSelected={index === activeIndex}
|
|
2221
|
+
onMouseEnter={() => onActiveIndexChange(index)}
|
|
2222
|
+
onClick={() => onSelect(setting)}
|
|
2223
|
+
/>
|
|
2224
|
+
))}
|
|
2225
|
+
</VStack>
|
|
2226
|
+
);
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
/**
|
|
2230
|
+
* The search box, shared by both shells. `hasClear` gives the clear button and
|
|
2231
|
+
* its live status for free; only the RESULTS list is hand-built.
|
|
2232
|
+
*/
|
|
2233
|
+
function SettingsSearchInput({search}: {search: SettingsSearch}) {
|
|
2234
|
+
return (
|
|
2235
|
+
<TextInput
|
|
2236
|
+
label="Search settings"
|
|
2237
|
+
isLabelHidden
|
|
2238
|
+
placeholder="Search settings"
|
|
2239
|
+
size="sm"
|
|
2240
|
+
// Search is a region control, not a settings-row control. The row fields
|
|
2241
|
+
// deliberately share `CONTROL_WIDTH`; applying that same 192px budget
|
|
2242
|
+
// here left a dead strip in SideNav's wider `topContent` region and made
|
|
2243
|
+
// the search look detached from the navigation it filters.
|
|
2244
|
+
width="100%"
|
|
2245
|
+
hasClear
|
|
2246
|
+
// The bare component, NOT `<Icon icon={...} />`: `TextInput` tints an icon
|
|
2247
|
+
// it renders itself, and returns an already-rendered element verbatim —
|
|
2248
|
+
// which leaves the magnifier a step darker than the placeholder beside it.
|
|
2249
|
+
startIcon={MagnifyingGlassIcon}
|
|
2250
|
+
role="combobox"
|
|
2251
|
+
aria-autocomplete="list"
|
|
2252
|
+
aria-controls="settings-search-results"
|
|
2253
|
+
aria-expanded={search.isActive ? search.results.length > 0 : false}
|
|
2254
|
+
aria-activedescendant={search.activeResultId}
|
|
2255
|
+
value={search.query}
|
|
2256
|
+
onChange={search.setQuery}
|
|
2257
|
+
onKeyDown={search.onKeyDown}
|
|
2258
|
+
/>
|
|
2259
|
+
);
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
/* ------------------------------------------------------------------ *
|
|
2263
|
+
* Shells
|
|
2264
|
+
* ------------------------------------------------------------------ */
|
|
2265
|
+
|
|
2266
|
+
/**
|
|
2267
|
+
* The whole left column: title, search box, and either results or the nav.
|
|
2268
|
+
* Desktop only — phone width renders `SettingsNarrowShell` instead, so there is
|
|
2269
|
+
* deliberately no collapsed state to configure here.
|
|
2270
|
+
*/
|
|
2271
|
+
function SettingsSideNav({
|
|
2272
|
+
titleId,
|
|
2273
|
+
search,
|
|
2274
|
+
activePanel,
|
|
2275
|
+
onSelectPanel,
|
|
2276
|
+
}: {
|
|
2277
|
+
titleId: string;
|
|
2278
|
+
search: SettingsSearch;
|
|
2279
|
+
activePanel: PanelId;
|
|
2280
|
+
onSelectPanel: (panel: PanelId) => void;
|
|
2281
|
+
}) {
|
|
2282
|
+
return (
|
|
2283
|
+
<SideNav
|
|
2284
|
+
aria-label="Settings sections"
|
|
2285
|
+
xstyle={styles.sideNav}
|
|
2286
|
+
// Hand-composed rather than `SideNavHeading`, whose `heading` is typed
|
|
2287
|
+
// `string` and pinned to the large text size — a step above heading-4,
|
|
2288
|
+
// with no prop to reach the heading scale. The inset and row height here
|
|
2289
|
+
// reproduce `SideNavHeading`'s own, so the title stays aligned with the
|
|
2290
|
+
// nav labels below it.
|
|
2291
|
+
header={
|
|
2292
|
+
<HStack gap={2} align="center" paddingInline={2} minHeight={32}>
|
|
2293
|
+
{/* Also the dialog's accessible name (`aria-labelledby`), so the
|
|
2294
|
+
painted title and the announced one cannot drift apart. */}
|
|
2295
|
+
<Heading level={4} id={titleId}>
|
|
2296
|
+
Settings
|
|
2297
|
+
</Heading>
|
|
183
2298
|
</HStack>
|
|
2299
|
+
}
|
|
2300
|
+
topContent={<SettingsSearchInput search={search} />}>
|
|
2301
|
+
{search.isActive ? (
|
|
2302
|
+
<SettingsSearchResults
|
|
2303
|
+
results={search.results}
|
|
2304
|
+
activeIndex={search.activeIndex}
|
|
2305
|
+
onActiveIndexChange={search.setActiveIndex}
|
|
2306
|
+
onSelect={setting => onSelectPanel(setting.panel)}
|
|
2307
|
+
/>
|
|
2308
|
+
) : (
|
|
2309
|
+
PANEL_GROUPS.map(group => (
|
|
2310
|
+
<SideNavSection
|
|
2311
|
+
key={group.label}
|
|
2312
|
+
title={group.label}
|
|
2313
|
+
xstyle={styles.sideNavSection}>
|
|
2314
|
+
{group.panels.map(panel => (
|
|
2315
|
+
// Single-line by construction: `SideNavItem` has no description.
|
|
2316
|
+
// The same text is the content pane's subtitle, so nothing leaves
|
|
2317
|
+
// the surface.
|
|
2318
|
+
<SideNavItem
|
|
2319
|
+
key={panel.id}
|
|
2320
|
+
label={panel.label}
|
|
2321
|
+
// Pre-rendered, not `icon={panel.icon}`: `SideNavItem` tints an
|
|
2322
|
+
// icon it renders itself `secondary` unless the item is
|
|
2323
|
+
// selected, leaving every unselected glyph a step dimmer than
|
|
2324
|
+
// its own label.
|
|
2325
|
+
icon={<Icon icon={panel.icon} size="sm" color="primary" />}
|
|
2326
|
+
isSelected={panel.id === activePanel}
|
|
2327
|
+
onClick={() => onSelectPanel(panel.id)}
|
|
2328
|
+
/>
|
|
2329
|
+
))}
|
|
2330
|
+
</SideNavSection>
|
|
2331
|
+
))
|
|
184
2332
|
)}
|
|
185
|
-
|
|
186
|
-
</>
|
|
2333
|
+
</SideNav>
|
|
187
2334
|
);
|
|
188
2335
|
}
|
|
189
2336
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
2337
|
+
/** The panel's own heading — the content title, in both shells. */
|
|
2338
|
+
function SettingsPanelHeading({panel}: {panel: PanelId}) {
|
|
2339
|
+
const config = panelConfig(panel);
|
|
2340
|
+
return (
|
|
2341
|
+
<VStack gap={0.5}>
|
|
2342
|
+
<Heading level={2}>{config.label}</Heading>
|
|
2343
|
+
<Text type="supporting" color="secondary">
|
|
2344
|
+
{config.description}
|
|
2345
|
+
</Text>
|
|
2346
|
+
</VStack>
|
|
2347
|
+
);
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
/**
|
|
2351
|
+
* The right column: the close control pinned to the pane's corner, with the
|
|
2352
|
+
* panel's heading and body scrolling under it.
|
|
2353
|
+
*/
|
|
2354
|
+
function SettingsContentPane({
|
|
2355
|
+
panel,
|
|
2356
|
+
settings,
|
|
2357
|
+
update,
|
|
2358
|
+
onClose,
|
|
194
2359
|
}: {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
2360
|
+
panel: PanelId;
|
|
2361
|
+
onClose: () => void;
|
|
2362
|
+
} & PanelProps) {
|
|
2363
|
+
return (
|
|
2364
|
+
<LayoutContent isScrollable padding={4}>
|
|
2365
|
+
{/* `width="100%"` is load-bearing. A vertical Stack is only as wide
|
|
2366
|
+
as its contents in this position; without the width the sticky box
|
|
2367
|
+
collapsed to the close button, so `justify="end"` had no space to
|
|
2368
|
+
traverse and the control landed over the panel title instead of the
|
|
2369
|
+
pane's top-right corner. */}
|
|
2370
|
+
<HStack width="100%" justify="end" xstyle={styles.closeAnchor}>
|
|
2371
|
+
<HStack xstyle={styles.closeBacking}>
|
|
2372
|
+
<IconButton
|
|
2373
|
+
label="Close"
|
|
2374
|
+
variant="ghost"
|
|
2375
|
+
size="sm"
|
|
2376
|
+
icon={<Icon icon="close" size="sm" />}
|
|
2377
|
+
onClick={onClose}
|
|
2378
|
+
/>
|
|
2379
|
+
</HStack>
|
|
2380
|
+
</HStack>
|
|
2381
|
+
{/* The panel column: `@container`, so every row inside it stacks on the
|
|
2382
|
+
width the column actually got rather than on the viewport's — see
|
|
2383
|
+
`ROW_STACK`. */}
|
|
2384
|
+
<VStack gap={4} xstyle={row.panel}>
|
|
2385
|
+
<HStack gap={2} align="start">
|
|
2386
|
+
<StackItem size="fill">
|
|
2387
|
+
<SettingsPanelHeading panel={panel} />
|
|
2388
|
+
</StackItem>
|
|
2389
|
+
{/* Holds the column the pinned control floats over, so a long panel
|
|
2390
|
+
description wraps at the button rather than running under it.
|
|
2391
|
+
Presentational only — the control itself is above, in the DOM
|
|
2392
|
+
order a keyboard reaches it in. */}
|
|
2393
|
+
<VStack aria-hidden="true" width={32} />
|
|
2394
|
+
</HStack>
|
|
2395
|
+
<SettingsPanelBody panel={panel} settings={settings} update={update} />
|
|
2396
|
+
</VStack>
|
|
2397
|
+
</LayoutContent>
|
|
2398
|
+
);
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
/**
|
|
2402
|
+
* The narrow-viewport panel picker: the same navigation laid on its side as a
|
|
2403
|
+
* scrolling strip of tabs.
|
|
2404
|
+
*/
|
|
2405
|
+
function SettingsPanelTabs({
|
|
2406
|
+
activePanel,
|
|
2407
|
+
onSelectPanel,
|
|
2408
|
+
}: {
|
|
2409
|
+
activePanel: PanelId;
|
|
2410
|
+
onSelectPanel: (panel: PanelId) => void;
|
|
198
2411
|
}) {
|
|
199
2412
|
return (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
2413
|
+
<HStack
|
|
2414
|
+
as="nav"
|
|
2415
|
+
aria-label="Settings sections"
|
|
2416
|
+
gap={1}
|
|
2417
|
+
wrap="nowrap"
|
|
2418
|
+
isScrollable
|
|
2419
|
+
paddingInline={3}
|
|
2420
|
+
paddingBlock={2}>
|
|
2421
|
+
{PANELS.map(panel => (
|
|
2422
|
+
<Button
|
|
2423
|
+
key={panel.id}
|
|
2424
|
+
label={panel.label}
|
|
2425
|
+
icon={<Icon icon={panel.icon} size="sm" />}
|
|
2426
|
+
variant={panel.id === activePanel ? 'secondary' : 'ghost'}
|
|
2427
|
+
size="sm"
|
|
2428
|
+
onClick={() => onSelectPanel(panel.id)}
|
|
2429
|
+
/>
|
|
2430
|
+
))}
|
|
2431
|
+
</HStack>
|
|
2432
|
+
);
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
/**
|
|
2436
|
+
* The whole dialog at phone width: title row, full-width search, the tab strip,
|
|
2437
|
+
* then the panel. One column, because a 351px dialog has no room for two.
|
|
2438
|
+
*/
|
|
2439
|
+
function SettingsNarrowShell({
|
|
2440
|
+
titleId,
|
|
2441
|
+
search,
|
|
2442
|
+
activePanel,
|
|
2443
|
+
settings,
|
|
2444
|
+
update,
|
|
2445
|
+
onSelectPanel,
|
|
2446
|
+
onClose,
|
|
2447
|
+
}: {
|
|
2448
|
+
titleId: string;
|
|
2449
|
+
search: SettingsSearch;
|
|
2450
|
+
activePanel: PanelId;
|
|
2451
|
+
onSelectPanel: (panel: PanelId) => void;
|
|
2452
|
+
onClose: () => void;
|
|
2453
|
+
} & PanelProps) {
|
|
2454
|
+
return (
|
|
2455
|
+
<VStack gap={0} height="100%">
|
|
2456
|
+
<HStack gap={2} align="center" paddingInline={3} paddingBlock={3}>
|
|
2457
|
+
<StackItem size="fill">
|
|
2458
|
+
{/* The element `aria-labelledby` points at in this shell. A title row,
|
|
2459
|
+
not the head of a navigation column, so it stays a label — the
|
|
2460
|
+
phone layout's only heading should be the panel's own. */}
|
|
2461
|
+
<Text type="label" id={titleId}>
|
|
2462
|
+
Settings
|
|
208
2463
|
</Text>
|
|
209
|
-
</
|
|
210
|
-
|
|
2464
|
+
</StackItem>
|
|
2465
|
+
<IconButton
|
|
2466
|
+
label="Close"
|
|
2467
|
+
variant="ghost"
|
|
2468
|
+
size="sm"
|
|
2469
|
+
icon={<Icon icon="close" size="sm" />}
|
|
2470
|
+
onClick={onClose}
|
|
2471
|
+
/>
|
|
211
2472
|
</HStack>
|
|
212
2473
|
<Divider />
|
|
213
|
-
|
|
2474
|
+
<VStack paddingInline={3} paddingBlock={2}>
|
|
2475
|
+
<SettingsSearchInput search={search} />
|
|
2476
|
+
</VStack>
|
|
2477
|
+
{search.isActive ? (
|
|
2478
|
+
<VStack paddingInline={3} isScrollable>
|
|
2479
|
+
<SettingsSearchResults
|
|
2480
|
+
results={search.results}
|
|
2481
|
+
activeIndex={search.activeIndex}
|
|
2482
|
+
onActiveIndexChange={search.setActiveIndex}
|
|
2483
|
+
onSelect={setting => onSelectPanel(setting.panel)}
|
|
2484
|
+
/>
|
|
2485
|
+
</VStack>
|
|
2486
|
+
) : (
|
|
2487
|
+
<>
|
|
2488
|
+
<SettingsPanelTabs
|
|
2489
|
+
activePanel={activePanel}
|
|
2490
|
+
onSelectPanel={onSelectPanel}
|
|
2491
|
+
/>
|
|
2492
|
+
<Divider />
|
|
2493
|
+
<StackItem size="fill">
|
|
2494
|
+
{/* Same `@container` as the desktop pane, so a row stacks on the
|
|
2495
|
+
width it was given in either shell. */}
|
|
2496
|
+
<VStack
|
|
2497
|
+
gap={4}
|
|
2498
|
+
padding={4}
|
|
2499
|
+
isScrollable
|
|
2500
|
+
height="100%"
|
|
2501
|
+
xstyle={row.panel}>
|
|
2502
|
+
<SettingsPanelHeading panel={activePanel} />
|
|
2503
|
+
<SettingsPanelBody
|
|
2504
|
+
panel={activePanel}
|
|
2505
|
+
settings={settings}
|
|
2506
|
+
update={update}
|
|
2507
|
+
/>
|
|
2508
|
+
</VStack>
|
|
2509
|
+
</StackItem>
|
|
2510
|
+
</>
|
|
2511
|
+
)}
|
|
2512
|
+
</VStack>
|
|
214
2513
|
);
|
|
215
2514
|
}
|
|
216
2515
|
|
|
2516
|
+
// Remove isInline for production — dialogs should be modal.
|
|
217
2517
|
export default function SettingsDialogTemplate() {
|
|
218
|
-
const [
|
|
219
|
-
const [
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
const [language, setLanguage] = useState('en-CA');
|
|
223
|
-
const [currency, setCurrency] = useState('CAD');
|
|
224
|
-
const [timezone, setTimezone] = useState('ET');
|
|
225
|
-
const [activeTab, setActiveTab] = useState('login');
|
|
226
|
-
|
|
227
|
-
const [legalName, setLegalName] = useState('Alex Johnson');
|
|
228
|
-
const [preferredName, setPreferredName] = useState('');
|
|
229
|
-
const [email, setEmail] = useState('a***n@example.com');
|
|
230
|
-
const [phone, setPhone] = useState('+1 ***-***-0123');
|
|
231
|
-
const [address, setAddress] = useState('');
|
|
232
|
-
const [mailingAddress, setMailingAddress] = useState('');
|
|
233
|
-
const [emergencyContact, setEmergencyContact] = useState('Provided');
|
|
234
|
-
const [readReceipts, setReadReceipts] = useState(true);
|
|
235
|
-
const [searchEngines, setSearchEngines] = useState(true);
|
|
236
|
-
const [showCity, setShowCity] = useState(true);
|
|
237
|
-
const [showTripType, setShowTripType] = useState(true);
|
|
238
|
-
const [showStayLength, setShowStayLength] = useState(true);
|
|
239
|
-
const [showServices, setShowServices] = useState(true);
|
|
240
|
-
const [aiFeatures, setAiFeatures] = useState(true);
|
|
241
|
-
|
|
242
|
-
const handleEdit = (row: string) => setExpandedRow(row);
|
|
243
|
-
const handleCancel = () => setExpandedRow(null);
|
|
244
|
-
const handleSave = () => setExpandedRow(null);
|
|
2518
|
+
const [activePanel, setActivePanel] = useState<PanelId>(DEFAULT_PANEL);
|
|
2519
|
+
const [settings, setSettings] = useState<SettingsState>(INITIAL_SETTINGS);
|
|
2520
|
+
const titleId = useId();
|
|
245
2521
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
variant="primary"
|
|
255
|
-
onClick={() => setIsOpen(true)}
|
|
256
|
-
/>
|
|
257
|
-
</Center>
|
|
258
|
-
</LayoutContent>
|
|
259
|
-
}
|
|
260
|
-
/>
|
|
2522
|
+
// Below this the two-column shell is swapped for `SettingsNarrowShell`. NOT a
|
|
2523
|
+
// collapsed rail — `SettingsPanelTabs` records why that does not survive a
|
|
2524
|
+
// phone-width dialog.
|
|
2525
|
+
const isNarrow = useMediaQuery(NARROW_VIEWPORT);
|
|
2526
|
+
|
|
2527
|
+
const update: UpdateSetting = (key, value) => {
|
|
2528
|
+
setSettings(current => ({...current, [key]: value}));
|
|
2529
|
+
};
|
|
261
2530
|
|
|
2531
|
+
const search = useSettingsSearch(panel => {
|
|
2532
|
+
selectPanel(panel);
|
|
2533
|
+
});
|
|
2534
|
+
|
|
2535
|
+
const selectPanel = (panel: PanelId) => {
|
|
2536
|
+
setActivePanel(panel);
|
|
2537
|
+
search.setQuery('');
|
|
2538
|
+
};
|
|
2539
|
+
|
|
2540
|
+
return (
|
|
2541
|
+
// `Center` is the page root — the rubric's root for a content page, and
|
|
2542
|
+
// what puts the inline dialog in the middle of the page it stands in for.
|
|
2543
|
+
<Center height="100dvh">
|
|
262
2544
|
<Dialog
|
|
263
|
-
isOpen
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
2545
|
+
isOpen
|
|
2546
|
+
// `isInline` renders this same dialog WITHOUT the `<dialog>` element, so
|
|
2547
|
+
// the page can be a thumbnail and a preview. A real modal cannot be
|
|
2548
|
+
// either: it is promoted to the browser's top layer, which no ancestor
|
|
2549
|
+
// transform or `overflow: hidden` can clip — an open one inside a scaled
|
|
2550
|
+
// preview tile paints over the whole gallery instead of inside its tile.
|
|
2551
|
+
// The docsite's own preview is also a Dialog, which a modal here would
|
|
2552
|
+
// then nest inside, and Astryx forbids nesting dialogs.
|
|
2553
|
+
//
|
|
2554
|
+
// Everything else on this page is the composition you want: drop
|
|
2555
|
+
// `isInline`, drive `isOpen` from state, hand `onOpenChange` the setter,
|
|
2556
|
+
// and point the close control at it, and this is a modal.
|
|
2557
|
+
isInline
|
|
2558
|
+
onOpenChange={() => {}}
|
|
268
2559
|
purpose="form"
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
activeNav === 'Personal information'
|
|
310
|
-
? 'Personal info'
|
|
311
|
-
: activeNav
|
|
312
|
-
}
|
|
313
|
-
onOpenChange={open => setIsOpen(open)}
|
|
314
|
-
hasDivider={false}
|
|
315
|
-
/>
|
|
316
|
-
</VStack>
|
|
317
|
-
<VStack gap={0} style={contentMaxWidth}>
|
|
318
|
-
{activeNav === 'Personal information' && (
|
|
319
|
-
<VStack gap={6}>
|
|
320
|
-
<VStack gap={4}>
|
|
321
|
-
<ExpandableRow
|
|
322
|
-
label="Legal name"
|
|
323
|
-
value={legalName}
|
|
324
|
-
isExpanded={expandedRow === 'legalName'}
|
|
325
|
-
onEdit={() => handleEdit('legalName')}
|
|
326
|
-
onCancel={handleCancel}
|
|
327
|
-
onSave={handleSave}>
|
|
328
|
-
<TextInput
|
|
329
|
-
label="Legal name"
|
|
330
|
-
isLabelHidden
|
|
331
|
-
value={legalName}
|
|
332
|
-
onChange={setLegalName}
|
|
333
|
-
/>
|
|
334
|
-
</ExpandableRow>
|
|
335
|
-
<ExpandableRow
|
|
336
|
-
label="Preferred first name"
|
|
337
|
-
value={preferredName || 'Not provided'}
|
|
338
|
-
isExpanded={expandedRow === 'preferredName'}
|
|
339
|
-
onEdit={() => handleEdit('preferredName')}
|
|
340
|
-
onCancel={handleCancel}
|
|
341
|
-
onSave={handleSave}>
|
|
342
|
-
<TextInput
|
|
343
|
-
label="Preferred first name"
|
|
344
|
-
isLabelHidden
|
|
345
|
-
value={preferredName}
|
|
346
|
-
onChange={setPreferredName}
|
|
347
|
-
/>
|
|
348
|
-
</ExpandableRow>
|
|
349
|
-
<ExpandableRow
|
|
350
|
-
label="Email address"
|
|
351
|
-
value={email}
|
|
352
|
-
isExpanded={expandedRow === 'email'}
|
|
353
|
-
onEdit={() => handleEdit('email')}
|
|
354
|
-
onCancel={handleCancel}
|
|
355
|
-
onSave={handleSave}>
|
|
356
|
-
<TextInput
|
|
357
|
-
label="Email address"
|
|
358
|
-
isLabelHidden
|
|
359
|
-
value={email}
|
|
360
|
-
onChange={setEmail}
|
|
361
|
-
/>
|
|
362
|
-
</ExpandableRow>
|
|
363
|
-
<ExpandableRow
|
|
364
|
-
label="Phone number"
|
|
365
|
-
value={phone}
|
|
366
|
-
isExpanded={expandedRow === 'phone'}
|
|
367
|
-
onEdit={() => handleEdit('phone')}
|
|
368
|
-
onCancel={handleCancel}
|
|
369
|
-
onSave={handleSave}>
|
|
370
|
-
<TextInput
|
|
371
|
-
label="Phone number"
|
|
372
|
-
isLabelHidden
|
|
373
|
-
value={phone}
|
|
374
|
-
onChange={setPhone}
|
|
375
|
-
/>
|
|
376
|
-
</ExpandableRow>
|
|
377
|
-
<InfoRowItem
|
|
378
|
-
label="Identity verification"
|
|
379
|
-
value="Verified"
|
|
380
|
-
action=""
|
|
381
|
-
/>
|
|
382
|
-
<ExpandableRow
|
|
383
|
-
label="Residential address"
|
|
384
|
-
value={address || 'Not provided'}
|
|
385
|
-
isExpanded={expandedRow === 'address'}
|
|
386
|
-
onEdit={() => handleEdit('address')}
|
|
387
|
-
onCancel={handleCancel}
|
|
388
|
-
onSave={handleSave}>
|
|
389
|
-
<TextInput
|
|
390
|
-
label="Residential address"
|
|
391
|
-
isLabelHidden
|
|
392
|
-
value={address}
|
|
393
|
-
onChange={setAddress}
|
|
394
|
-
/>
|
|
395
|
-
</ExpandableRow>
|
|
396
|
-
<ExpandableRow
|
|
397
|
-
label="Mailing address"
|
|
398
|
-
value={mailingAddress || 'Not provided'}
|
|
399
|
-
isExpanded={expandedRow === 'mailingAddress'}
|
|
400
|
-
onEdit={() => handleEdit('mailingAddress')}
|
|
401
|
-
onCancel={handleCancel}
|
|
402
|
-
onSave={handleSave}>
|
|
403
|
-
<TextInput
|
|
404
|
-
label="Mailing address"
|
|
405
|
-
isLabelHidden
|
|
406
|
-
value={mailingAddress}
|
|
407
|
-
onChange={setMailingAddress}
|
|
408
|
-
/>
|
|
409
|
-
</ExpandableRow>
|
|
410
|
-
<ExpandableRow
|
|
411
|
-
label="Emergency contact"
|
|
412
|
-
value={emergencyContact}
|
|
413
|
-
isExpanded={expandedRow === 'emergencyContact'}
|
|
414
|
-
onEdit={() => handleEdit('emergencyContact')}
|
|
415
|
-
onCancel={handleCancel}
|
|
416
|
-
onSave={handleSave}>
|
|
417
|
-
<TextInput
|
|
418
|
-
label="Emergency contact"
|
|
419
|
-
isLabelHidden
|
|
420
|
-
value={emergencyContact}
|
|
421
|
-
onChange={setEmergencyContact}
|
|
422
|
-
/>
|
|
423
|
-
</ExpandableRow>
|
|
424
|
-
</VStack>
|
|
425
|
-
|
|
426
|
-
<Card padding={4}>
|
|
427
|
-
<VStack gap={4}>
|
|
428
|
-
<HStack gap={3} vAlign="start">
|
|
429
|
-
<Center width={48} height={48} style={iconBox}>
|
|
430
|
-
<Icon icon={LockClosedIcon} />
|
|
431
|
-
</Center>
|
|
432
|
-
<VStack gap={0}>
|
|
433
|
-
<Text
|
|
434
|
-
type="body"
|
|
435
|
-
weight="semibold"
|
|
436
|
-
display="block">
|
|
437
|
-
Why isn't my info shown here?
|
|
438
|
-
</Text>
|
|
439
|
-
<Text
|
|
440
|
-
type="supporting"
|
|
441
|
-
color="secondary"
|
|
442
|
-
display="block">
|
|
443
|
-
We're hiding some account details to
|
|
444
|
-
protect your identity.
|
|
445
|
-
</Text>
|
|
446
|
-
</VStack>
|
|
447
|
-
</HStack>
|
|
448
|
-
<Divider />
|
|
449
|
-
<HStack gap={3} vAlign="start">
|
|
450
|
-
<Center width={48} height={48} style={iconBox}>
|
|
451
|
-
<Icon icon={PencilSquareIcon} />
|
|
452
|
-
</Center>
|
|
453
|
-
<VStack gap={0}>
|
|
454
|
-
<Text
|
|
455
|
-
type="body"
|
|
456
|
-
weight="semibold"
|
|
457
|
-
display="block">
|
|
458
|
-
Which details can be edited?
|
|
459
|
-
</Text>
|
|
460
|
-
<Text
|
|
461
|
-
type="supporting"
|
|
462
|
-
color="secondary"
|
|
463
|
-
display="block">
|
|
464
|
-
Contact info and personal details can be edited.
|
|
465
|
-
If this info was used to verify your identity,
|
|
466
|
-
you'll need to get verified again the next
|
|
467
|
-
time you book—or to continue hosting.
|
|
468
|
-
</Text>
|
|
469
|
-
</VStack>
|
|
470
|
-
</HStack>
|
|
471
|
-
<Divider />
|
|
472
|
-
<HStack gap={3} vAlign="start">
|
|
473
|
-
<Center width={48} height={48} style={iconBox}>
|
|
474
|
-
<Icon icon={ShareIcon} />
|
|
475
|
-
</Center>
|
|
476
|
-
<VStack gap={0}>
|
|
477
|
-
<Text
|
|
478
|
-
type="body"
|
|
479
|
-
weight="semibold"
|
|
480
|
-
display="block">
|
|
481
|
-
What info is shared with others?
|
|
482
|
-
</Text>
|
|
483
|
-
<Text
|
|
484
|
-
type="supporting"
|
|
485
|
-
color="secondary"
|
|
486
|
-
display="block">
|
|
487
|
-
We only release contact information after a
|
|
488
|
-
reservation is confirmed.
|
|
489
|
-
</Text>
|
|
490
|
-
</VStack>
|
|
491
|
-
</HStack>
|
|
492
|
-
</VStack>
|
|
493
|
-
</Card>
|
|
494
|
-
</VStack>
|
|
495
|
-
)}
|
|
496
|
-
|
|
497
|
-
{activeNav === 'Login & security' && (
|
|
498
|
-
<VStack gap={6}>
|
|
499
|
-
<TabList
|
|
500
|
-
value={activeTab}
|
|
501
|
-
onChange={setActiveTab}
|
|
502
|
-
hasDivider>
|
|
503
|
-
<Tab value="login" label="Login" />
|
|
504
|
-
<Tab value="shared" label="Shared access" />
|
|
505
|
-
</TabList>
|
|
506
|
-
|
|
507
|
-
{activeTab === 'login' && (
|
|
508
|
-
<VStack gap={8}>
|
|
509
|
-
<VStack gap={4}>
|
|
510
|
-
<Heading level={3}>Login</Heading>
|
|
511
|
-
<Divider />
|
|
512
|
-
{LOGIN_ROWS.map(row => (
|
|
513
|
-
<InfoRowItem key={row.label} {...row} />
|
|
514
|
-
))}
|
|
515
|
-
</VStack>
|
|
516
|
-
|
|
517
|
-
<VStack gap={4}>
|
|
518
|
-
<Heading level={3}>Social accounts</Heading>
|
|
519
|
-
<Divider />
|
|
520
|
-
{SOCIAL_ROWS.map(row => (
|
|
521
|
-
<InfoRowItem key={row.label} {...row} />
|
|
522
|
-
))}
|
|
523
|
-
</VStack>
|
|
524
|
-
|
|
525
|
-
<VStack gap={4}>
|
|
526
|
-
<Heading level={3}>Device history</Heading>
|
|
527
|
-
<Divider />
|
|
528
|
-
{DEVICE_ROWS.map((device, i) => (
|
|
529
|
-
<React.Fragment key={i}>
|
|
530
|
-
<HStack gap={3} vAlign="start">
|
|
531
|
-
<Icon icon={ComputerDesktopIcon} />
|
|
532
|
-
<StackItem size="fill">
|
|
533
|
-
<VStack gap={0}>
|
|
534
|
-
<HStack gap={2} vAlign="center">
|
|
535
|
-
<Text type="body" weight="semibold">
|
|
536
|
-
{device.label}
|
|
537
|
-
</Text>
|
|
538
|
-
{device.badge && (
|
|
539
|
-
<Badge label={device.badge} />
|
|
540
|
-
)}
|
|
541
|
-
</HStack>
|
|
542
|
-
<Text
|
|
543
|
-
type="supporting"
|
|
544
|
-
color="secondary"
|
|
545
|
-
display="block">
|
|
546
|
-
{device.location}
|
|
547
|
-
</Text>
|
|
548
|
-
</VStack>
|
|
549
|
-
</StackItem>
|
|
550
|
-
{device.action && (
|
|
551
|
-
<Link href="#">{device.action}</Link>
|
|
552
|
-
)}
|
|
553
|
-
</HStack>
|
|
554
|
-
<Divider />
|
|
555
|
-
</React.Fragment>
|
|
556
|
-
))}
|
|
557
|
-
</VStack>
|
|
558
|
-
|
|
559
|
-
<VStack gap={4}>
|
|
560
|
-
<Heading level={3}>Account</Heading>
|
|
561
|
-
<Divider />
|
|
562
|
-
<HStack hAlign="between" vAlign="start">
|
|
563
|
-
<VStack gap={0}>
|
|
564
|
-
<Text
|
|
565
|
-
type="body"
|
|
566
|
-
weight="semibold"
|
|
567
|
-
display="block">
|
|
568
|
-
Deactivate your account
|
|
569
|
-
</Text>
|
|
570
|
-
<Text
|
|
571
|
-
type="supporting"
|
|
572
|
-
color="secondary"
|
|
573
|
-
display="block">
|
|
574
|
-
This action cannot be undone
|
|
575
|
-
</Text>
|
|
576
|
-
</VStack>
|
|
577
|
-
<Link href="#">Deactivate</Link>
|
|
578
|
-
</HStack>
|
|
579
|
-
<Divider />
|
|
580
|
-
</VStack>
|
|
581
|
-
</VStack>
|
|
582
|
-
)}
|
|
583
|
-
|
|
584
|
-
{activeTab === 'shared' && (
|
|
585
|
-
<VStack gap={8}>
|
|
586
|
-
<VStack gap={2}>
|
|
587
|
-
<Heading level={3}>Shared access</Heading>
|
|
588
|
-
<Divider />
|
|
589
|
-
<Text type="body" color="secondary">
|
|
590
|
-
Review each request carefully before approving
|
|
591
|
-
access. We'll email your employee or
|
|
592
|
-
co-worker a 4-digit code that lets them log into
|
|
593
|
-
your account with their trusted device.
|
|
594
|
-
</Text>
|
|
595
|
-
</VStack>
|
|
596
|
-
|
|
597
|
-
<Card variant="muted">
|
|
598
|
-
<HStack gap={4} vAlign="start">
|
|
599
|
-
<Center width={48} height={48} style={iconBox}>
|
|
600
|
-
<Icon icon={LockClosedIcon} />
|
|
601
|
-
</Center>
|
|
602
|
-
<VStack gap={1}>
|
|
603
|
-
<Text type="body" weight="bold">
|
|
604
|
-
Adding devices from people you trust
|
|
605
|
-
</Text>
|
|
606
|
-
<Text type="body" color="secondary">
|
|
607
|
-
When you approve a request, you grant someone
|
|
608
|
-
full access to your account. They'll be
|
|
609
|
-
able to change reservations and send messages
|
|
610
|
-
on your behalf.
|
|
611
|
-
</Text>
|
|
612
|
-
</VStack>
|
|
613
|
-
</HStack>
|
|
614
|
-
</Card>
|
|
615
|
-
</VStack>
|
|
616
|
-
)}
|
|
617
|
-
</VStack>
|
|
618
|
-
)}
|
|
619
|
-
|
|
620
|
-
{activeNav === 'Languages & currency' && (
|
|
621
|
-
<VStack gap={6}>
|
|
622
|
-
<VStack gap={4}>
|
|
623
|
-
<ExpandableRow
|
|
624
|
-
label="Preferred language"
|
|
625
|
-
value={
|
|
626
|
-
LANGUAGES.find(l => l.value === language)?.label ??
|
|
627
|
-
language
|
|
628
|
-
}
|
|
629
|
-
isExpanded={expandedRow === 'language'}
|
|
630
|
-
onEdit={() => handleEdit('language')}
|
|
631
|
-
onCancel={handleCancel}
|
|
632
|
-
onSave={handleSave}>
|
|
633
|
-
<Selector
|
|
634
|
-
label="Language"
|
|
635
|
-
isLabelHidden
|
|
636
|
-
size="lg"
|
|
637
|
-
value={language}
|
|
638
|
-
onChange={setLanguage}
|
|
639
|
-
options={LANGUAGES}
|
|
640
|
-
/>
|
|
641
|
-
</ExpandableRow>
|
|
642
|
-
<ExpandableRow
|
|
643
|
-
label="Preferred currency"
|
|
644
|
-
value={
|
|
645
|
-
CURRENCIES.find(c => c.value === currency)?.label ??
|
|
646
|
-
currency
|
|
647
|
-
}
|
|
648
|
-
isExpanded={expandedRow === 'currency'}
|
|
649
|
-
onEdit={() => handleEdit('currency')}
|
|
650
|
-
onCancel={handleCancel}
|
|
651
|
-
onSave={handleSave}>
|
|
652
|
-
<Selector
|
|
653
|
-
label="Currency"
|
|
654
|
-
isLabelHidden
|
|
655
|
-
size="lg"
|
|
656
|
-
value={currency}
|
|
657
|
-
onChange={setCurrency}
|
|
658
|
-
options={CURRENCIES}
|
|
659
|
-
/>
|
|
660
|
-
</ExpandableRow>
|
|
661
|
-
<ExpandableRow
|
|
662
|
-
label="Time zone"
|
|
663
|
-
value={
|
|
664
|
-
TIMEZONES.find(t => t.value === timezone)?.label ??
|
|
665
|
-
timezone
|
|
666
|
-
}
|
|
667
|
-
isExpanded={expandedRow === 'timezone'}
|
|
668
|
-
onEdit={() => handleEdit('timezone')}
|
|
669
|
-
onCancel={handleCancel}
|
|
670
|
-
onSave={handleSave}>
|
|
671
|
-
<Selector
|
|
672
|
-
label="Time zone"
|
|
673
|
-
isLabelHidden
|
|
674
|
-
size="lg"
|
|
675
|
-
value={timezone}
|
|
676
|
-
onChange={setTimezone}
|
|
677
|
-
options={TIMEZONES}
|
|
678
|
-
/>
|
|
679
|
-
</ExpandableRow>
|
|
680
|
-
</VStack>
|
|
681
|
-
</VStack>
|
|
682
|
-
)}
|
|
683
|
-
|
|
684
|
-
{activeNav === 'Privacy' && (
|
|
685
|
-
<VStack gap={6}>
|
|
686
|
-
<VStack gap={8}>
|
|
687
|
-
<VStack gap={4}>
|
|
688
|
-
<Heading level={3}>Messages</Heading>
|
|
689
|
-
<Switch
|
|
690
|
-
label="Show people when I've read their messages."
|
|
691
|
-
value={readReceipts}
|
|
692
|
-
onChange={setReadReceipts}
|
|
693
|
-
labelPosition="start"
|
|
694
|
-
labelSpacing="spread"
|
|
695
|
-
/>
|
|
696
|
-
<HStack hAlign="between" vAlign="center">
|
|
697
|
-
<Text type="body" weight="semibold">
|
|
698
|
-
Blocked people
|
|
699
|
-
</Text>
|
|
700
|
-
<Link href="#">View</Link>
|
|
701
|
-
</HStack>
|
|
702
|
-
<Divider />
|
|
703
|
-
</VStack>
|
|
704
|
-
|
|
705
|
-
<VStack gap={4}>
|
|
706
|
-
<Heading level={3}>Listings</Heading>
|
|
707
|
-
<Switch
|
|
708
|
-
label="Include my listing(s) in search engines"
|
|
709
|
-
description="Turning this on means search engines, like Google, will display your listing page(s) in search results."
|
|
710
|
-
value={searchEngines}
|
|
711
|
-
onChange={setSearchEngines}
|
|
712
|
-
labelPosition="start"
|
|
713
|
-
labelSpacing="spread"
|
|
714
|
-
/>
|
|
715
|
-
<Divider />
|
|
716
|
-
</VStack>
|
|
717
|
-
|
|
718
|
-
<VStack gap={4}>
|
|
719
|
-
<Heading level={3}>Reviews</Heading>
|
|
720
|
-
<Text type="supporting" color="secondary">
|
|
721
|
-
Choose what's shared when you write a review.{' '}
|
|
722
|
-
<Link href="#" type="supporting">
|
|
723
|
-
Learn more
|
|
724
|
-
</Link>
|
|
725
|
-
</Text>
|
|
726
|
-
<VStack gap={4}>
|
|
727
|
-
<Switch
|
|
728
|
-
label="Show my home city and country"
|
|
729
|
-
description="Ex: City and country"
|
|
730
|
-
value={showCity}
|
|
731
|
-
onChange={setShowCity}
|
|
732
|
-
labelPosition="start"
|
|
733
|
-
labelSpacing="spread"
|
|
734
|
-
/>
|
|
735
|
-
<Switch
|
|
736
|
-
label="Show my trip type"
|
|
737
|
-
description="Ex: Stayed with kids or pets"
|
|
738
|
-
value={showTripType}
|
|
739
|
-
onChange={setShowTripType}
|
|
740
|
-
labelPosition="start"
|
|
741
|
-
labelSpacing="spread"
|
|
742
|
-
/>
|
|
743
|
-
<Switch
|
|
744
|
-
label="Show my length of stay"
|
|
745
|
-
description="Ex: A few nights, about a week, etc."
|
|
746
|
-
value={showStayLength}
|
|
747
|
-
onChange={setShowStayLength}
|
|
748
|
-
labelPosition="start"
|
|
749
|
-
labelSpacing="spread"
|
|
750
|
-
/>
|
|
751
|
-
<Switch
|
|
752
|
-
label="Show my booked services"
|
|
753
|
-
description="Ex: Gourmet brunch or tasting menu"
|
|
754
|
-
value={showServices}
|
|
755
|
-
onChange={setShowServices}
|
|
756
|
-
labelPosition="start"
|
|
757
|
-
labelSpacing="spread"
|
|
758
|
-
/>
|
|
759
|
-
</VStack>
|
|
760
|
-
<Divider />
|
|
761
|
-
</VStack>
|
|
762
|
-
|
|
763
|
-
<VStack gap={4}>
|
|
764
|
-
<Heading level={3}>Data privacy</Heading>
|
|
765
|
-
<Card>
|
|
766
|
-
<HStack hAlign="between" vAlign="center">
|
|
767
|
-
<Text type="body">Request my personal data</Text>
|
|
768
|
-
<Link href="#">Request</Link>
|
|
769
|
-
</HStack>
|
|
770
|
-
</Card>
|
|
771
|
-
<Switch
|
|
772
|
-
label="Help improve AI-powered features"
|
|
773
|
-
description="When this is on, we use your data to develop and improve AI models."
|
|
774
|
-
value={aiFeatures}
|
|
775
|
-
onChange={setAiFeatures}
|
|
776
|
-
labelPosition="start"
|
|
777
|
-
labelSpacing="spread"
|
|
778
|
-
/>
|
|
779
|
-
<Card>
|
|
780
|
-
<HStack hAlign="between" vAlign="center">
|
|
781
|
-
<Text type="body">Delete my account</Text>
|
|
782
|
-
<Link href="#">Delete</Link>
|
|
783
|
-
</HStack>
|
|
784
|
-
</Card>
|
|
785
|
-
<Card variant="muted">
|
|
786
|
-
<HStack gap={4} vAlign="start">
|
|
787
|
-
<Center width={48} height={48} style={iconBox}>
|
|
788
|
-
<Icon icon={ShieldCheckIcon} />
|
|
789
|
-
</Center>
|
|
790
|
-
<VStack gap={1}>
|
|
791
|
-
<Text type="body" weight="bold">
|
|
792
|
-
Committed to privacy
|
|
793
|
-
</Text>
|
|
794
|
-
<Text type="supporting" color="secondary">
|
|
795
|
-
We're committed to keeping your data
|
|
796
|
-
protected. See details in our{' '}
|
|
797
|
-
<Link href="#" type="supporting">
|
|
798
|
-
Privacy Policy
|
|
799
|
-
</Link>
|
|
800
|
-
.
|
|
801
|
-
</Text>
|
|
802
|
-
</VStack>
|
|
803
|
-
</HStack>
|
|
804
|
-
</Card>
|
|
805
|
-
</VStack>
|
|
806
|
-
</VStack>
|
|
807
|
-
</VStack>
|
|
808
|
-
)}
|
|
809
|
-
</VStack>
|
|
810
|
-
</VStack>
|
|
811
|
-
</LayoutContent>
|
|
812
|
-
}
|
|
813
|
-
/>
|
|
2560
|
+
width={1120}
|
|
2561
|
+
maxHeight="min(800px, calc(100dvh - 2rem))"
|
|
2562
|
+
padding={0}
|
|
2563
|
+
aria-labelledby={titleId}>
|
|
2564
|
+
{/* Two shells rather than one responsive one — see the module header.
|
|
2565
|
+
Both are wrapped in the one height, so the frame is the same on
|
|
2566
|
+
every panel and it is the panel that scrolls, not the dialog that
|
|
2567
|
+
grows. */}
|
|
2568
|
+
<VStack xstyle={styles.shell}>
|
|
2569
|
+
{isNarrow ? (
|
|
2570
|
+
<SettingsNarrowShell
|
|
2571
|
+
titleId={titleId}
|
|
2572
|
+
search={search}
|
|
2573
|
+
activePanel={activePanel}
|
|
2574
|
+
settings={settings}
|
|
2575
|
+
update={update}
|
|
2576
|
+
onSelectPanel={selectPanel}
|
|
2577
|
+
onClose={noop}
|
|
2578
|
+
/>
|
|
2579
|
+
) : (
|
|
2580
|
+
<Layout
|
|
2581
|
+
start={
|
|
2582
|
+
<SettingsSideNav
|
|
2583
|
+
titleId={titleId}
|
|
2584
|
+
search={search}
|
|
2585
|
+
activePanel={activePanel}
|
|
2586
|
+
onSelectPanel={selectPanel}
|
|
2587
|
+
/>
|
|
2588
|
+
}
|
|
2589
|
+
content={
|
|
2590
|
+
<SettingsContentPane
|
|
2591
|
+
panel={activePanel}
|
|
2592
|
+
settings={settings}
|
|
2593
|
+
update={update}
|
|
2594
|
+
onClose={noop}
|
|
2595
|
+
/>
|
|
2596
|
+
}
|
|
2597
|
+
/>
|
|
2598
|
+
)}
|
|
2599
|
+
</VStack>
|
|
814
2600
|
</Dialog>
|
|
815
|
-
|
|
2601
|
+
</Center>
|
|
816
2602
|
);
|
|
817
2603
|
}
|