@12-apps/ui 6.50.1 → 6.51.1
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/components-catalog.md +2 -2
- package/dist/_internal/components/index.js +7 -7
- package/dist/_internal/components/navigation/SectionNav/SectionNav.bar.js +6 -5
- package/dist/_internal/components/navigation/SectionNav/SectionNav.helpers.js +5 -1
- package/dist/_internal/components/navigation/SectionNav/SectionNav.js +8 -7
- package/dist/_internal/components/navigation/SectionNav/SectionNav.parts.js +8 -2
- package/dist/_internal/components/navigation/SectionNav/SectionNav.primary.js +3 -3
- package/dist/_internal/components/navigation/SectionNav/SectionNav.rail.js +3 -3
- package/dist/_internal/components/navigation/SectionNav/SectionNav.sheet.js +3 -3
- package/dist/_internal/components/navigation/SectionNav/__stories__/fixtures.js +12 -0
- package/dist/_internal/components/navigation/SectionNav/__stories__/fixtures.js.map +1 -1
- package/dist/_internal/components/navigation/index.js +8 -7
- package/dist/{chunk-SA2ERYVV.js → chunk-5GQYTB3K.js} +3 -3
- package/dist/{chunk-GLKQT2DS.js → chunk-A436VORP.js} +33 -4
- package/dist/chunk-A436VORP.js.map +1 -0
- package/dist/{chunk-RX7J6VRI.js → chunk-HKEBTPKY.js} +119 -42
- package/dist/chunk-HKEBTPKY.js.map +1 -0
- package/dist/{chunk-VHVI3D4R.js → chunk-P3GBXE73.js} +11 -2
- package/dist/chunk-P3GBXE73.js.map +1 -0
- package/dist/{chunk-32JMSHEA.js → chunk-S4ZUZ6DS.js} +10 -6
- package/dist/chunk-S4ZUZ6DS.js.map +1 -0
- package/dist/chunk-WNKRFMSI.js +113 -0
- package/dist/chunk-WNKRFMSI.js.map +1 -0
- package/dist/chunk-WNZBUR76.js +294 -0
- package/dist/chunk-WNZBUR76.js.map +1 -0
- package/dist/navigation/SectionNav.js +8 -7
- package/dist/types/components/navigation/SectionNav/SectionNav.bar.d.ts.map +1 -1
- package/dist/types/components/navigation/SectionNav/SectionNav.helpers.d.ts +12 -1
- package/dist/types/components/navigation/SectionNav/SectionNav.helpers.d.ts.map +1 -1
- package/dist/types/components/navigation/SectionNav/SectionNav.parts.d.ts +36 -2
- package/dist/types/components/navigation/SectionNav/SectionNav.parts.d.ts.map +1 -1
- package/dist/types/components/navigation/SectionNav/SectionNav.primary.d.ts +22 -4
- package/dist/types/components/navigation/SectionNav/SectionNav.primary.d.ts.map +1 -1
- package/dist/types/components/navigation/SectionNav/SectionNav.rail.d.ts.map +1 -1
- package/dist/types/components/navigation/SectionNav/SectionNav.sheet.d.ts.map +1 -1
- package/dist/types/components/navigation/SectionNav/SectionNav.types.d.ts +81 -5
- package/dist/types/components/navigation/SectionNav/SectionNav.types.d.ts.map +1 -1
- package/dist/types/components/navigation/SectionNav/__stories__/fixtures.d.ts +11 -1
- package/dist/types/components/navigation/SectionNav/__stories__/fixtures.d.ts.map +1 -1
- package/dist/types/components/navigation/SectionNav/index.d.ts +1 -1
- package/dist/types/components/navigation/SectionNav/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/navigation/SectionNav/SectionNav.bar.tsx +166 -90
- package/src/components/navigation/SectionNav/SectionNav.helpers.ts +16 -1
- package/src/components/navigation/SectionNav/SectionNav.md +40 -4
- package/src/components/navigation/SectionNav/SectionNav.parts.tsx +59 -1
- package/src/components/navigation/SectionNav/SectionNav.primary.tsx +107 -30
- package/src/components/navigation/SectionNav/SectionNav.rail.tsx +154 -41
- package/src/components/navigation/SectionNav/SectionNav.sheet.tsx +6 -2
- package/src/components/navigation/SectionNav/SectionNav.types.ts +82 -5
- package/src/components/navigation/SectionNav/__stories__/fixtures.tsx +21 -1
- package/src/components/navigation/SectionNav/index.ts +1 -0
- package/dist/chunk-32JMSHEA.js.map +0 -1
- package/dist/chunk-43Q6CVPB.js +0 -240
- package/dist/chunk-43Q6CVPB.js.map +0 -1
- package/dist/chunk-GLKQT2DS.js.map +0 -1
- package/dist/chunk-RX7J6VRI.js.map +0 -1
- package/dist/chunk-RZ4SBSLP.js +0 -62
- package/dist/chunk-RZ4SBSLP.js.map +0 -1
- package/dist/chunk-VHVI3D4R.js.map +0 -1
- /package/dist/{chunk-SA2ERYVV.js.map → chunk-5GQYTB3K.js.map} +0 -0
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import Typography from '@mui/material/Typography/index.js';
|
|
2
|
-
import { useTheme } from '@mui/material/styles/index.js';
|
|
2
|
+
import { useTheme, type SxProps, type Theme } from '@mui/material/styles/index.js';
|
|
3
3
|
import { useState, type ElementType, type ReactNode } from 'react';
|
|
4
4
|
|
|
5
5
|
import type { SectionNavCopy } from '../../../copy';
|
|
6
|
+
import { stackedOverlayZIndex } from '../../../tokens/layers';
|
|
6
7
|
import { rem } from '../../../tokens/scales';
|
|
7
8
|
|
|
8
|
-
import { barSlots, menuActive, menuCount, type BarSlot } from './SectionNav.helpers';
|
|
9
|
-
import {
|
|
9
|
+
import { barSlots, destinationState, isMenu, menuActive, menuCount, type BarSlot } from './SectionNav.helpers';
|
|
10
|
+
import {
|
|
11
|
+
Box,
|
|
12
|
+
CONTROL_RESET,
|
|
13
|
+
CountedIcon,
|
|
14
|
+
NOT_LIVE,
|
|
15
|
+
SlotIcon,
|
|
16
|
+
controlProps,
|
|
17
|
+
destinationControl,
|
|
18
|
+
focusRing,
|
|
19
|
+
} from './SectionNav.parts';
|
|
10
20
|
import { RaisedActionButton } from './SectionNav.primary';
|
|
11
21
|
import { SectionNavSheet } from './SectionNav.sheet';
|
|
12
|
-
import type { SectionNavMenu, SectionNavProps } from './SectionNav.types';
|
|
22
|
+
import type { SectionNavAction, SectionNavDestination, SectionNavMenu, SectionNavProps } from './SectionNav.types';
|
|
13
23
|
|
|
14
24
|
/** Which of the bar's two menus is open, if any. */
|
|
15
25
|
type OpenMenu = 'primary' | 'more' | null;
|
|
@@ -29,13 +39,17 @@ export function sectionNavBarInset(theme: Parameters<typeof rem>[0]): string {
|
|
|
29
39
|
/**
|
|
30
40
|
* One slot: icon over label, a count on the icon.
|
|
31
41
|
*
|
|
32
|
-
* `current` is the page the viewer is on and is what `aria-current` reports
|
|
33
|
-
*
|
|
34
|
-
*
|
|
42
|
+
* `current` is the page the viewer is on and is what `aria-current` reports —
|
|
43
|
+
* a link's claim only; an action slot that is ON says so with `aria-pressed`,
|
|
44
|
+
* which arrives in `control`. `lit` is only how the slot looks, and goes out
|
|
45
|
+
* while a sheet is open so the bar shows where the thumb is. Opening a sheet
|
|
46
|
+
* is `aria-expanded`'s to say.
|
|
35
47
|
*
|
|
36
|
-
* A destination is a link; the "more" slot is a button that
|
|
37
|
-
* Either way the whole slot is the target, so a thumb landing
|
|
38
|
-
* lands on the control.
|
|
48
|
+
* A destination is a link or an action; the "more" slot is a button that
|
|
49
|
+
* opens its sheet. Either way the whole slot is the target, so a thumb landing
|
|
50
|
+
* on the label lands on the control. A `disabled` slot is a disabled button,
|
|
51
|
+
* dimmed — the act is still where the hand expects it. A `loading` or
|
|
52
|
+
* `dimmed` slot is drawn the same and stays focusable (`aria-disabled`).
|
|
39
53
|
*/
|
|
40
54
|
function Slot({
|
|
41
55
|
label,
|
|
@@ -47,6 +61,7 @@ function Slot({
|
|
|
47
61
|
testId,
|
|
48
62
|
control,
|
|
49
63
|
expanded,
|
|
64
|
+
loading = false,
|
|
50
65
|
}: {
|
|
51
66
|
label: string;
|
|
52
67
|
icon: ReactNode;
|
|
@@ -57,6 +72,7 @@ function Slot({
|
|
|
57
72
|
testId: string;
|
|
58
73
|
control: Record<string, unknown>;
|
|
59
74
|
expanded?: boolean;
|
|
75
|
+
loading?: boolean;
|
|
60
76
|
}): React.JSX.Element {
|
|
61
77
|
const theme = useTheme();
|
|
62
78
|
return (
|
|
@@ -64,36 +80,17 @@ function Slot({
|
|
|
64
80
|
{...control}
|
|
65
81
|
aria-current={current ? 'page' : undefined}
|
|
66
82
|
aria-expanded={expanded}
|
|
83
|
+
aria-busy={loading || undefined}
|
|
67
84
|
data-lit={lit ? 'true' : undefined}
|
|
68
85
|
data-testid={testId}
|
|
69
|
-
sx={
|
|
70
|
-
...CONTROL_RESET,
|
|
71
|
-
...focusRing(theme),
|
|
72
|
-
flex: 1,
|
|
73
|
-
minWidth: 0,
|
|
74
|
-
display: 'flex',
|
|
75
|
-
flexDirection: 'column',
|
|
76
|
-
alignItems: 'center',
|
|
77
|
-
justifyContent: 'center',
|
|
78
|
-
gap: 0.25,
|
|
79
|
-
position: 'relative',
|
|
80
|
-
color: lit ? 'primary.main' : 'text.secondary',
|
|
81
|
-
// The current slot is marked by more than its colour: a short rule on
|
|
82
|
-
// its top edge, so the state survives a colour-blind reading.
|
|
83
|
-
'&[data-lit="true"]::before': {
|
|
84
|
-
content: '""',
|
|
85
|
-
position: 'absolute',
|
|
86
|
-
top: 0,
|
|
87
|
-
insetInline: '22%',
|
|
88
|
-
height: rem(theme, 3),
|
|
89
|
-
borderBottomLeftRadius: rem(theme, 3),
|
|
90
|
-
borderBottomRightRadius: rem(theme, 3),
|
|
91
|
-
bgcolor: 'primary.main',
|
|
92
|
-
},
|
|
93
|
-
'& svg': { fontSize: rem(theme, 22) },
|
|
94
|
-
}}
|
|
86
|
+
sx={slotSx(theme, lit)}
|
|
95
87
|
>
|
|
96
|
-
<CountedIcon
|
|
88
|
+
<CountedIcon
|
|
89
|
+
icon={<SlotIcon icon={icon} loading={loading} />}
|
|
90
|
+
count={count}
|
|
91
|
+
label={copy.badge}
|
|
92
|
+
testId={`${testId}-badge`}
|
|
93
|
+
/>
|
|
97
94
|
<Typography
|
|
98
95
|
component="span"
|
|
99
96
|
variant="caption"
|
|
@@ -112,71 +109,152 @@ function Slot({
|
|
|
112
109
|
);
|
|
113
110
|
}
|
|
114
111
|
|
|
115
|
-
/**
|
|
116
|
-
function
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
112
|
+
/** A slot's look: icon over label, the lit rule on its top edge, dimmed when disabled. */
|
|
113
|
+
function slotSx(theme: Theme, lit: boolean): SxProps<Theme> {
|
|
114
|
+
return {
|
|
115
|
+
...CONTROL_RESET,
|
|
116
|
+
...focusRing(theme),
|
|
117
|
+
flex: 1,
|
|
118
|
+
minWidth: 0,
|
|
119
|
+
display: 'flex',
|
|
120
|
+
flexDirection: 'column',
|
|
121
|
+
alignItems: 'center',
|
|
122
|
+
justifyContent: 'center',
|
|
123
|
+
gap: 0.25,
|
|
124
|
+
position: 'relative',
|
|
125
|
+
color: lit ? 'primary.main' : 'text.secondary',
|
|
126
|
+
// The current slot is marked by more than its colour: a short rule on
|
|
127
|
+
// its top edge, so the state survives a colour-blind reading.
|
|
128
|
+
'&[data-lit="true"]::before': {
|
|
129
|
+
content: '""',
|
|
130
|
+
position: 'absolute',
|
|
131
|
+
top: 0,
|
|
132
|
+
insetInline: '22%',
|
|
133
|
+
height: rem(theme, 3),
|
|
134
|
+
borderBottomLeftRadius: rem(theme, 3),
|
|
135
|
+
borderBottomRightRadius: rem(theme, 3),
|
|
136
|
+
bgcolor: 'primary.main',
|
|
137
|
+
},
|
|
138
|
+
'& svg': { fontSize: rem(theme, 22) },
|
|
139
|
+
[NOT_LIVE]: { color: 'text.disabled' },
|
|
140
|
+
} as SxProps<Theme>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** What every slot kind is handed: the bar's open state and how to change it. */
|
|
144
|
+
interface SlotContext {
|
|
130
145
|
open: OpenMenu;
|
|
131
146
|
toggle: (menu: Exclude<OpenMenu, null>) => void;
|
|
132
147
|
close: () => void;
|
|
133
148
|
linkComponent: ElementType | undefined;
|
|
134
149
|
copy: SectionNavCopy;
|
|
135
150
|
dataTestId: string;
|
|
136
|
-
}
|
|
137
|
-
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The raised button: an action runs on tap; a menu opens its sheet. */
|
|
154
|
+
function PrimarySlot({
|
|
155
|
+
primary,
|
|
156
|
+
context,
|
|
157
|
+
}: {
|
|
158
|
+
primary: SectionNavMenu | SectionNavAction;
|
|
159
|
+
context: SlotContext;
|
|
160
|
+
}): React.JSX.Element {
|
|
161
|
+
const testId = primary.dataTestId ?? `${context.dataTestId}-primary`;
|
|
162
|
+
if (!isMenu(primary)) {
|
|
138
163
|
return (
|
|
139
164
|
<RaisedActionButton
|
|
140
165
|
label={primary.label}
|
|
141
166
|
icon={primary.icon}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
return (
|
|
151
|
-
<Slot
|
|
152
|
-
label={more.label}
|
|
153
|
-
icon={more.icon}
|
|
154
|
-
count={menuCount(more)}
|
|
155
|
-
current={false}
|
|
156
|
-
lit={open === 'more' || (open === null && menuActive(more))}
|
|
157
|
-
expanded={open === 'more'}
|
|
158
|
-
copy={copy}
|
|
159
|
-
testId={`${dataTestId}-more`}
|
|
160
|
-
control={controlProps({ href: undefined, linkComponent, onClick: () => toggle('more') })}
|
|
167
|
+
captioned
|
|
168
|
+
disabled={primary.disabled}
|
|
169
|
+
loading={primary.loading}
|
|
170
|
+
dataTestId={testId}
|
|
171
|
+
onClick={() => {
|
|
172
|
+
context.close();
|
|
173
|
+
primary.onSelect();
|
|
174
|
+
}}
|
|
161
175
|
/>
|
|
162
176
|
);
|
|
163
177
|
}
|
|
164
|
-
|
|
165
|
-
|
|
178
|
+
return (
|
|
179
|
+
<RaisedActionButton
|
|
180
|
+
label={primary.label}
|
|
181
|
+
icon={primary.icon}
|
|
182
|
+
open={context.open === 'primary'}
|
|
183
|
+
closeLabel={context.copy.close}
|
|
184
|
+
disabled={primary.disabled}
|
|
185
|
+
dataTestId={testId}
|
|
186
|
+
onClick={() => context.toggle('primary')}
|
|
187
|
+
/>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The "more" slot: a button that opens its sheet, carrying its entries' counts. */
|
|
192
|
+
function MoreSlot({ more, context }: { more: SectionNavMenu; context: SlotContext }): React.JSX.Element {
|
|
193
|
+
const { open } = context;
|
|
194
|
+
return (
|
|
195
|
+
<Slot
|
|
196
|
+
label={more.label}
|
|
197
|
+
icon={more.icon}
|
|
198
|
+
count={menuCount(more)}
|
|
199
|
+
current={false}
|
|
200
|
+
lit={open === 'more' || (open === null && menuActive(more))}
|
|
201
|
+
expanded={open === 'more'}
|
|
202
|
+
copy={context.copy}
|
|
203
|
+
testId={more.dataTestId ?? `${context.dataTestId}-more`}
|
|
204
|
+
control={controlProps({
|
|
205
|
+
href: undefined,
|
|
206
|
+
linkComponent: context.linkComponent,
|
|
207
|
+
inert: more.disabled === true,
|
|
208
|
+
onClick: () => context.toggle('more'),
|
|
209
|
+
})}
|
|
210
|
+
/>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A destination: a link, or an action slot (a toggle when it carries `active`). */
|
|
215
|
+
function DestinationSlot({
|
|
216
|
+
destination,
|
|
217
|
+
context,
|
|
218
|
+
}: {
|
|
219
|
+
destination: SectionNavDestination;
|
|
220
|
+
context: SlotContext;
|
|
221
|
+
}): React.JSX.Element {
|
|
222
|
+
const { current, lit } = destinationState(destination);
|
|
166
223
|
return (
|
|
167
224
|
<Slot
|
|
168
225
|
label={destination.label}
|
|
169
226
|
icon={destination.icon}
|
|
170
227
|
count={destination.badge}
|
|
171
|
-
current={
|
|
172
|
-
lit={open === null &&
|
|
173
|
-
copy={copy}
|
|
174
|
-
testId={`${dataTestId}-dest-${destination.id}`}
|
|
175
|
-
|
|
228
|
+
current={current}
|
|
229
|
+
lit={context.open === null && lit}
|
|
230
|
+
copy={context.copy}
|
|
231
|
+
testId={destination.dataTestId ?? `${context.dataTestId}-dest-${destination.id}`}
|
|
232
|
+
loading={destination.loading === true}
|
|
233
|
+
control={destinationControl(destination, context.linkComponent, () => {
|
|
234
|
+
context.close();
|
|
235
|
+
destination.onSelect?.();
|
|
236
|
+
})}
|
|
176
237
|
/>
|
|
177
238
|
);
|
|
178
239
|
}
|
|
179
240
|
|
|
241
|
+
/** One slot of the bar, whichever kind it is. */
|
|
242
|
+
function BarSlotView({
|
|
243
|
+
slot,
|
|
244
|
+
primary,
|
|
245
|
+
more,
|
|
246
|
+
context,
|
|
247
|
+
}: {
|
|
248
|
+
slot: BarSlot;
|
|
249
|
+
primary: SectionNavMenu | SectionNavAction | undefined;
|
|
250
|
+
more: SectionNavMenu | undefined;
|
|
251
|
+
context: SlotContext;
|
|
252
|
+
}): React.JSX.Element | null {
|
|
253
|
+
if (slot.kind === 'primary') return primary ? <PrimarySlot primary={primary} context={context} /> : null;
|
|
254
|
+
if (slot.kind === 'more') return more ? <MoreSlot more={more} context={context} /> : null;
|
|
255
|
+
return <DestinationSlot destination={slot.destination} context={context} />;
|
|
256
|
+
}
|
|
257
|
+
|
|
180
258
|
/** The phone layout: a bottom bar, with its menus as bottom sheets. */
|
|
181
259
|
export function SectionNavBar({
|
|
182
260
|
label,
|
|
@@ -188,6 +266,7 @@ export function SectionNavBar({
|
|
|
188
266
|
dataTestId,
|
|
189
267
|
}: Required<Pick<SectionNavProps, 'label' | 'destinations' | 'copy' | 'dataTestId'>> &
|
|
190
268
|
Pick<SectionNavProps, 'primary' | 'more'> & { linkComponent: ElementType | undefined }): React.JSX.Element {
|
|
269
|
+
const sheetMenu = isMenu(primary) ? primary : undefined;
|
|
191
270
|
const theme = useTheme();
|
|
192
271
|
const [open, setOpen] = useState<OpenMenu>(null);
|
|
193
272
|
const toggle = (menu: Exclude<OpenMenu, null>): void => setOpen((current) => (current === menu ? null : menu));
|
|
@@ -211,9 +290,11 @@ export function SectionNavBar({
|
|
|
211
290
|
// Above the backdrop ONLY while one of its own sheets is open, so the
|
|
212
291
|
// primary button can close what it opened. Raised all the time it
|
|
213
292
|
// would sit over every dialog the page opens, and on a small phone a
|
|
214
|
-
// dialog's own footer is exactly where the bar is.
|
|
293
|
+
// dialog's own footer is exactly where the bar is. `stackedOverlayZIndex`,
|
|
294
|
+
// not `zIndex.modal + 1`: a host lifting a bottom Drawer clear of a
|
|
295
|
+
// stacked sheet catches this bar's OWN sheets too.
|
|
215
296
|
position: 'relative',
|
|
216
|
-
zIndex: open === null ? 'auto' : theme
|
|
297
|
+
zIndex: open === null ? 'auto' : stackedOverlayZIndex(theme) + 1,
|
|
217
298
|
}}
|
|
218
299
|
>
|
|
219
300
|
{barSlots(destinations, more !== undefined, primary !== undefined).map((slot) => (
|
|
@@ -222,18 +303,13 @@ export function SectionNavBar({
|
|
|
222
303
|
slot={slot}
|
|
223
304
|
primary={primary}
|
|
224
305
|
more={more}
|
|
225
|
-
|
|
226
|
-
toggle={toggle}
|
|
227
|
-
close={close}
|
|
228
|
-
linkComponent={linkComponent}
|
|
229
|
-
copy={copy}
|
|
230
|
-
dataTestId={dataTestId}
|
|
306
|
+
context={{ open, toggle, close, linkComponent, copy, dataTestId }}
|
|
231
307
|
/>
|
|
232
308
|
))}
|
|
233
309
|
</Box>
|
|
234
|
-
{
|
|
310
|
+
{sheetMenu ? (
|
|
235
311
|
<SectionNavSheet
|
|
236
|
-
menu={
|
|
312
|
+
menu={sheetMenu}
|
|
237
313
|
open={open === 'primary'}
|
|
238
314
|
onClose={close}
|
|
239
315
|
linkComponent={linkComponent}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SectionNavDestination, SectionNavMenu } from './SectionNav.types';
|
|
1
|
+
import type { SectionNavAction, SectionNavDestination, SectionNavMenu } from './SectionNav.types';
|
|
2
2
|
|
|
3
3
|
/** A count worth drawing: positive and finite. Zero and "unknown" draw nothing. */
|
|
4
4
|
export function shownCount(count: number | undefined): number | undefined {
|
|
@@ -49,3 +49,18 @@ export function barSlots(
|
|
|
49
49
|
if (hasPrimary) slots.splice(Math.floor(slots.length / 2), 0, { kind: 'primary' });
|
|
50
50
|
return slots;
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
/** A primary that opens a sheet, as opposed to one that acts on tap. */
|
|
54
|
+
export function isMenu(primary: SectionNavMenu | SectionNavAction | undefined): primary is SectionNavMenu {
|
|
55
|
+
return primary !== undefined && 'groups' in primary;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* How a destination reads. `current` is `aria-current="page"` and belongs to a
|
|
60
|
+
* LINK only — an action slot that is ON reports `aria-pressed` instead. `lit`
|
|
61
|
+
* is the look, and a `dimmed` slot is never lit: it says "not now".
|
|
62
|
+
*/
|
|
63
|
+
export function destinationState(destination: SectionNavDestination): { current: boolean; lit: boolean } {
|
|
64
|
+
const active = destination.active === true;
|
|
65
|
+
return { current: active && destination.href !== undefined, lit: active && destination.dimmed !== true };
|
|
66
|
+
}
|
|
@@ -31,10 +31,13 @@ screen.
|
|
|
31
31
|
- **layout**: `'bar' | 'rail'`.
|
|
32
32
|
- **label**: the navigation landmark's accessible name.
|
|
33
33
|
- **destinations**: `SectionNavDestination[]`, each with `id`, `label`, `icon`,
|
|
34
|
-
`href`, `badge
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
`href` OR `onSelect`, `badge?`, `active?`, `disabled?`, `dimmed?`,
|
|
35
|
+
`loading?` and `dataTestId?`.
|
|
36
|
+
- **primary**: a `SectionNavMenu` (`label`, `icon`, `title`, `groups`) or a
|
|
37
|
+
`SectionNavAction` (`label`, `icon`, `onSelect`, `disabled?`, `loading?`). In
|
|
38
|
+
the bar it is the raised button. A menu opens its sheet, and the button's name
|
|
39
|
+
becomes `copy.close` while it is open. An action runs on tap and its label is
|
|
40
|
+
drawn under the button.
|
|
38
41
|
- **more**: a `SectionNavMenu`. In the bar it is the last slot, and its badge
|
|
39
42
|
is the sum of its entries' badges.
|
|
40
43
|
- **back**, **heading**: the rail only.
|
|
@@ -48,6 +51,36 @@ screen.
|
|
|
48
51
|
named packs; `SECTION_NAV_COPY` is the locale pack.
|
|
49
52
|
- **dataTestId**: the prefix for every test id (`section-nav`).
|
|
50
53
|
|
|
54
|
+
## A bar of verbs
|
|
55
|
+
|
|
56
|
+
The same bar pins a SCREEN's actions at its foot: give destinations an
|
|
57
|
+
`onSelect` instead of an `href`, and `primary` a `SectionNavAction`. A bar of
|
|
58
|
+
places and a bar of verbs are one component, so they cannot drift into two
|
|
59
|
+
looks one tap apart.
|
|
60
|
+
|
|
61
|
+
- `disabled` draws the slot dimmed and makes it a native disabled button, even
|
|
62
|
+
when it carries an `href`: a link cannot be disabled, and one that looks
|
|
63
|
+
dimmed but still navigates is a lie. A tap does nothing; `onSelect` is not
|
|
64
|
+
called.
|
|
65
|
+
- `dimmed` is for an act the screen cannot do now but should EXPLAIN when
|
|
66
|
+
tapped. It is drawn exactly like `disabled` (the `text.disabled` colour) and
|
|
67
|
+
reports `aria-disabled="true"`, but it is NOT natively disabled: it stays
|
|
68
|
+
focusable, its `onSelect` still fires, and the host answers with a message
|
|
69
|
+
(why not, and what would make it possible). It is never lit.
|
|
70
|
+
- `loading` replaces the icon with a spinner and reports `aria-busy` and
|
|
71
|
+
`aria-disabled="true"` until the write it started answers. It stays
|
|
72
|
+
focusable, so the keyboard keeps its place, and a tap is ignored. The same
|
|
73
|
+
holds for a loading action `primary`. Native `disabled` is used only for
|
|
74
|
+
`disabled`.
|
|
75
|
+
- `active` on an action slot marks a TOGGLE that is on: it lights the slot and
|
|
76
|
+
reports `aria-pressed="true"` (`false` when `active: false`). Only a link
|
|
77
|
+
reports `aria-current="page"`; an action is not a page.
|
|
78
|
+
- A menu entry, and a whole menu, can be `disabled` too. In the bar a disabled
|
|
79
|
+
menu's trigger is inert and opens no sheet; in the rail, where the menu is
|
|
80
|
+
listed rather than behind a trigger, every row of it is inert.
|
|
81
|
+
- `dataTestId` on a destination, entry, action or menu replaces the id the nav
|
|
82
|
+
would derive, for a host whose suites already drive their own.
|
|
83
|
+
|
|
51
84
|
A menu's **groups** carry `entries`. Each entry has an `href` (a link) or an
|
|
52
85
|
`onSelect` (an action). `layout: 'grid'` draws three tiles across in the sheet;
|
|
53
86
|
`list` (the default) draws one row per entry with its `description`. Choosing
|
|
@@ -81,6 +114,9 @@ the slot reports the sheet through `aria-expanded`.
|
|
|
81
114
|
|
|
82
115
|
The raised round button on its own, for a host that already draws its own bar:
|
|
83
116
|
drop it between two tabs in a flex row. Without `open` it is a plain action.
|
|
117
|
+
With `captioned` its label is drawn under it, hidden from assistive tech so
|
|
118
|
+
the button is named once. `disabled` is native; `loading` keeps it focusable
|
|
119
|
+
with `aria-busy` and `aria-disabled`, and drops the click.
|
|
84
120
|
With `open` it reports `aria-expanded`, takes `closeLabel` as its name, and its
|
|
85
121
|
icon turns 45° while open.
|
|
86
122
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Box from '@mui/material/Box/index.js';
|
|
2
|
+
import CircularProgress from '@mui/material/CircularProgress/index.js';
|
|
2
3
|
import type { SxProps, Theme } from '@mui/material/styles/index.js';
|
|
3
4
|
import type { ElementType, ReactNode } from 'react';
|
|
4
5
|
|
|
@@ -6,6 +7,7 @@ import { rem } from '../../../tokens/scales';
|
|
|
6
7
|
import { Badge } from '../../data-display/Badge';
|
|
7
8
|
|
|
8
9
|
import { shownCount } from './SectionNav.helpers';
|
|
10
|
+
import type { SectionNavDestination } from './SectionNav.types';
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* An icon with its count on the shoulder.
|
|
@@ -59,21 +61,76 @@ export function focusRing(theme: Theme): SxProps<Theme> {
|
|
|
59
61
|
* `href`, a button for an `onSelect`.
|
|
60
62
|
*
|
|
61
63
|
* Returned as props for a `Box`, so the control stays ONE interactive
|
|
62
|
-
* element — never a button wrapped around a link.
|
|
64
|
+
* element — never a button wrapped around a link. Whatever it would lead to,
|
|
65
|
+
* a control that is not live is a BUTTON: a link cannot be disabled, and one
|
|
66
|
+
* that looks dimmed but still navigates is the lie this rules out.
|
|
67
|
+
*
|
|
68
|
+
* - `inert` — a native `disabled` button: no focus, no click.
|
|
69
|
+
* - `busy` — a write is in flight: `aria-disabled`, still focusable, and the
|
|
70
|
+
* click is dropped, so the keyboard keeps its place until the write answers.
|
|
71
|
+
* - `dimmed` — `aria-disabled`, still focusable, and the click STILL runs, so
|
|
72
|
+
* the host can explain why the act cannot be done now.
|
|
63
73
|
*/
|
|
64
74
|
export function controlProps({
|
|
65
75
|
href,
|
|
66
76
|
onClick,
|
|
67
77
|
linkComponent,
|
|
78
|
+
inert = false,
|
|
79
|
+
busy = false,
|
|
80
|
+
dimmed = false,
|
|
68
81
|
}: {
|
|
69
82
|
href: string | undefined;
|
|
70
83
|
onClick?: () => void;
|
|
71
84
|
linkComponent: ElementType | undefined;
|
|
85
|
+
inert?: boolean;
|
|
86
|
+
busy?: boolean;
|
|
87
|
+
dimmed?: boolean;
|
|
72
88
|
}): Record<string, unknown> {
|
|
89
|
+
if (inert) return { component: 'button', type: 'button', disabled: true };
|
|
90
|
+
if (busy) return { component: 'button', type: 'button', 'aria-disabled': 'true' };
|
|
91
|
+
if (dimmed) return { component: 'button', type: 'button', 'aria-disabled': 'true', onClick };
|
|
73
92
|
if (href !== undefined) return { component: linkComponent ?? 'a', href, onClick };
|
|
74
93
|
return { component: 'button', type: 'button', onClick };
|
|
75
94
|
}
|
|
76
95
|
|
|
96
|
+
/**
|
|
97
|
+
* A destination's control: `controlProps` from its own state, plus
|
|
98
|
+
* `aria-pressed` when it is an action slot that is a toggle. A link reports
|
|
99
|
+
* where the viewer is through `aria-current` instead — the row draws that.
|
|
100
|
+
*/
|
|
101
|
+
export function destinationControl(
|
|
102
|
+
destination: SectionNavDestination,
|
|
103
|
+
linkComponent: ElementType | undefined,
|
|
104
|
+
onClick: () => void,
|
|
105
|
+
): Record<string, unknown> {
|
|
106
|
+
return {
|
|
107
|
+
...controlProps({
|
|
108
|
+
href: destination.href,
|
|
109
|
+
linkComponent,
|
|
110
|
+
inert: destination.disabled === true,
|
|
111
|
+
busy: destination.loading === true,
|
|
112
|
+
dimmed: destination.dimmed === true,
|
|
113
|
+
onClick,
|
|
114
|
+
}),
|
|
115
|
+
'aria-pressed': destination.href === undefined ? destination.active : undefined,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The selector for a control drawn as not live: natively disabled, or
|
|
121
|
+
* `aria-disabled` (in flight, or dimmed) and still focusable.
|
|
122
|
+
*/
|
|
123
|
+
export const NOT_LIVE = '&:disabled, &[aria-disabled="true"]';
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The icon, or the spinner that stands in for it while the slot's write is in
|
|
127
|
+
* flight — the same box, so the label under it does not move.
|
|
128
|
+
*/
|
|
129
|
+
export function SlotIcon({ icon, loading }: { icon: ReactNode; loading: boolean }): React.JSX.Element {
|
|
130
|
+
if (!loading) return <>{icon}</>;
|
|
131
|
+
return <CircularProgress size="1em" color="inherit" thickness={5} data-testid="section-nav-spinner" />;
|
|
132
|
+
}
|
|
133
|
+
|
|
77
134
|
/** The base reset every clickable in the nav shares: no underline, no button chrome. */
|
|
78
135
|
export const CONTROL_RESET = {
|
|
79
136
|
border: 0,
|
|
@@ -83,6 +140,7 @@ export const CONTROL_RESET = {
|
|
|
83
140
|
textDecoration: 'none',
|
|
84
141
|
cursor: 'pointer',
|
|
85
142
|
margin: 0,
|
|
143
|
+
[NOT_LIVE]: { cursor: 'default' },
|
|
86
144
|
} as const;
|
|
87
145
|
|
|
88
146
|
export { Box };
|