@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.
Files changed (61) hide show
  1. package/components-catalog.md +2 -2
  2. package/dist/_internal/components/index.js +7 -7
  3. package/dist/_internal/components/navigation/SectionNav/SectionNav.bar.js +6 -5
  4. package/dist/_internal/components/navigation/SectionNav/SectionNav.helpers.js +5 -1
  5. package/dist/_internal/components/navigation/SectionNav/SectionNav.js +8 -7
  6. package/dist/_internal/components/navigation/SectionNav/SectionNav.parts.js +8 -2
  7. package/dist/_internal/components/navigation/SectionNav/SectionNav.primary.js +3 -3
  8. package/dist/_internal/components/navigation/SectionNav/SectionNav.rail.js +3 -3
  9. package/dist/_internal/components/navigation/SectionNav/SectionNav.sheet.js +3 -3
  10. package/dist/_internal/components/navigation/SectionNav/__stories__/fixtures.js +12 -0
  11. package/dist/_internal/components/navigation/SectionNav/__stories__/fixtures.js.map +1 -1
  12. package/dist/_internal/components/navigation/index.js +8 -7
  13. package/dist/{chunk-SA2ERYVV.js → chunk-5GQYTB3K.js} +3 -3
  14. package/dist/{chunk-GLKQT2DS.js → chunk-A436VORP.js} +33 -4
  15. package/dist/chunk-A436VORP.js.map +1 -0
  16. package/dist/{chunk-RX7J6VRI.js → chunk-HKEBTPKY.js} +119 -42
  17. package/dist/chunk-HKEBTPKY.js.map +1 -0
  18. package/dist/{chunk-VHVI3D4R.js → chunk-P3GBXE73.js} +11 -2
  19. package/dist/chunk-P3GBXE73.js.map +1 -0
  20. package/dist/{chunk-32JMSHEA.js → chunk-S4ZUZ6DS.js} +10 -6
  21. package/dist/chunk-S4ZUZ6DS.js.map +1 -0
  22. package/dist/chunk-WNKRFMSI.js +113 -0
  23. package/dist/chunk-WNKRFMSI.js.map +1 -0
  24. package/dist/chunk-WNZBUR76.js +294 -0
  25. package/dist/chunk-WNZBUR76.js.map +1 -0
  26. package/dist/navigation/SectionNav.js +8 -7
  27. package/dist/types/components/navigation/SectionNav/SectionNav.bar.d.ts.map +1 -1
  28. package/dist/types/components/navigation/SectionNav/SectionNav.helpers.d.ts +12 -1
  29. package/dist/types/components/navigation/SectionNav/SectionNav.helpers.d.ts.map +1 -1
  30. package/dist/types/components/navigation/SectionNav/SectionNav.parts.d.ts +36 -2
  31. package/dist/types/components/navigation/SectionNav/SectionNav.parts.d.ts.map +1 -1
  32. package/dist/types/components/navigation/SectionNav/SectionNav.primary.d.ts +22 -4
  33. package/dist/types/components/navigation/SectionNav/SectionNav.primary.d.ts.map +1 -1
  34. package/dist/types/components/navigation/SectionNav/SectionNav.rail.d.ts.map +1 -1
  35. package/dist/types/components/navigation/SectionNav/SectionNav.sheet.d.ts.map +1 -1
  36. package/dist/types/components/navigation/SectionNav/SectionNav.types.d.ts +81 -5
  37. package/dist/types/components/navigation/SectionNav/SectionNav.types.d.ts.map +1 -1
  38. package/dist/types/components/navigation/SectionNav/__stories__/fixtures.d.ts +11 -1
  39. package/dist/types/components/navigation/SectionNav/__stories__/fixtures.d.ts.map +1 -1
  40. package/dist/types/components/navigation/SectionNav/index.d.ts +1 -1
  41. package/dist/types/components/navigation/SectionNav/index.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/components/navigation/SectionNav/SectionNav.bar.tsx +166 -90
  44. package/src/components/navigation/SectionNav/SectionNav.helpers.ts +16 -1
  45. package/src/components/navigation/SectionNav/SectionNav.md +40 -4
  46. package/src/components/navigation/SectionNav/SectionNav.parts.tsx +59 -1
  47. package/src/components/navigation/SectionNav/SectionNav.primary.tsx +107 -30
  48. package/src/components/navigation/SectionNav/SectionNav.rail.tsx +154 -41
  49. package/src/components/navigation/SectionNav/SectionNav.sheet.tsx +6 -2
  50. package/src/components/navigation/SectionNav/SectionNav.types.ts +82 -5
  51. package/src/components/navigation/SectionNav/__stories__/fixtures.tsx +21 -1
  52. package/src/components/navigation/SectionNav/index.ts +1 -0
  53. package/dist/chunk-32JMSHEA.js.map +0 -1
  54. package/dist/chunk-43Q6CVPB.js +0 -240
  55. package/dist/chunk-43Q6CVPB.js.map +0 -1
  56. package/dist/chunk-GLKQT2DS.js.map +0 -1
  57. package/dist/chunk-RX7J6VRI.js.map +0 -1
  58. package/dist/chunk-RZ4SBSLP.js +0 -62
  59. package/dist/chunk-RZ4SBSLP.js.map +0 -1
  60. package/dist/chunk-VHVI3D4R.js.map +0 -1
  61. /package/dist/{chunk-SA2ERYVV.js.map → chunk-5GQYTB3K.js.map} +0 -0
