@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.
Files changed (37) hide show
  1. package/components-catalog.md +7 -0
  2. package/dist/layout/SettingsLayout.js +560 -108
  3. package/dist/layout/SettingsLayout.js.map +1 -1
  4. package/dist/types/components/layout/SettingsLayout/SettingsLayout.d.ts +19 -2
  5. package/dist/types/components/layout/SettingsLayout/SettingsLayout.d.ts.map +1 -1
  6. package/dist/types/components/layout/SettingsLayout/SettingsLayout.filters.d.ts +6 -0
  7. package/dist/types/components/layout/SettingsLayout/SettingsLayout.filters.d.ts.map +1 -0
  8. package/dist/types/components/layout/SettingsLayout/SettingsLayout.items.d.ts +31 -0
  9. package/dist/types/components/layout/SettingsLayout/SettingsLayout.items.d.ts.map +1 -0
  10. package/dist/types/components/layout/SettingsLayout/{SettingsLayout.parts.d.ts → SettingsLayout.rail.d.ts} +6 -9
  11. package/dist/types/components/layout/SettingsLayout/SettingsLayout.rail.d.ts.map +1 -0
  12. package/dist/types/components/layout/SettingsLayout/SettingsLayout.styles.d.ts +35 -0
  13. package/dist/types/components/layout/SettingsLayout/SettingsLayout.styles.d.ts.map +1 -0
  14. package/dist/types/components/layout/SettingsLayout/SettingsLayout.types.d.ts +99 -0
  15. package/dist/types/components/layout/SettingsLayout/SettingsLayout.types.d.ts.map +1 -1
  16. package/dist/types/components/layout/SettingsLayout/SettingsPanel.d.ts +29 -0
  17. package/dist/types/components/layout/SettingsLayout/SettingsPanel.d.ts.map +1 -0
  18. package/dist/types/components/layout/SettingsLayout/SettingsSectionChips.d.ts +26 -0
  19. package/dist/types/components/layout/SettingsLayout/SettingsSectionChips.d.ts.map +1 -0
  20. package/dist/types/components/layout/SettingsLayout/SettingsStatusMarker.d.ts +28 -0
  21. package/dist/types/components/layout/SettingsLayout/SettingsStatusMarker.d.ts.map +1 -0
  22. package/dist/types/components/layout/SettingsLayout/index.d.ts +5 -1
  23. package/dist/types/components/layout/SettingsLayout/index.d.ts.map +1 -1
  24. package/package.json +2 -2
  25. package/src/components/layout/SettingsLayout/SettingsLayout.filters.ts +21 -0
  26. package/src/components/layout/SettingsLayout/SettingsLayout.items.tsx +228 -0
  27. package/src/components/layout/SettingsLayout/SettingsLayout.md +93 -1
  28. package/src/components/layout/SettingsLayout/SettingsLayout.rail.tsx +279 -0
  29. package/src/components/layout/SettingsLayout/SettingsLayout.styles.ts +64 -0
  30. package/src/components/layout/SettingsLayout/SettingsLayout.tsx +114 -24
  31. package/src/components/layout/SettingsLayout/SettingsLayout.types.ts +103 -0
  32. package/src/components/layout/SettingsLayout/SettingsPanel.tsx +140 -0
  33. package/src/components/layout/SettingsLayout/SettingsSectionChips.tsx +0 -0
  34. package/src/components/layout/SettingsLayout/SettingsStatusMarker.tsx +75 -0
  35. package/src/components/layout/SettingsLayout/index.ts +8 -0
  36. package/dist/types/components/layout/SettingsLayout/SettingsLayout.parts.d.ts.map +0 -1
  37. 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 `md` the rail stacks full-width above the panel
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
+ }