@12-apps/ui 6.50.1 → 6.51.0

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 (60) 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 +5 -5
  4. package/dist/_internal/components/navigation/SectionNav/SectionNav.helpers.js +5 -1
  5. package/dist/_internal/components/navigation/SectionNav/SectionNav.js +7 -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 +7 -7
  13. package/dist/{chunk-GLKQT2DS.js → chunk-A436VORP.js} +33 -4
  14. package/dist/chunk-A436VORP.js.map +1 -0
  15. package/dist/{chunk-RX7J6VRI.js → chunk-HKEBTPKY.js} +119 -42
  16. package/dist/chunk-HKEBTPKY.js.map +1 -0
  17. package/dist/{chunk-VHVI3D4R.js → chunk-P3GBXE73.js} +11 -2
  18. package/dist/chunk-P3GBXE73.js.map +1 -0
  19. package/dist/{chunk-32JMSHEA.js → chunk-S4ZUZ6DS.js} +10 -6
  20. package/dist/chunk-S4ZUZ6DS.js.map +1 -0
  21. package/dist/{chunk-SA2ERYVV.js → chunk-VAO63IIP.js} +3 -3
  22. package/dist/{chunk-43Q6CVPB.js → chunk-VELGOFCL.js} +127 -78
  23. package/dist/chunk-VELGOFCL.js.map +1 -0
  24. package/dist/chunk-WNKRFMSI.js +113 -0
  25. package/dist/chunk-WNKRFMSI.js.map +1 -0
  26. package/dist/navigation/SectionNav.js +7 -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 +161 -88
  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.map +0 -1
  55. package/dist/chunk-GLKQT2DS.js.map +0 -1
  56. package/dist/chunk-RX7J6VRI.js.map +0 -1
  57. package/dist/chunk-RZ4SBSLP.js +0 -62
  58. package/dist/chunk-RZ4SBSLP.js.map +0 -1
  59. package/dist/chunk-VHVI3D4R.js.map +0 -1
  60. /package/dist/{chunk-SA2ERYVV.js.map → chunk-VAO63IIP.js.map} +0 -0
@@ -1,15 +1,24 @@
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
6
  import { rem } from '../../../tokens/scales';
7
7
 
8
- import { barSlots, menuActive, menuCount, type BarSlot } from './SectionNav.helpers';
9
- import { Box, CONTROL_RESET, CountedIcon, controlProps, focusRing } from './SectionNav.parts';
8
+ import { barSlots, destinationState, isMenu, menuActive, menuCount, type BarSlot } 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';
10
19
  import { RaisedActionButton } from './SectionNav.primary';
11
20
  import { SectionNavSheet } from './SectionNav.sheet';
12
- import type { SectionNavMenu, SectionNavProps } from './SectionNav.types';
21
+ import type { SectionNavAction, SectionNavDestination, SectionNavMenu, SectionNavProps } from './SectionNav.types';
13
22
 
14
23
  /** Which of the bar's two menus is open, if any. */
15
24
  type OpenMenu = 'primary' | 'more' | null;
