@12-apps/ui 5.0.1 → 5.1.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.
- package/components-catalog.md +7 -0
- package/dist/layout/SettingsLayout.js +560 -108
- package/dist/layout/SettingsLayout.js.map +1 -1
- package/dist/types/components/layout/SettingsLayout/SettingsLayout.d.ts +19 -2
- package/dist/types/components/layout/SettingsLayout/SettingsLayout.d.ts.map +1 -1
- package/dist/types/components/layout/SettingsLayout/SettingsLayout.filters.d.ts +6 -0
- package/dist/types/components/layout/SettingsLayout/SettingsLayout.filters.d.ts.map +1 -0
- package/dist/types/components/layout/SettingsLayout/SettingsLayout.items.d.ts +31 -0
- package/dist/types/components/layout/SettingsLayout/SettingsLayout.items.d.ts.map +1 -0
- package/dist/types/components/layout/SettingsLayout/{SettingsLayout.parts.d.ts → SettingsLayout.rail.d.ts} +6 -9
- package/dist/types/components/layout/SettingsLayout/SettingsLayout.rail.d.ts.map +1 -0
- package/dist/types/components/layout/SettingsLayout/SettingsLayout.styles.d.ts +35 -0
- package/dist/types/components/layout/SettingsLayout/SettingsLayout.styles.d.ts.map +1 -0
- package/dist/types/components/layout/SettingsLayout/SettingsLayout.types.d.ts +99 -0
- package/dist/types/components/layout/SettingsLayout/SettingsLayout.types.d.ts.map +1 -1
- package/dist/types/components/layout/SettingsLayout/SettingsPanel.d.ts +29 -0
- package/dist/types/components/layout/SettingsLayout/SettingsPanel.d.ts.map +1 -0
- package/dist/types/components/layout/SettingsLayout/SettingsSectionChips.d.ts +26 -0
- package/dist/types/components/layout/SettingsLayout/SettingsSectionChips.d.ts.map +1 -0
- package/dist/types/components/layout/SettingsLayout/SettingsStatusMarker.d.ts +28 -0
- package/dist/types/components/layout/SettingsLayout/SettingsStatusMarker.d.ts.map +1 -0
- package/dist/types/components/layout/SettingsLayout/index.d.ts +5 -1
- package/dist/types/components/layout/SettingsLayout/index.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/layout/SettingsLayout/SettingsLayout.filters.ts +21 -0
- package/src/components/layout/SettingsLayout/SettingsLayout.items.tsx +228 -0
- package/src/components/layout/SettingsLayout/SettingsLayout.md +93 -1
- package/src/components/layout/SettingsLayout/SettingsLayout.rail.tsx +279 -0
- package/src/components/layout/SettingsLayout/SettingsLayout.styles.ts +64 -0
- package/src/components/layout/SettingsLayout/SettingsLayout.tsx +114 -24
- package/src/components/layout/SettingsLayout/SettingsLayout.types.ts +103 -0
- package/src/components/layout/SettingsLayout/SettingsPanel.tsx +140 -0
- package/src/components/layout/SettingsLayout/SettingsSectionChips.tsx +0 -0
- package/src/components/layout/SettingsLayout/SettingsStatusMarker.tsx +75 -0
- package/src/components/layout/SettingsLayout/index.ts +8 -0
- package/dist/types/components/layout/SettingsLayout/SettingsLayout.parts.d.ts.map +0 -1
- package/src/components/layout/SettingsLayout/SettingsLayout.parts.tsx +0 -358
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SettingsNavGroup, SettingsNavItem } from './SettingsLayout.types';
|
|
2
|
+
|
|
3
|
+
/** Case-insensitive filter over item label + keywords; drops emptied groups. */
|
|
4
|
+
export function filterGroups(groups: SettingsNavGroup[], query: string): SettingsNavGroup[] {
|
|
5
|
+
const needle = query.trim().toLowerCase();
|
|
6
|
+
if (!needle) return groups;
|
|
7
|
+
const matches = (item: SettingsNavItem): boolean =>
|
|
8
|
+
item.label.toLowerCase().includes(needle) ||
|
|
9
|
+
(item.keywords ?? []).some((keyword) => keyword.toLowerCase().includes(needle));
|
|
10
|
+
return groups
|
|
11
|
+
.map((group) => ({ ...group, items: group.items.filter(matches) }))
|
|
12
|
+
.filter((group) => group.items.length > 0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Label of the active item, shown in the `switcher`'s collapsed header. */
|
|
16
|
+
export function activeItemLabel(
|
|
17
|
+
groups: SettingsNavGroup[],
|
|
18
|
+
activeItemId?: string,
|
|
19
|
+
): string | undefined {
|
|
20
|
+
return groups.flatMap((group) => group.items).find((item) => item.id === activeItemId)?.label;
|
|
21
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
alpha,
|
|
5
|
+
Box,
|
|
6
|
+
List,
|
|
7
|
+
ListItem,
|
|
8
|
+
ListItemButton,
|
|
9
|
+
ListItemIcon,
|
|
10
|
+
ListItemText,
|
|
11
|
+
Typography,
|
|
12
|
+
useTheme,
|
|
13
|
+
} from '@mui/material';
|
|
14
|
+
import React from 'react';
|
|
15
|
+
|
|
16
|
+
import { atLeastRail, TOUCH_TARGET } from './SettingsLayout.styles';
|
|
17
|
+
import { SettingsStatusMarker } from './SettingsStatusMarker';
|
|
18
|
+
import type {
|
|
19
|
+
SettingsLayoutProps,
|
|
20
|
+
SettingsNavGroup,
|
|
21
|
+
SettingsNavItem,
|
|
22
|
+
SettingsNavVariant,
|
|
23
|
+
SettingsRailBreakpoint,
|
|
24
|
+
} from './SettingsLayout.types';
|
|
25
|
+
|
|
26
|
+
/** The shape-deciding inputs every rail part needs to know about. */
|
|
27
|
+
export interface RailShape {
|
|
28
|
+
variant: SettingsNavVariant;
|
|
29
|
+
breakpoint: SettingsRailBreakpoint;
|
|
30
|
+
atIndex: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface RailItemProps extends RailShape {
|
|
34
|
+
item: SettingsNavItem;
|
|
35
|
+
active: boolean;
|
|
36
|
+
linkComponent?: SettingsLayoutProps['linkComponent'];
|
|
37
|
+
onSelectItem?: (id: string) => void;
|
|
38
|
+
/** Called on activation so the `switcher`'s rail can collapse after a choice. */
|
|
39
|
+
onNavigate?: () => void;
|
|
40
|
+
testIdPrefix: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Label, plus the situation marker when the host resolved one. */
|
|
44
|
+
function RailItemContent({
|
|
45
|
+
item,
|
|
46
|
+
active,
|
|
47
|
+
testIdPrefix,
|
|
48
|
+
}: {
|
|
49
|
+
item: SettingsNavItem;
|
|
50
|
+
active: boolean;
|
|
51
|
+
testIdPrefix: string;
|
|
52
|
+
}): React.JSX.Element {
|
|
53
|
+
return (
|
|
54
|
+
<>
|
|
55
|
+
{item.icon ? (
|
|
56
|
+
<ListItemIcon sx={{ minWidth: 0, color: 'inherit' }}>{item.icon}</ListItemIcon>
|
|
57
|
+
) : null}
|
|
58
|
+
<ListItemText
|
|
59
|
+
primary={item.label}
|
|
60
|
+
primaryTypographyProps={{ variant: 'body2', noWrap: true, fontWeight: active ? 600 : 500 }}
|
|
61
|
+
/>
|
|
62
|
+
{item.status ? (
|
|
63
|
+
<SettingsStatusMarker
|
|
64
|
+
status={item.status}
|
|
65
|
+
label={item.statusLabel}
|
|
66
|
+
testId={`${testIdPrefix}-status-${item.id}`}
|
|
67
|
+
/>
|
|
68
|
+
) : null}
|
|
69
|
+
</>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One rail entry — a link when it has `href`, inert text when it says so, else a button. */
|
|
74
|
+
export function SettingsRailItem({
|
|
75
|
+
item,
|
|
76
|
+
active,
|
|
77
|
+
linkComponent,
|
|
78
|
+
onSelectItem,
|
|
79
|
+
onNavigate,
|
|
80
|
+
testIdPrefix,
|
|
81
|
+
variant,
|
|
82
|
+
breakpoint,
|
|
83
|
+
}: RailItemProps): React.JSX.Element {
|
|
84
|
+
const theme = useTheme();
|
|
85
|
+
// In `drilldown` the narrow rail IS the page's list, so its rows are the
|
|
86
|
+
// primary touch targets rather than a secondary column — hence the 44px floor
|
|
87
|
+
// below the breakpoint. Above it, the row goes back to rail density.
|
|
88
|
+
const touchFloor =
|
|
89
|
+
variant === 'drilldown'
|
|
90
|
+
? { minHeight: TOUCH_TARGET, [atLeastRail(theme, breakpoint)]: { minHeight: 'auto' } }
|
|
91
|
+
: null;
|
|
92
|
+
const shared = {
|
|
93
|
+
'data-testid': `${testIdPrefix}-item-${item.id}`,
|
|
94
|
+
sx: {
|
|
95
|
+
borderRadius: 1.5,
|
|
96
|
+
mb: 0.25,
|
|
97
|
+
gap: 1.5,
|
|
98
|
+
...touchFloor,
|
|
99
|
+
color: active ? 'primary.main' : 'text.secondary',
|
|
100
|
+
'&.Mui-selected': {
|
|
101
|
+
bgcolor: alpha(theme.palette.primary.main, 0.12),
|
|
102
|
+
'&:hover': { bgcolor: alpha(theme.palette.primary.main, 0.18) },
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
const content = <RailItemContent item={item} active={active} testIdPrefix={testIdPrefix} />;
|
|
107
|
+
|
|
108
|
+
// Listed but not reachable from here. Deliberately not a disabled button: a
|
|
109
|
+
// disabled control says "you may not", and the true statement is "not from
|
|
110
|
+
// this screen" — the section exists and someone else's screen owns it.
|
|
111
|
+
if (item.inert) {
|
|
112
|
+
return (
|
|
113
|
+
<ListItem {...shared} data-inert="true">
|
|
114
|
+
{content}
|
|
115
|
+
</ListItem>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const interactive = {
|
|
120
|
+
...shared,
|
|
121
|
+
selected: active,
|
|
122
|
+
'aria-current': active ? ('page' as const) : undefined,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (item.href && linkComponent) {
|
|
126
|
+
const LinkComponent = linkComponent;
|
|
127
|
+
return (
|
|
128
|
+
<ListItemButton
|
|
129
|
+
component={LinkComponent}
|
|
130
|
+
href={item.href}
|
|
131
|
+
onClick={onNavigate}
|
|
132
|
+
{...interactive}
|
|
133
|
+
>
|
|
134
|
+
{content}
|
|
135
|
+
</ListItemButton>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return (
|
|
139
|
+
<ListItemButton
|
|
140
|
+
onClick={() => {
|
|
141
|
+
onSelectItem?.(item.id);
|
|
142
|
+
onNavigate?.();
|
|
143
|
+
}}
|
|
144
|
+
{...interactive}
|
|
145
|
+
>
|
|
146
|
+
{content}
|
|
147
|
+
</ListItemButton>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface RailGroupProps extends RailShape {
|
|
152
|
+
group: SettingsNavGroup;
|
|
153
|
+
activeItemId?: string;
|
|
154
|
+
linkComponent?: SettingsLayoutProps['linkComponent'];
|
|
155
|
+
onSelectItem?: (id: string) => void;
|
|
156
|
+
onNavigate?: () => void;
|
|
157
|
+
testIdPrefix: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The list's own grid, in `drilldown` at the index only.
|
|
162
|
+
*
|
|
163
|
+
* At the index on a wide phone or a tablet the list has room for two columns;
|
|
164
|
+
* the rail column never does, so the second column is undone at exactly the
|
|
165
|
+
* width where this stops being the page.
|
|
166
|
+
*/
|
|
167
|
+
function indexListSx(shape: RailShape) {
|
|
168
|
+
if (shape.variant !== 'drilldown' || !shape.atIndex) return null;
|
|
169
|
+
return (theme: import('@mui/material').Theme) => ({
|
|
170
|
+
display: 'grid',
|
|
171
|
+
gridTemplateColumns: '1fr',
|
|
172
|
+
columnGap: 8,
|
|
173
|
+
[theme.breakpoints.up('sm')]: { gridTemplateColumns: '1fr 1fr' },
|
|
174
|
+
[atLeastRail(theme, shape.breakpoint)]: { gridTemplateColumns: '1fr' },
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A category: header + optional description + its items. */
|
|
179
|
+
export function SettingsRailGroup({
|
|
180
|
+
group,
|
|
181
|
+
activeItemId,
|
|
182
|
+
linkComponent,
|
|
183
|
+
onSelectItem,
|
|
184
|
+
onNavigate,
|
|
185
|
+
testIdPrefix,
|
|
186
|
+
...shape
|
|
187
|
+
}: RailGroupProps): React.JSX.Element {
|
|
188
|
+
const grid = indexListSx(shape);
|
|
189
|
+
return (
|
|
190
|
+
<Box data-testid={`${testIdPrefix}-group-${group.id}`} sx={{ mb: 2 }}>
|
|
191
|
+
<Typography
|
|
192
|
+
variant="caption"
|
|
193
|
+
sx={{
|
|
194
|
+
display: 'block',
|
|
195
|
+
px: 1,
|
|
196
|
+
color: 'text.secondary',
|
|
197
|
+
fontWeight: 600,
|
|
198
|
+
textTransform: 'uppercase',
|
|
199
|
+
letterSpacing: '0.06em',
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
{group.label}
|
|
203
|
+
</Typography>
|
|
204
|
+
{group.description ? (
|
|
205
|
+
<Typography variant="caption" component="p" sx={{ px: 1, mt: 0.25, color: 'text.disabled' }}>
|
|
206
|
+
{group.description}
|
|
207
|
+
</Typography>
|
|
208
|
+
) : null}
|
|
209
|
+
<List
|
|
210
|
+
disablePadding
|
|
211
|
+
sx={(theme) => ({ mt: 0.5, ...(grid ? grid(theme) : null) })}
|
|
212
|
+
>
|
|
213
|
+
{group.items.map((item) => (
|
|
214
|
+
<SettingsRailItem
|
|
215
|
+
key={item.id}
|
|
216
|
+
item={item}
|
|
217
|
+
active={item.id === activeItemId}
|
|
218
|
+
linkComponent={linkComponent}
|
|
219
|
+
onSelectItem={onSelectItem}
|
|
220
|
+
onNavigate={onNavigate}
|
|
221
|
+
testIdPrefix={testIdPrefix}
|
|
222
|
+
{...shape}
|
|
223
|
+
/>
|
|
224
|
+
))}
|
|
225
|
+
</List>
|
|
226
|
+
</Box>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
@@ -19,9 +19,77 @@ it themes automatically in light/dark.
|
|
|
19
19
|
- **Central panel** (`children`) — a custom screen per configuration
|
|
20
20
|
- **Link or button mode** per item (`href` + `linkComponent`, or `onSelectItem`)
|
|
21
21
|
- **Active highlight** via `activeItemId` (`aria-current="page"`, semantic tint)
|
|
22
|
-
- **Responsive** — below `
|
|
22
|
+
- **Responsive** — below `railBreakpoint` the rail either stacks above the panel
|
|
23
|
+
(`switcher`) or hands the width to a list→detail drilldown (`drilldown`)
|
|
24
|
+
- **Situation markers** per item — host-resolved, with the meaning in text
|
|
25
|
+
- **Section chip strip** on narrow widths, scrolling itself to the open section
|
|
23
26
|
- **Agnostic** — no app-specific content; drive it with any `groups` data
|
|
24
27
|
|
|
28
|
+
## The two narrow-width shapes
|
|
29
|
+
|
|
30
|
+
`navVariant` decides what happens below `railBreakpoint`.
|
|
31
|
+
|
|
32
|
+
**`switcher`** (default) folds the rail into a disclosure stacked above the
|
|
33
|
+
panel. Right when the panel is always the point and the rail is a chooser.
|
|
34
|
+
|
|
35
|
+
**`drilldown`** gives the shape every mobile settings app has: the list first,
|
|
36
|
+
then the screen, with a way back.
|
|
37
|
+
|
|
38
|
+
| where | below `railBreakpoint` | at and above it |
|
|
39
|
+
| --- | --- | --- |
|
|
40
|
+
| at the index (`atIndex`) | the grouped **list is the page** | rail + panel |
|
|
41
|
+
| inside a section | back link, **chip strip**, panel | rail + panel |
|
|
42
|
+
|
|
43
|
+
Pass `atIndex` from the router, `indexHref` for the back link, and
|
|
44
|
+
`sectionChips` with the sibling sections the strip should carry (usually the
|
|
45
|
+
open item's own group).
|
|
46
|
+
|
|
47
|
+
### Why the width decision is CSS, never a JS media query
|
|
48
|
+
|
|
49
|
+
Both navigation forms — the rail and the chip strip — are mounted at every
|
|
50
|
+
width, and only `display` moves between them.
|
|
51
|
+
|
|
52
|
+
A layout that RENDERS one navigation and not the other can offer the narrow
|
|
53
|
+
width less than the wide one, and nothing catches it: the missing control is not
|
|
54
|
+
in the tree to be asserted about, so no test fails and no review notices. With
|
|
55
|
+
one tree, "the phone reaches everything the desktop reaches" holds by
|
|
56
|
+
construction. `DrilldownBothNavigations` in the test stories is the assertion
|
|
57
|
+
that keeps it that way.
|
|
58
|
+
|
|
59
|
+
The one media query that IS read in JavaScript is `prefers-reduced-motion`, in
|
|
60
|
+
the chip strip — a motion preference has no CSS equivalent at the point where
|
|
61
|
+
`scrollTo` picks its behaviour.
|
|
62
|
+
|
|
63
|
+
## Situation markers
|
|
64
|
+
|
|
65
|
+
An item may carry a host-resolved `status`, shown as a marker beside its label:
|
|
66
|
+
|
|
67
|
+
| `status` | drawn as | means |
|
|
68
|
+
| --- | --- | --- |
|
|
69
|
+
| `ok` | green dot | set up and working |
|
|
70
|
+
| `off` | grey dot | exists, switched off |
|
|
71
|
+
| `new` | red dot | never opened |
|
|
72
|
+
| `locked` | padlock | the plan does not include it |
|
|
73
|
+
|
|
74
|
+
`locked` is a padlock rather than a fourth colour on purpose: a locked section
|
|
75
|
+
has no configuration state at all, and a grey dot would claim it is merely off.
|
|
76
|
+
|
|
77
|
+
**`statusLabel` is not optional in practice.** It is rendered as visually-hidden
|
|
78
|
+
text inside the row, so the row's accessible name reads "Endereço, desligado".
|
|
79
|
+
Colour alone never carries the meaning, and a marker without a label is
|
|
80
|
+
decoration.
|
|
81
|
+
|
|
82
|
+
The layout never derives `status` — it cannot know what "configured" means for a
|
|
83
|
+
screen it does not own, and a rail that guesses disagrees with the screen the
|
|
84
|
+
moment the rule moves.
|
|
85
|
+
|
|
86
|
+
## Inert items
|
|
87
|
+
|
|
88
|
+
`inert: true` renders an entry as listed text rather than a control — for a
|
|
89
|
+
section that exists but is not reachable from here. It is deliberately not a
|
|
90
|
+
disabled button: disabled says "you may not", and the honest statement is "not
|
|
91
|
+
from this screen".
|
|
92
|
+
|
|
25
93
|
## Usage
|
|
26
94
|
|
|
27
95
|
### Route-based (recommended for app settings)
|
|
@@ -81,9 +149,16 @@ const [active, setActive] = useState('profile');
|
|
|
81
149
|
| onSelectItem | (id: string) => void | - | Fired when a non-link item is chosen |
|
|
82
150
|
| searchPlaceholder | string | `'Search settings'` | Placeholder for the search field |
|
|
83
151
|
| emptySearchLabel | ReactNode | generic string | Shown when the search matches no items |
|
|
152
|
+
| emptySearchAction | `{ label: ReactNode; onClear?: () => void }` | - | The way out, rendered inside the empty-search state |
|
|
84
153
|
| linkComponent | ElementType | - | Element used to render items that have an `href` (e.g. Next `Link`)|
|
|
85
154
|
| children | ReactNode | - | Central panel content (the selected screen) |
|
|
86
155
|
| testIdPrefix | string | `'settings'` | Prefix for `data-testid` attributes |
|
|
156
|
+
| railBreakpoint | `'sm' \| 'md' \| 'lg' \| 'xl'` | `'md'` | Width at which the rail takes its own column |
|
|
157
|
+
| navVariant | `'switcher' \| 'drilldown'` | `'switcher'` | Narrow-width navigation shape |
|
|
158
|
+
| atIndex | boolean | `false` | `drilldown`: the router is at the area's index, not in a section |
|
|
159
|
+
| indexHref | string | - | `drilldown`: where the back link goes |
|
|
160
|
+
| backLabel | string | `'Back'` | Label on the back control |
|
|
161
|
+
| sectionChips | SettingsNavItem[] | - | Sections carried by the narrow-width chip strip |
|
|
87
162
|
|
|
88
163
|
### SettingsNavGroup
|
|
89
164
|
|
|
@@ -103,6 +178,9 @@ const [active, setActive] = useState('profile');
|
|
|
103
178
|
| icon | ReactNode | Optional leading icon |
|
|
104
179
|
| href | string | Optional link target (rendered via `linkComponent`) |
|
|
105
180
|
| keywords | string[] | Extra search terms beyond the label |
|
|
181
|
+
| status | `'ok' \| 'off' \| 'new' \| 'locked'` | Host-resolved situation marker |
|
|
182
|
+
| statusLabel | string | What the marker means — read aloud with the row |
|
|
183
|
+
| inert | boolean | Listed, but not a control (not the same as disabled) |
|
|
106
184
|
|
|
107
185
|
## Accessibility
|
|
108
186
|
|
|
@@ -111,6 +189,14 @@ const [active, setActive] = useState('profile');
|
|
|
111
189
|
- Items with `href` render as a single interactive anchor (via `linkComponent`),
|
|
112
190
|
avoiding nested interactive elements.
|
|
113
191
|
- The search field carries an accessible label matching its placeholder.
|
|
192
|
+
- A situation marker puts its meaning in visually-hidden text inside the row, so
|
|
193
|
+
the situation is part of the row's accessible name rather than a colour.
|
|
194
|
+
- The chip strip is its own labelled `<nav>`, and the open chip carries
|
|
195
|
+
`aria-current="page"`.
|
|
196
|
+
- Every control in the `drilldown` shape holds a 44px minimum target below the
|
|
197
|
+
breakpoint, including the back link and the chips.
|
|
198
|
+
- The chip strip honours `prefers-reduced-motion`: with it set, the scroll to
|
|
199
|
+
the open section is a jump rather than an animation.
|
|
114
200
|
|
|
115
201
|
## Best Practices
|
|
116
202
|
|
|
@@ -134,6 +220,12 @@ const [active, setActive] = useState('profile');
|
|
|
134
220
|
| Group | `{testIdPrefix}-group-{groupId}` |
|
|
135
221
|
| Item | `{testIdPrefix}-item-{itemId}` |
|
|
136
222
|
| Empty state | `{testIdPrefix}-empty` |
|
|
223
|
+
| Empty-state exit | `{testIdPrefix}-empty-action` |
|
|
224
|
+
| Situation marker | `{testIdPrefix}-status-{itemId}` |
|
|
137
225
|
| Central panel | `{testIdPrefix}-panel` |
|
|
226
|
+
| Back link | `{testIdPrefix}-back` |
|
|
227
|
+
| Chip strip | `{testIdPrefix}-chips` |
|
|
228
|
+
| Chip | `{testIdPrefix}-chip-{itemId}` |
|
|
229
|
+
| Chip marker | `{testIdPrefix}-chip-status-{itemId}` |
|
|
138
230
|
|
|
139
231
|
`testIdPrefix` defaults to `settings`.
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
|
4
|
+
import SearchIcon from '@mui/icons-material/Search';
|
|
5
|
+
import { Box, Button, InputAdornment, TextField, Typography } from '@mui/material';
|
|
6
|
+
import React from 'react';
|
|
7
|
+
|
|
8
|
+
import { SettingsRailGroup, type RailShape } from './SettingsLayout.items';
|
|
9
|
+
import { atLeastRail, displayAcrossRail, RAIL_WIDTH, TOUCH_TARGET } from './SettingsLayout.styles';
|
|
10
|
+
import type {
|
|
11
|
+
SettingsEmptySearchAction,
|
|
12
|
+
SettingsLayoutProps,
|
|
13
|
+
SettingsNavGroup,
|
|
14
|
+
SettingsRailBreakpoint,
|
|
15
|
+
} from './SettingsLayout.types';
|
|
16
|
+
|
|
17
|
+
interface RailToggleProps {
|
|
18
|
+
title?: React.ReactNode;
|
|
19
|
+
activeLabel?: string;
|
|
20
|
+
open: boolean;
|
|
21
|
+
onToggle: () => void;
|
|
22
|
+
breakpoint: SettingsRailBreakpoint;
|
|
23
|
+
testIdPrefix: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `switcher`-only collapse control: shows the current section (a compact
|
|
28
|
+
* switcher) so the panel content stays at the top of the screen. Hidden once the
|
|
29
|
+
* rail has its own column.
|
|
30
|
+
*/
|
|
31
|
+
function SettingsRailToggle({
|
|
32
|
+
title,
|
|
33
|
+
activeLabel,
|
|
34
|
+
open,
|
|
35
|
+
onToggle,
|
|
36
|
+
breakpoint,
|
|
37
|
+
testIdPrefix,
|
|
38
|
+
}: RailToggleProps): React.JSX.Element {
|
|
39
|
+
return (
|
|
40
|
+
<Box
|
|
41
|
+
component="button"
|
|
42
|
+
type="button"
|
|
43
|
+
onClick={onToggle}
|
|
44
|
+
aria-expanded={open}
|
|
45
|
+
aria-label={typeof title === 'string' ? title : 'Settings'}
|
|
46
|
+
data-testid={`${testIdPrefix}-toggle`}
|
|
47
|
+
sx={(theme) => ({
|
|
48
|
+
...displayAcrossRail(theme, breakpoint, 'flex', 'none'),
|
|
49
|
+
alignItems: 'center',
|
|
50
|
+
justifyContent: 'space-between',
|
|
51
|
+
gap: 1,
|
|
52
|
+
width: '100%',
|
|
53
|
+
minHeight: TOUCH_TARGET,
|
|
54
|
+
px: 1.5,
|
|
55
|
+
py: 1,
|
|
56
|
+
mb: 1,
|
|
57
|
+
textAlign: 'left',
|
|
58
|
+
cursor: 'pointer',
|
|
59
|
+
color: 'text.primary',
|
|
60
|
+
background: 'transparent',
|
|
61
|
+
border: `1px solid ${theme.palette.divider}`,
|
|
62
|
+
borderRadius: 1.5,
|
|
63
|
+
})}
|
|
64
|
+
>
|
|
65
|
+
<Box sx={{ minWidth: 0 }}>
|
|
66
|
+
{title ? (
|
|
67
|
+
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
|
|
68
|
+
{title}
|
|
69
|
+
</Typography>
|
|
70
|
+
) : null}
|
|
71
|
+
<Typography variant="body2" noWrap sx={{ fontWeight: 600 }}>
|
|
72
|
+
{activeLabel ?? ''}
|
|
73
|
+
</Typography>
|
|
74
|
+
</Box>
|
|
75
|
+
<ExpandMoreIcon
|
|
76
|
+
fontSize="small"
|
|
77
|
+
sx={{
|
|
78
|
+
flex: '0 0 auto',
|
|
79
|
+
color: 'text.secondary',
|
|
80
|
+
transition: 'transform 150ms ease',
|
|
81
|
+
transform: open ? 'rotate(180deg)' : 'none',
|
|
82
|
+
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
|
83
|
+
}}
|
|
84
|
+
/>
|
|
85
|
+
</Box>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The empty-search state, with the way out of it rendered inside. */
|
|
90
|
+
function SettingsSearchEmpty({
|
|
91
|
+
label,
|
|
92
|
+
action,
|
|
93
|
+
onClear,
|
|
94
|
+
testIdPrefix,
|
|
95
|
+
}: {
|
|
96
|
+
label: React.ReactNode;
|
|
97
|
+
action?: SettingsEmptySearchAction;
|
|
98
|
+
onClear: () => void;
|
|
99
|
+
testIdPrefix: string;
|
|
100
|
+
}): React.JSX.Element {
|
|
101
|
+
return (
|
|
102
|
+
<Box data-testid={`${testIdPrefix}-empty`} sx={{ px: 1, py: 2 }}>
|
|
103
|
+
<Typography variant="body2" color="text.secondary">
|
|
104
|
+
{label}
|
|
105
|
+
</Typography>
|
|
106
|
+
{action ? (
|
|
107
|
+
<Button
|
|
108
|
+
size="small"
|
|
109
|
+
variant="outlined"
|
|
110
|
+
onClick={() => {
|
|
111
|
+
onClear();
|
|
112
|
+
action.onClear?.();
|
|
113
|
+
}}
|
|
114
|
+
data-testid={`${testIdPrefix}-empty-action`}
|
|
115
|
+
sx={{ mt: 1.5, minHeight: TOUCH_TARGET }}
|
|
116
|
+
>
|
|
117
|
+
{action.label}
|
|
118
|
+
</Button>
|
|
119
|
+
) : null}
|
|
120
|
+
</Box>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface RailBodyProps extends RailShape {
|
|
125
|
+
navOpen: boolean;
|
|
126
|
+
query: string;
|
|
127
|
+
onQueryChange: (value: string) => void;
|
|
128
|
+
searchPlaceholder: string;
|
|
129
|
+
emptySearchLabel: React.ReactNode;
|
|
130
|
+
emptySearchAction?: SettingsEmptySearchAction;
|
|
131
|
+
filteredGroups: SettingsNavGroup[];
|
|
132
|
+
activeItemId?: string;
|
|
133
|
+
linkComponent?: SettingsLayoutProps['linkComponent'];
|
|
134
|
+
onSelectItem?: (id: string) => void;
|
|
135
|
+
onNavigate?: () => void;
|
|
136
|
+
testIdPrefix: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The searchable rail body (search + groups). */
|
|
140
|
+
function SettingsRailBody({
|
|
141
|
+
navOpen,
|
|
142
|
+
query,
|
|
143
|
+
onQueryChange,
|
|
144
|
+
searchPlaceholder,
|
|
145
|
+
emptySearchLabel,
|
|
146
|
+
emptySearchAction,
|
|
147
|
+
filteredGroups,
|
|
148
|
+
activeItemId,
|
|
149
|
+
linkComponent,
|
|
150
|
+
onSelectItem,
|
|
151
|
+
onNavigate,
|
|
152
|
+
testIdPrefix,
|
|
153
|
+
...shape
|
|
154
|
+
}: RailBodyProps): React.JSX.Element {
|
|
155
|
+
// `drilldown` has no disclosure to be closed behind: below the breakpoint the
|
|
156
|
+
// body is the page, above it the rail. Only `switcher` hides it.
|
|
157
|
+
const hidden = navOpen ? 'block' : 'none';
|
|
158
|
+
return (
|
|
159
|
+
<Box
|
|
160
|
+
data-testid={`${testIdPrefix}-rail-body`}
|
|
161
|
+
sx={(theme) =>
|
|
162
|
+
shape.variant === 'drilldown'
|
|
163
|
+
? { display: 'block' }
|
|
164
|
+
: displayAcrossRail(theme, shape.breakpoint, hidden, 'block')
|
|
165
|
+
}
|
|
166
|
+
>
|
|
167
|
+
<TextField
|
|
168
|
+
value={query}
|
|
169
|
+
onChange={(event) => onQueryChange(event.target.value)}
|
|
170
|
+
placeholder={searchPlaceholder}
|
|
171
|
+
size="small"
|
|
172
|
+
fullWidth
|
|
173
|
+
aria-label={searchPlaceholder}
|
|
174
|
+
data-testid={`${testIdPrefix}-search`}
|
|
175
|
+
InputProps={{
|
|
176
|
+
startAdornment: (
|
|
177
|
+
<InputAdornment position="start">
|
|
178
|
+
<SearchIcon fontSize="small" sx={{ color: 'text.secondary' }} />
|
|
179
|
+
</InputAdornment>
|
|
180
|
+
),
|
|
181
|
+
}}
|
|
182
|
+
sx={{ mb: 2 }}
|
|
183
|
+
/>
|
|
184
|
+
{filteredGroups.length === 0 ? (
|
|
185
|
+
<SettingsSearchEmpty
|
|
186
|
+
label={emptySearchLabel}
|
|
187
|
+
action={emptySearchAction}
|
|
188
|
+
onClear={() => onQueryChange('')}
|
|
189
|
+
testIdPrefix={testIdPrefix}
|
|
190
|
+
/>
|
|
191
|
+
) : (
|
|
192
|
+
filteredGroups.map((group) => (
|
|
193
|
+
<SettingsRailGroup
|
|
194
|
+
key={group.id}
|
|
195
|
+
group={group}
|
|
196
|
+
activeItemId={activeItemId}
|
|
197
|
+
linkComponent={linkComponent}
|
|
198
|
+
onSelectItem={onSelectItem}
|
|
199
|
+
onNavigate={onNavigate}
|
|
200
|
+
testIdPrefix={testIdPrefix}
|
|
201
|
+
{...shape}
|
|
202
|
+
/>
|
|
203
|
+
))
|
|
204
|
+
)}
|
|
205
|
+
</Box>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Props for {@link SettingsRail}: the rail body's inputs plus the mobile chrome. */
|
|
210
|
+
export type SettingsRailProps = RailBodyProps & {
|
|
211
|
+
title?: React.ReactNode;
|
|
212
|
+
activeLabel?: string;
|
|
213
|
+
onToggleNav: () => void;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/** The navigation column: title, the `switcher`'s disclosure, and the body. */
|
|
217
|
+
export function SettingsRail({
|
|
218
|
+
title,
|
|
219
|
+
activeLabel,
|
|
220
|
+
navOpen,
|
|
221
|
+
onToggleNav,
|
|
222
|
+
testIdPrefix,
|
|
223
|
+
...bodyProps
|
|
224
|
+
}: SettingsRailProps): React.JSX.Element {
|
|
225
|
+
const { variant, breakpoint, atIndex } = bodyProps;
|
|
226
|
+
// In `drilldown` the rail is the index's list below the breakpoint and the
|
|
227
|
+
// left column above it — so inside a section it steps aside for the chip
|
|
228
|
+
// strip. It is `display: none`, never unmounted: the two navigation forms
|
|
229
|
+
// both stay in the DOM at every width, which is what makes "the narrow width
|
|
230
|
+
// offers what the wide one offers" true by construction instead of by review.
|
|
231
|
+
const hiddenWhileInSection = variant === 'drilldown' && !atIndex;
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<Box
|
|
235
|
+
component="nav"
|
|
236
|
+
aria-label={typeof title === 'string' ? title : 'Settings'}
|
|
237
|
+
data-testid={`${testIdPrefix}-rail`}
|
|
238
|
+
sx={(theme) => ({
|
|
239
|
+
display: hiddenWhileInSection ? 'none' : 'block',
|
|
240
|
+
flex: '0 0 auto',
|
|
241
|
+
width: '100%',
|
|
242
|
+
[atLeastRail(theme, breakpoint)]: {
|
|
243
|
+
display: 'block',
|
|
244
|
+
flex: `0 0 ${RAIL_WIDTH}px`,
|
|
245
|
+
width: RAIL_WIDTH,
|
|
246
|
+
borderRight: `1px solid ${theme.palette.divider}`,
|
|
247
|
+
pr: 2,
|
|
248
|
+
},
|
|
249
|
+
})}
|
|
250
|
+
>
|
|
251
|
+
{title ? (
|
|
252
|
+
<Typography
|
|
253
|
+
variant="h6"
|
|
254
|
+
data-testid={`${testIdPrefix}-title`}
|
|
255
|
+
sx={(theme) => ({
|
|
256
|
+
...(variant === 'drilldown'
|
|
257
|
+
? { display: 'block' }
|
|
258
|
+
: displayAcrossRail(theme, breakpoint, 'none', 'block')),
|
|
259
|
+
fontWeight: 700,
|
|
260
|
+
mb: 1.5,
|
|
261
|
+
})}
|
|
262
|
+
>
|
|
263
|
+
{title}
|
|
264
|
+
</Typography>
|
|
265
|
+
) : null}
|
|
266
|
+
{variant === 'switcher' ? (
|
|
267
|
+
<SettingsRailToggle
|
|
268
|
+
title={title}
|
|
269
|
+
activeLabel={activeLabel}
|
|
270
|
+
open={navOpen}
|
|
271
|
+
onToggle={onToggleNav}
|
|
272
|
+
breakpoint={breakpoint}
|
|
273
|
+
testIdPrefix={testIdPrefix}
|
|
274
|
+
/>
|
|
275
|
+
) : null}
|
|
276
|
+
<SettingsRailBody navOpen={navOpen} testIdPrefix={testIdPrefix} {...bodyProps} />
|
|
277
|
+
</Box>
|
|
278
|
+
);
|
|
279
|
+
}
|