@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,12 +1,17 @@
|
|
|
1
|
-
import
|
|
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
|
-
/**
|
|
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
|
|
30
|
-
*
|
|
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
|
|
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
|
-
|
|
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 {
|
|
9
|
-
import
|
|
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={
|
|
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
|
|
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
|
-
/**
|
|
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({
|
|
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
|
-
<
|
|
304
|
+
<RailDestination
|
|
192
305
|
key={destination.id}
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
92
|
-
|
|
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,
|