@@ -29,13 +38,17 @@ export function sectionNavBarInset(theme: Parameters<typeof rem>[0]): string {
29
38
  /**
30
39
  * One slot: icon over label, a count on the icon.
31
40
  *
32
- * `current` is the page the viewer is on and is what `aria-current` reports;
33
- * `lit` is only how the slot looks, and goes out while a sheet is open so the
34
- * bar shows where the thumb is. Opening a sheet is `aria-expanded`'s to say.
41
+ * `current` is the page the viewer is on and is what `aria-current` reports —
42
+ * a link's claim only; an action slot that is ON says so with `aria-pressed`,
43
+ * which arrives in `control`. `lit` is only how the slot looks, and goes out
44
+ * while a sheet is open so the bar shows where the thumb is. Opening a sheet
45
+ * is `aria-expanded`'s to say.
35
46
  *
36
- * A destination is a link; the "more" slot is a button that opens its sheet.
37
- * Either way the whole slot is the target, so a thumb landing on the label
38
- * lands on the control.
47
+ * A destination is a link or an action; the "more" slot is a button that
48
+ * opens its sheet. Either way the whole slot is the target, so a thumb landing
49
+ * on the label lands on the control. A `disabled` slot is a disabled button,
50
+ * dimmed — the act is still where the hand expects it. A `loading` or
51
+ * `dimmed` slot is drawn the same and stays focusable (`aria-disabled`).
39
52
  */
40
53
  function Slot({
41
54
  label,
@@ -47,6 +60,7 @@ function Slot({
47
60
  testId,
48
61
  control,
49
62
  expanded,
63
+ loading = false,
50
64
  }: {
51
65
  label: string;
52
66
  icon: ReactNode;
@@ -57,6 +71,7 @@ function Slot({
57
71
  testId: string;
58
72
  control: Record<string, unknown>;
59
73
  expanded?: boolean;
74
+ loading?: boolean;
60
75
  }): React.JSX.Element {
61
76
  const theme = useTheme();
62
77
  return (
@@ -64,36 +79,17 @@ function Slot({
64
79
  {...control}
65
80
  aria-current={current ? 'page' : undefined}
66
81
  aria-expanded={expanded}
82
+ aria-busy={loading || undefined}
67
83
  data-lit={lit ? 'true' : undefined}
68
84
  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
- }}
85
+ sx={slotSx(theme, lit)}
95
86
  >
96
- <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
+ />
97
93
  <Typography
98
94
  component="span"
99
95
  variant="caption"
@@ -112,71 +108,152 @@ function Slot({
112
108
  );
113
109
  }
114
110
 
115
- /** One slot of the bar, whichever kind it is. */
116
- function BarSlotView({
117
- slot,
118
- primary,
119
- more,
120
- open,
121
- toggle,
122
- close,
123
- linkComponent,
124
- copy,
125
- dataTestId,
126
- }: {
127
- slot: BarSlot;
128
- primary: SectionNavMenu | undefined;
129
- more: SectionNavMenu | undefined;
111
+ /** A slot's look: icon over label, the lit rule on its top edge, dimmed when disabled. */
112
+ function slotSx(theme: Theme, lit: boolean): SxProps<Theme> {
113
+ return {
114
+ ...CONTROL_RESET,
115
+ ...focusRing(theme),
116
+ flex: 1,
117
+ minWidth: 0,
118
+ display: 'flex',
119
+ flexDirection: 'column',
120
+ alignItems: 'center',
121
+ justifyContent: 'center',
122
+ gap: 0.25,
123
+ position: 'relative',
124
+ color: lit ? 'primary.main' : 'text.secondary',
125
+ // The current slot is marked by more than its colour: a short rule on
126
+ // its top edge, so the state survives a colour-blind reading.
127
+ '&[data-lit="true"]::before': {
128
+ content: '""',
129
+ position: 'absolute',
130
+ top: 0,
131
+ insetInline: '22%',
132
+ height: rem(theme, 3),
133
+ borderBottomLeftRadius: rem(theme, 3),
134
+ borderBottomRightRadius: rem(theme, 3),
135
+ bgcolor: 'primary.main',
136
+ },
137
+ '& svg': { fontSize: rem(theme, 22) },
138
+ [NOT_LIVE]: { color: 'text.disabled' },
139
+ } as SxProps<Theme>;
140
+ }
141
+
142
+ /** What every slot kind is handed: the bar's open state and how to change it. */
143
+ interface SlotContext {
130
144
  open: OpenMenu;
131
145
  toggle: (menu: Exclude<OpenMenu, null>) => void;
132
146
  close: () => void;
133
147
  linkComponent: ElementType | undefined;
134
148
  copy: SectionNavCopy;
135
149
  dataTestId: string;
136
- }): React.JSX.Element | null {
137
- if (slot.kind === 'primary' && primary) {
150
+ }
151
+
152
+ /** The raised button: an action runs on tap; a menu opens its sheet. */
153
+ function PrimarySlot({
154
+ primary,
155
+ context,
156
+ }: {
157
+ primary: SectionNavMenu | SectionNavAction;
158
+ context: SlotContext;
159
+ }): React.JSX.Element {
160
+ const testId = primary.dataTestId ?? `${context.dataTestId}-primary`;
161
+ if (!isMenu(primary)) {
138
162
  return (
139
163
  <RaisedActionButton
140
164
  label={primary.label}
141
165
  icon={primary.icon}
142
- open={open === 'primary'}
143
- closeLabel={copy.close}
144
- dataTestId={`${dataTestId}-primary`}
145
- onClick={() => toggle('primary')}
146
- />
147
- );
148
- }
149
- if (slot.kind === 'more' && more) {
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') })}
166
+ captioned
167
+ disabled={primary.disabled}
168
+ loading={primary.loading}
169
+ dataTestId={testId}
170
+ onClick={() => {
171
+ context.close();
172
+ primary.onSelect();
173
+ }}
161
174
  />
162
175
  );
163
176
  }
164
- if (slot.kind !== 'destination') return null;
165
- const { destination } = slot;
177
+ return (
178
+ <RaisedActionButton
179
+ label={primary.label}
180
+ icon={primary.icon}
181
+ open={context.open === 'primary'}
182
+ closeLabel={context.copy.close}
183
+ disabled={primary.disabled}
184
+ dataTestId={testId}
185
+ onClick={() => context.toggle('primary')}
186
+ />
187
+ );
188
+ }
189
+
190
+ /** The "more" slot: a button that opens its sheet, carrying its entries' counts. */
191
+ function MoreSlot({ more, context }: { more: SectionNavMenu; context: SlotContext }): React.JSX.Element {
192
+ const { open } = context;
193
+ return (
194
+ <Slot
195
+ label={more.label}
196
+ icon={more.icon}
197
+ count={menuCount(more)}
198
+ current={false}
199
+ lit={open === 'more' || (open === null && menuActive(more))}
200
+ expanded={open === 'more'}
201
+ copy={context.copy}
202
+ testId={more.dataTestId ?? `${context.dataTestId}-more`}
203
+ control={controlProps({
204
+ href: undefined,
205
+ linkComponent: context.linkComponent,
206
+ inert: more.disabled === true,
207
+ onClick: () => context.toggle('more'),
208
+ })}
209
+ />
210
+ );
211
+ }
212
+
213
+ /** A destination: a link, or an action slot (a toggle when it carries `active`). */
214
+ function DestinationSlot({
215
+ destination,
216
+ context,
217
+ }: {
218
+ destination: SectionNavDestination;
219
+ context: SlotContext;
220
+ }): React.JSX.Element {
221
+ const { current, lit } = destinationState(destination);
166
222
  return (
167
223
  <Slot
168
224
  label={destination.label}
169
225
  icon={destination.icon}
170
226
  count={destination.badge}
171
- current={destination.active === true}
172
- lit={open === null && destination.active === true}
173
- copy={copy}
174
- testId={`${dataTestId}-dest-${destination.id}`}
175
- control={controlProps({ href: destination.href, linkComponent, onClick: close })}
227
+ current={current}
228
+ lit={context.open === null && lit}
229
+ copy={context.copy}
230
+ testId={destination.dataTestId ?? `${context.dataTestId}-dest-${destination.id}`}
231
+ loading={destination.loading === true}
232
+ control={destinationControl(destination, context.linkComponent, () => {
233
+ context.close();
234
+ destination.onSelect?.();
235
+ })}
176
236
  />
177
237
  );
178
238
  }
179
239
 
240
+ /** One slot of the bar, whichever kind it is. */
241
+ function BarSlotView({
242
+ slot,
243
+ primary,
244
+ more,
245
+ context,
246
+ }: {
247
+ slot: BarSlot;
248
+ primary: SectionNavMenu | SectionNavAction | undefined;
249
+ more: SectionNavMenu | undefined;
250
+ context: SlotContext;
251
+ }): React.JSX.Element | null {
252
+ if (slot.kind === 'primary') return primary ? <PrimarySlot primary={primary} context={context} /> : null;
253
+ if (slot.kind === 'more') return more ? <MoreSlot more={more} context={context} /> : null;
254
+ return <DestinationSlot destination={slot.destination} context={context} />;
255
+ }
256
+
180
257
  /** The phone layout: a bottom bar, with its menus as bottom sheets. */
181
258
  export function SectionNavBar({
182
259
  label,
@@ -188,6 +265,7 @@ export function SectionNavBar({
188
265
  dataTestId,
189
266
  }: Required<Pick<SectionNavProps, 'label' | 'destinations' | 'copy' | 'dataTestId'>> &
190
267
  Pick<SectionNavProps, 'primary' | 'more'> & { linkComponent: ElementType | undefined }): React.JSX.Element {
268
+ const sheetMenu = isMenu(primary) ? primary : undefined;
191
269
  const theme = useTheme();
192
270
  const [open, setOpen] = useState<OpenMenu>(null);
193
271
  const toggle = (menu: Exclude<OpenMenu, null>): void => setOpen((current) => (current === menu ? null : menu));
@@ -222,18 +300,13 @@ export function SectionNavBar({
222
300
  slot={slot}
223
301
  primary={primary}
224
302
  more={more}
225
- open={open}
226
- toggle={toggle}
227
- close={close}
228
- linkComponent={linkComponent}
229
- copy={copy}
230
- dataTestId={dataTestId}
303
+ context={{ open, toggle, close, linkComponent, copy, dataTestId }}
231
304
  />
232
305
  ))}
233
306
  </Box>
234
- {primary ? (
307
+ {sheetMenu ? (
235
308
  <SectionNavSheet
236
- menu={primary}
309
+ menu={sheetMenu}
237
310
  open={open === 'primary'}
238
311
  onClose={close}
239
312
  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?` and `active?`.
35
- - **primary**: a `SectionNavMenu` (`label`, `icon`, `title`, `groups`). In the
36
- bar it is the raised button, whose name becomes `copy.close` while its sheet
37
- is open.
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 };