@@ -1,12 +1,17 @@
1
- import { useTheme } from '@mui/material/styles/index.js';
1
+ import Typography from '@mui/material/Typography/index.js';
2
+ import { useTheme, type SxProps, type Theme } from '@mui/material/styles/index.js';
2
3
  import type { ReactNode } from 'react';
3
4
 
4
5
  import { rem } from '../../../tokens/scales';
5
6
 
6
- import { Box, CONTROL_RESET, focusRing } from './SectionNav.parts';
7
+ import { Box, CONTROL_RESET, NOT_LIVE, SlotIcon, focusRing } from './SectionNav.parts';
7
8
 
8
9
  export interface RaisedActionButtonProps {
9
- /** The button's accessible name — it has no visible label. */
10
+ /**
11
+ * The button's accessible name. Drawn under the button only when
12
+ * `captioned`; the caption is hidden from assistive tech, so the name is
13
+ * read once either way.
14
+ */
10
15
  label: string;
11
16
  icon: ReactNode;
12
17
  onClick: () => void;
@@ -18,6 +23,19 @@ export interface RaisedActionButtonProps {
18
23
  open?: boolean;
19
24
  /** The name it takes while `open` — the same control closes what it opened. REQUIRED with `open`. */
20
25
  closeLabel?: string;
26
+ /**
27
+ * Draw `label` under the button. A bar of VERBS names the act it is for; a
28
+ * bar of places can leave its one action to the icon.
29
+ */
30
+ captioned?: boolean;
31
+ /** Dimmed and not operable: a native `disabled` button. */
32
+ disabled?: boolean;
33
+ /**
34
+ * A write it started is in flight: a spinner replaces the icon, it reports
35
+ * `aria-busy` and `aria-disabled`, and a tap is ignored. It stays focusable,
36
+ * so the keyboard does not lose its place while the write answers.
37
+ */
38
+ loading?: boolean;
21
39
  dataTestId?: string;
22
40
  }
23
41
 
@@ -26,8 +44,9 @@ export interface RaisedActionButtonProps {
26
44
  * the row that is an ACTION rather than a place.
27
45
  *
28
46
  * Lifted half out of the bar so it reads as different in kind from the tabs
29
- * beside it, and unlabelled so it costs the row less width than a tab: that
30
- * width is what lets a bar keep four labelled tabs AND this button at 320px.
47
+ * beside it. Uncaptioned it costs the row less width than a tab: that width is
48
+ * what lets a bar keep four labelled tabs AND this button at 320px. A bar of
49
+ * verbs captions it, naming the act under the button.
31
50
  *
32
51
  * `SectionNav` draws it for its `primary` menu. It is exported on its own for
33
52
  * a bar the host already draws — drop it between two tabs, in a flex row.
@@ -38,44 +57,102 @@ export function RaisedActionButton({
38
57
  onClick,
39
58
  open,
40
59
  closeLabel,
60
+ captioned = false,
61
+ disabled = false,
62
+ loading = false,
41
63
  dataTestId = 'raised-action',
42
64
  }: RaisedActionButtonProps): React.JSX.Element {
43
65
  const theme = useTheme();
44
- const size = rem(theme, 52);
45
66
  const expanded = open === true;
46
67
  return (
47
- <Box sx={{ flex: 'none', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', px: 0.5 }}>
68
+ <Box
69
+ sx={{
70
+ flex: 'none',
71
+ display: 'flex',
72
+ flexDirection: 'column',
73
+ alignItems: 'center',
74
+ justifyContent: 'flex-start',
75
+ px: 0.5,
76
+ }}
77
+ >
48
78
  <Box
49
79
  component="button"
50
80
  type="button"
51
- onClick={onClick}
81
+ {...raisedState({ onClick, disabled, loading })}
52
82
  aria-label={expanded && closeLabel ? closeLabel : label}
53
83
  aria-expanded={open}
54
84
  data-testid={dataTestId}
55
- sx={{
56
- ...CONTROL_RESET,
57
- ...focusRing(theme),
58
- width: size,
59
- height: size,
60
- // Lifted half out of the bar: the one control that is not a place.
61
- marginTop: rem(theme, -18),
62
- borderRadius: '50%',
63
- display: 'flex',
64
- alignItems: 'center',
65
- justifyContent: 'center',
66
- bgcolor: 'primary.main',
67
- color: 'primary.contrastText',
68
- boxShadow: theme.shadows[6],
69
- '& svg': {
70
- fontSize: rem(theme, 26),
71
- transition: theme.transitions.create('transform', { duration: theme.transitions.duration.shorter }),
72
- transform: expanded ? 'rotate(45deg)' : 'none',
73
- },
74
- '@media (prefers-reduced-motion: reduce)': { '& svg': { transition: 'none' } },
75
- }}
85
+ sx={raisedSx(theme, expanded)}
76
86
  >
77
- {icon}
87
+ <SlotIcon icon={icon} loading={loading} />
78
88
  </Box>
89
+ {captioned ? <RaisedCaption label={label} inert={disabled || loading} /> : null}
79
90
  </Box>
80
91
  );
81
92
  }
93
+
94
+ /**
95
+ * The button's operability. Only `disabled` is native: an in-flight button
96
+ * keeps its focus, reports `aria-busy` and `aria-disabled`, and drops the click.
97
+ */
98
+ function raisedState({
99
+ onClick,
100
+ disabled,
101
+ loading,
102
+ }: {
103
+ onClick: () => void;
104
+ disabled: boolean;
105
+ loading: boolean;
106
+ }): Record<string, unknown> {
107
+ const busy = loading && !disabled;
108
+ return {
109
+ onClick: busy ? undefined : onClick,
110
+ disabled,
111
+ 'aria-disabled': busy ? 'true' : undefined,
112
+ 'aria-busy': loading || undefined,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * The label under a captioned button. Hidden from assistive tech: the button
118
+ * already carries the same words as its name, and reading them twice is noise.
119
+ */
120
+ function RaisedCaption({ label, inert }: { label: string; inert: boolean }): React.JSX.Element {
121
+ return (
122
+ <Typography
123
+ component="span"
124
+ variant="caption"
125
+ aria-hidden
126
+ sx={{ lineHeight: 1.3, mt: 0.25, whiteSpace: 'nowrap', color: inert ? 'text.disabled' : 'text.primary' }}
127
+ >
128
+ {label}
129
+ </Typography>
130
+ );
131
+ }
132
+
133
+ /** The round button: lifted half out of the bar, its icon turning while what it opened is open. */
134
+ function raisedSx(theme: Theme, expanded: boolean): SxProps<Theme> {
135
+ const size = rem(theme, 52);
136
+ return {
137
+ ...CONTROL_RESET,
138
+ ...focusRing(theme),
139
+ width: size,
140
+ height: size,
141
+ // Lifted half out of the bar: the one control that is not a place.
142
+ marginTop: rem(theme, -18),
143
+ borderRadius: '50%',
144
+ display: 'flex',
145
+ alignItems: 'center',
146
+ justifyContent: 'center',
147
+ bgcolor: 'primary.main',
148
+ color: 'primary.contrastText',
149
+ boxShadow: theme.shadows[6],
150
+ [NOT_LIVE]: { cursor: 'default', bgcolor: 'action.disabledBackground', color: 'text.disabled', boxShadow: 'none' },
151
+ '& svg': {
152
+ fontSize: rem(theme, 26),
153
+ transition: theme.transitions.create('transform', { duration: theme.transitions.duration.shorter }),
154
+ transform: expanded ? 'rotate(45deg)' : 'none',
155
+ },
156
+ '@media (prefers-reduced-motion: reduce)': { '& svg': { transition: 'none' } },
157
+ } as SxProps<Theme>;
158
+ }
@@ -5,8 +5,24 @@ import type { ElementType, ReactNode } from 'react';
5
5
  import type { SectionNavCopy } from '../../../copy';
6
6
  import { rem } from '../../../tokens/scales';
7
7
 
8
- import { Box, CONTROL_RESET, CountedIcon, controlProps, focusRing } from './SectionNav.parts';
9
- import type { SectionNavMenu, SectionNavProps } from './SectionNav.types';
8
+ import { destinationState, isMenu } from './SectionNav.helpers';
9
+ import {
10
+ Box,
11
+ CONTROL_RESET,
12
+ CountedIcon,
13
+ NOT_LIVE,
14
+ SlotIcon,
15
+ controlProps,
16
+ destinationControl,
17
+ focusRing,
18
+ } from './SectionNav.parts';
19
+ import type {
20
+ SectionNavAction,
21
+ SectionNavBack,
22
+ SectionNavDestination,
23
+ SectionNavMenu,
24
+ SectionNavProps,
25
+ } from './SectionNav.types';
10
26
 
11
27
  /**
12
28
  * One rail row: icon, label, and the count at the far end.
@@ -14,6 +30,10 @@ import type { SectionNavMenu, SectionNavProps } from './SectionNav.types';
14
30
  * Destinations and menu entries draw the same row — in the rail there is room
15
31
  * for everything, so the difference between "a place" and "an action" is the
16
32
  * section it sits in, not its shape.
33
+ *
34
+ * `active` is the look; `current` is what `aria-current` reports, and is a
35
+ * link's claim only — an action row that is ON says so with `aria-pressed`,
36
+ * which arrives in `control`. Omitted, `current` follows `active`.
17
37
  */
18
38
  function RailRow({
19
39
  label,
@@ -21,24 +41,29 @@ function RailRow({
21
41
  icon,
22
42
  count,
23
43
  active,
44
+ current = active,
24
45
  copy,
25
46
  testId,
26
47
  control,
48
+ loading = false,
27
49
  }: {
28
50
  label: string;
29
51
  description?: string;
30
52
  icon: ReactNode;
31
53
  count: number | undefined;
32
54
  active: boolean;
55
+ current?: boolean;
33
56
  copy: SectionNavCopy;
34
57
  testId: string;
35
58
  control: Record<string, unknown>;
59
+ loading?: boolean;
36
60
  }): React.JSX.Element {
37
61
  const theme = useTheme();
38
62
  return (
39
63
  <Box
40
64
  {...control}
41
- aria-current={active ? 'page' : undefined}
65
+ aria-current={current ? 'page' : undefined}
66
+ aria-busy={loading || undefined}
42
67
  data-testid={testId}
43
68
  sx={{
44
69
  ...CONTROL_RESET,
@@ -56,9 +81,15 @@ function RailRow({
56
81
  bgcolor: active ? 'action.selected' : 'transparent',
57
82
  '&:hover': { bgcolor: active ? 'action.selected' : 'action.hover' },
58
83
  '& svg': { color: 'primary.main', fontSize: rem(theme, 20) },
84
+ [NOT_LIVE]: { color: 'text.disabled', '& svg': { color: 'text.disabled' } },
59
85
  }}
60
86
  >
61
- <CountedIcon icon={icon} count={count} label={copy.badge} testId={`${testId}-badge`} />
87
+ <CountedIcon
88
+ icon={<SlotIcon icon={icon} loading={loading} />}
89
+ count={count}
90
+ label={copy.badge}
91
+ testId={`${testId}-badge`}
92
+ />
62
93
  <Box component="span" sx={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
63
94
  <Typography component="span" variant="body2" sx={{ fontWeight: active ? 700 : 500 }}>
64
95
  {label}
@@ -82,7 +113,11 @@ function RailHeading({ children }: { children: ReactNode }): React.JSX.Element {
82
113
  );
83
114
  }
84
115
 
85
- /** A menu's groups, listed under its title — nothing folded away on a wide screen. */
116
+ /**
117
+ * A menu's groups, listed under its title — nothing folded away on a wide
118
+ * screen. A `disabled` menu has no trigger here to refuse, so every row of it
119
+ * is inert instead.
120
+ */
86
121
  function RailMenu({
87
122
  menu,
88
123
  linkComponent,
@@ -113,8 +148,13 @@ function RailMenu({
113
148
  count={entry.badge}
114
149
  active={entry.active === true}
115
150
  copy={copy}
116
- testId={`${testId}-entry-${entry.id}`}
117
- control={controlProps({ href: entry.href, linkComponent, onClick: () => entry.onSelect?.() })}
151
+ testId={entry.dataTestId ?? `${testId}-entry-${entry.id}`}
152
+ control={controlProps({
153
+ href: entry.href,
154
+ linkComponent,
155
+ inert: menu.disabled === true || entry.disabled === true,
156
+ onClick: () => entry.onSelect?.(),
157
+ })}
118
158
  />
119
159
  ))}
120
160
  </Box>
@@ -123,6 +163,104 @@ function RailMenu({
123
163
  );
124
164
  }
125
165
 
166
+ /** The way back out of the section, at the top of the rail. */
167
+ function RailBack({
168
+ back,
169
+ linkComponent,
170
+ testId,
171
+ }: {
172
+ back: SectionNavBack;
173
+ linkComponent: ElementType | undefined;
174
+ testId: string;
175
+ }): React.JSX.Element {
176
+ const theme = useTheme();
177
+ return (
178
+ <Box
179
+ {...controlProps({ href: back.href, linkComponent })}
180
+ data-testid={testId}
181
+ sx={{
182
+ ...CONTROL_RESET,
183
+ ...focusRing(theme),
184
+ display: 'flex',
185
+ alignItems: 'center',
186
+ gap: 1,
187
+ px: 1.25,
188
+ py: 0.75,
189
+ mb: 0.5,
190
+ borderRadius: 1,
191
+ color: 'text.secondary',
192
+ '&:hover': { bgcolor: 'action.hover', color: 'text.primary' },
193
+ '& svg': { fontSize: rem(theme, 20) },
194
+ }}
195
+ >
196
+ {back.icon}
197
+ <Typography component="span" variant="body2" sx={{ fontWeight: 600 }}>
198
+ {back.label}
199
+ </Typography>
200
+ </Box>
201
+ );
202
+ }
203
+
204
+ /** A primary that ACTS, as one row: there is no sheet to list on a wide screen. */
205
+ function RailAction({
206
+ action,
207
+ linkComponent,
208
+ copy,
209
+ testId,
210
+ }: {
211
+ action: SectionNavAction;
212
+ linkComponent: ElementType | undefined;
213
+ copy: SectionNavCopy;
214
+ testId: string;
215
+ }): React.JSX.Element {
216
+ return (
217
+ <RailRow
218
+ label={action.label}
219
+ icon={action.icon}
220
+ count={undefined}
221
+ active={false}
222
+ copy={copy}
223
+ testId={action.dataTestId ?? testId}
224
+ loading={action.loading === true}
225
+ control={controlProps({
226
+ href: undefined,
227
+ linkComponent,
228
+ inert: action.disabled === true,
229
+ busy: action.loading === true,
230
+ onClick: action.onSelect,
231
+ })}
232
+ />
233
+ );
234
+ }
235
+
236
+ /** A destination as a row: a link, or an action (a toggle when it carries `active`). */
237
+ function RailDestination({
238
+ destination,
239
+ linkComponent,
240
+ copy,
241
+ testId,
242
+ }: {
243
+ destination: SectionNavDestination;
244
+ linkComponent: ElementType | undefined;
245
+ copy: SectionNavCopy;
246
+ testId: string;
247
+ }): React.JSX.Element {
248
+ const { current, lit } = destinationState(destination);
249
+ return (
250
+ <RailRow
251
+ label={destination.label}
252
+ icon={destination.icon}
253
+ count={destination.badge}
254
+ active={lit}
255
+ current={current}
256
+ copy={copy}
257
+ testId={testId}
258
+ loading={destination.loading === true}
259
+ control={destinationControl(destination, linkComponent, () => destination.onSelect?.())}
260
+ />
261
+ );
262
+ }
263
+
126
264
  /**
127
265
  * The wide-screen layout: a vertical rail — the way back, the destinations,
128
266
  * then every menu's entries.
@@ -144,7 +282,6 @@ export function SectionNavRail({
144
282
  Pick<SectionNavProps, 'primary' | 'more' | 'back' | 'heading'> & {
145
283
  linkComponent: ElementType | undefined;
146
284
  }): React.JSX.Element {
147
- const theme = useTheme();
148
285
  return (
149
286
  <Box
150
287
  component="nav"
@@ -161,45 +298,21 @@ export function SectionNavRail({
161
298
  bgcolor: 'background.paper',
162
299
  }}
163
300
  >
164
- {back ? (
165
- <Box
166
- {...controlProps({ href: back.href, linkComponent })}
167
- data-testid={`${dataTestId}-back`}
168
- sx={{
169
- ...CONTROL_RESET,
170
- ...focusRing(theme),
171
- display: 'flex',
172
- alignItems: 'center',
173
- gap: 1,
174
- px: 1.25,
175
- py: 0.75,
176
- mb: 0.5,
177
- borderRadius: 1,
178
- color: 'text.secondary',
179
- '&:hover': { bgcolor: 'action.hover', color: 'text.primary' },
180
- '& svg': { fontSize: rem(theme, 20) },
181
- }}
182
- >
183
- {back.icon}
184
- <Typography component="span" variant="body2" sx={{ fontWeight: 600 }}>
185
- {back.label}
186
- </Typography>
187
- </Box>
188
- ) : null}
301
+ {back ? <RailBack back={back} linkComponent={linkComponent} testId={`${dataTestId}-back`} /> : null}
189
302
  {heading ? <RailHeading>{heading}</RailHeading> : null}
190
303
  {destinations.map((destination) => (
191
- <RailRow
304
+ <RailDestination
192
305
  key={destination.id}
193
- label={destination.label}
194
- icon={destination.icon}
195
- count={destination.badge}
196
- active={destination.active === true}
306
+ destination={destination}
307
+ linkComponent={linkComponent}
197
308
  copy={copy}
198
- testId={`${dataTestId}-dest-${destination.id}`}
199
- control={controlProps({ href: destination.href, linkComponent })}
309
+ testId={destination.dataTestId ?? `${dataTestId}-dest-${destination.id}`}
200
310
  />
201
311
  ))}
202
- {primary ? (
312
+ {primary && !isMenu(primary) ? (
313
+ <RailAction action={primary} linkComponent={linkComponent} copy={copy} testId={`${dataTestId}-primary`} />
314
+ ) : null}
315
+ {isMenu(primary) ? (
203
316
  <RailMenu menu={primary} linkComponent={linkComponent} copy={copy} testId={`${dataTestId}-primary`} />
204
317
  ) : null}
205
318
  {more ? <RailMenu menu={more} linkComponent={linkComponent} copy={copy} testId={`${dataTestId}-more`} /> : null}
@@ -25,13 +25,14 @@ function GridEntry({ entry, linkComponent, copy, testId, onDone }: EntryProps):
25
25
  {...controlProps({
26
26
  href: entry.href,
27
27
  linkComponent,
28
+ inert: entry.disabled === true,
28
29
  onClick: () => {
29
30
  entry.onSelect?.();
30
31
  onDone();
31
32
  },
32
33
  })}
33
34
  aria-current={entry.active ? 'page' : undefined}
34
- data-testid={`${testId}-entry-${entry.id}`}
35
+ data-testid={entry.dataTestId ?? `${testId}-entry-${entry.id}`}
35
36
  sx={{
36
37
  ...CONTROL_RESET,
37
38
  ...focusRing(theme),
@@ -48,6 +49,7 @@ function GridEntry({ entry, linkComponent, copy, testId, onDone }: EntryProps):
48
49
  bgcolor: entry.active ? 'action.selected' : 'action.hover',
49
50
  color: entry.active ? 'primary.main' : 'text.primary',
50
51
  '& svg': { color: 'primary.main' },
52
+ '&:disabled': { color: 'text.disabled', '& svg': { color: 'text.disabled' } },
51
53
  }}
52
54
  >
53
55
  <CountedIcon icon={entry.icon} count={entry.badge} label={copy.badge} testId={`${testId}-badge-${entry.id}`} />
@@ -66,13 +68,14 @@ function ListEntry({ entry, linkComponent, copy, testId, onDone }: EntryProps):
66
68
  {...controlProps({
67
69
  href: entry.href,
68
70
  linkComponent,
71
+ inert: entry.disabled === true,
69
72
  onClick: () => {
70
73
  entry.onSelect?.();
71
74
  onDone();
72
75
  },
73
76
  })}
74
77
  aria-current={entry.active ? 'page' : undefined}
75
- data-testid={`${testId}-entry-${entry.id}`}
78
+ data-testid={entry.dataTestId ?? `${testId}-entry-${entry.id}`}
76
79
  sx={{
77
80
  ...CONTROL_RESET,
78
81
  ...focusRing(theme),
@@ -87,6 +90,7 @@ function ListEntry({ entry, linkComponent, copy, testId, onDone }: EntryProps):
87
90
  textAlign: 'start',
88
91
  bgcolor: entry.active ? 'action.selected' : 'action.hover',
89
92
  '& svg': { color: 'primary.main' },
93
+ '&:disabled': { color: 'text.disabled', '& svg': { color: 'text.disabled' } },
90
94
  }}
91
95
  >
92
96
  <CountedIcon icon={entry.icon} count={entry.badge} label={copy.badge} testId={`${testId}-badge-${entry.id}`} />
@@ -3,21 +3,61 @@ import type { ElementType, ReactNode } from 'react';
3
3
  import type { SectionNavCopy } from '../../../copy';
4
4
 
5
5
  /**
6
- * One place the section leads to — a bar slot on a phone, a row in the rail.
6
+ * One slot of the section — a bar slot on a phone, a row in the rail.
7
+ *
8
+ * A slot either LEADS somewhere (`href`, rendered through `linkComponent`) or
9
+ * DOES something (`onSelect`), the way a menu entry does. A bar of places is a
10
+ * section's navigation; a bar of verbs is a screen's actions pinned at its
11
+ * foot, and it is the same bar, so the two cannot be drawn two ways. Give a
12
+ * slot one or the other.
7
13
  */
8
14
  export interface SectionNavDestination {
9
15
  /** Stable key. Also the suffix of the slot's `data-testid`. */
10
16
  id: string;
11
17
  label: string;
12
18
  icon: ReactNode;
13
- href: string;
19
+ href?: string;
20
+ /**
21
+ * What the slot does, for an action slot. Ignored while `disabled` or
22
+ * `loading`; still called while `dimmed`.
23
+ */
24
+ onSelect?: () => void;
25
+ /**
26
+ * Drawn dimmed and not operable: the act exists here, and cannot be done
27
+ * now. A native `disabled` button — a tap does nothing, and `onSelect` is
28
+ * not called.
29
+ */
30
+ disabled?: boolean;
31
+ /**
32
+ * Drawn exactly like `disabled`, but still TAPPABLE: for an act the screen
33
+ * cannot do now and should EXPLAIN when tapped. It reports
34
+ * `aria-disabled="true"`, stays focusable, never lights up, and its
35
+ * `onSelect` still fires so the host can answer with a message (why not,
36
+ * and what would make it possible).
37
+ */
38
+ dimmed?: boolean;
39
+ /**
40
+ * A write this slot started is in flight — a spinner replaces the icon, the
41
+ * slot reports `aria-busy` and `aria-disabled`, and a tap is ignored. It
42
+ * stays focusable, so the keyboard does not lose its place mid-write.
43
+ */
44
+ loading?: boolean;
45
+ /**
46
+ * The control's own `data-testid`, in place of the one the nav derives. For
47
+ * a host whose suites already drive an id of their own shape.
48
+ */
49
+ dataTestId?: string;
14
50
  /**
15
51
  * How many things behind this destination wait on the viewer. `0` and
16
52
  * `undefined` render no badge — "nothing waiting" and "not known yet" are
17
53
  * different claims and neither is a number.
18
54
  */
19
55
  badge?: number;
20
- /** The destination the viewer is on. The host decides; the nav never reads the URL. */
56
+ /**
57
+ * For a link: the destination the viewer is on (`aria-current="page"`). For
58
+ * an action slot: a toggle that is ON (`aria-pressed`). The host decides;
59
+ * the nav never reads the URL.
60
+ */
21
61
  active?: boolean;
22
62
  }
23
63
 
@@ -37,6 +77,13 @@ export interface SectionNavEntry {
37
77
  onSelect?: () => void;
38
78
  badge?: number;
39
79
  active?: boolean;
80
+ /** Drawn dimmed and not operable — a print already on its way, an act this state refuses. */
81
+ disabled?: boolean;
82
+ /**
83
+ * The control's own `data-testid`, in place of the one the nav derives. For
84
+ * a host whose suites already drive an id of their own shape.
85
+ */
86
+ dataTestId?: string;
40
87
  }
41
88
 
42
89
  /** A titled run of entries inside a menu. */
@@ -67,6 +114,33 @@ export interface SectionNavMenu {
67
114
  /** The sheet's heading, and the rail section's. */
68
115
  title: string;
69
116
  groups: SectionNavGroup[];
117
+ /** The trigger is drawn dimmed and does not open; in the rail every row of it is inert. */
118
+ disabled?: boolean;
119
+ /**
120
+ * The control's own `data-testid`, in place of the one the nav derives. For
121
+ * a host whose suites already drive an id of their own shape.
122
+ */
123
+ dataTestId?: string;
124
+ }
125
+
126
+ /**
127
+ * The raised primary as a plain ACTION rather than a menu: one act, done on
128
+ * tap. Its label is drawn under the button, because a bar of verbs names the
129
+ * act it is for rather than leaving it to an icon.
130
+ */
131
+ export interface SectionNavAction {
132
+ label: string;
133
+ icon: ReactNode;
134
+ onSelect: () => void;
135
+ /** A native `disabled` button: a tap does nothing. */
136
+ disabled?: boolean;
137
+ /** In flight: a spinner, `aria-busy`, `aria-disabled`; focusable, and a tap is ignored. */
138
+ loading?: boolean;
139
+ /**
140
+ * The control's own `data-testid`, in place of the one the nav derives. For
141
+ * a host whose suites already drive an id of their own shape.
142
+ */
143
+ dataTestId?: string;
70
144
  }
71
145
 
72
146
  /** The way out of the section, drawn at the top of the rail. */
@@ -88,8 +162,11 @@ export interface SectionNavProps {
88
162
  /** The navigation landmark's accessible name. */
89
163
  label: string;
90
164
  destinations: SectionNavDestination[];
91
- /** The raised action in the middle of the bar. */
92
- primary?: SectionNavMenu;
165
+ /**
166
+ * The raised button in the middle of the bar: a menu it opens as a sheet,
167
+ * or an action it does on tap (drawn with its label under it).
168
+ */
169
+ primary?: SectionNavMenu | SectionNavAction;
93
170
  /** The last slot of the bar. Its badge is the sum of its entries' badges. */
94
171
  more?: SectionNavMenu;
95
172
  /** Rail only: the way back out of the section. */
@@ -16,7 +16,7 @@ import React from 'react';
16
16
 
17
17
  import { sxRem } from '../../../../tokens/scales';
18
18
 
19
- import type { SectionNavDestination, SectionNavMenu } from '../SectionNav.types';
19
+ import type { SectionNavAction, SectionNavDestination, SectionNavMenu } from '../SectionNav.types';
20
20
 
21
21
  /**
22
22
  * The stories' example section, shared by the showcase and the interaction
@@ -109,3 +109,23 @@ export function PhoneFrame({ children }: { children: React.ReactNode }): React.R
109
109
  export function RailFrame({ children }: { children: React.ReactNode }): React.ReactElement {
110
110
  return <Box sx={{ width: sxRem(240), height: sxRem(560), border: 1, borderColor: 'divider' }}>{children}</Box>;
111
111
  }
112
+
113
+ /**
114
+ * A screen's ACTIONS pinned at its foot — the same bar, drawn with verbs: a
115
+ * bill being settled, one person at a time.
116
+ *
117
+ * `split` is DIMMED — it cannot be done yet, and a tap still reaches the host
118
+ * so it can say why. `equal` is a TOGGLE that is on: `aria-pressed`, never
119
+ * `aria-current`, because an action is not a page.
120
+ */
121
+ export const ACTIONS: SectionNavDestination[] = [
122
+ { id: 'split', label: 'Split', icon: <ReceiptLongOutlinedIcon />, onSelect: () => undefined, dimmed: true },
123
+ { id: 'equal', label: 'Equally', icon: <GroupsOutlinedIcon />, onSelect: () => undefined, active: true },
124
+ { id: 'person', label: 'Person', icon: <AddIcon />, onSelect: () => undefined },
125
+ ];
126
+
127
+ export const PAY_ALL: SectionNavAction = {
128
+ label: 'Pay all',
129
+ icon: <StorefrontOutlinedIcon />,
130
+ onSelect: () => undefined,
131
+ };
@@ -3,6 +3,7 @@ export { sectionNavBarInset } from './SectionNav.bar';
3
3
  export { RaisedActionButton } from './SectionNav.primary';
4
4
  export type { RaisedActionButtonProps } from './SectionNav.primary';
5
5
  export type {
6
+ SectionNavAction,
6
7
  SectionNavBack,
7
8
  SectionNavDestination,
8
9
  SectionNavEntry,