@12-apps/ui 5.0.1 → 5.2.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 +563 -109
  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 +109 -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 +118 -25
  31. package/src/components/layout/SettingsLayout/SettingsLayout.types.ts +113 -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,64 @@
1
+ import type { CSSObject, Theme } from '@mui/material';
2
+
3
+ import type { SettingsNavStatus, SettingsRailBreakpoint } from './SettingsLayout.types';
4
+
5
+ /** Fixed width of the left navigation rail once it has its own column. */
6
+ export const RAIL_WIDTH = 300;
7
+
8
+ /** Visually hidden, still read aloud — for text a marker's colour cannot carry. */
9
+ export const SR_ONLY_SX: CSSObject = {
10
+ position: 'absolute',
11
+ width: '1px',
12
+ height: '1px',
13
+ padding: 0,
14
+ margin: '-1px',
15
+ overflow: 'hidden',
16
+ clip: 'rect(0, 0, 0, 0)',
17
+ whiteSpace: 'nowrap',
18
+ border: 0,
19
+ };
20
+
21
+ /**
22
+ * The media query for "the rail has its own column".
23
+ *
24
+ * `theme.breakpoints.up` takes a NUMBER as happily as a name, which is why
25
+ * `railBreakpoint` accepts one: a settings area whose structural switch is at
26
+ * 1024px has no MUI breakpoint to name (the default `lg` is 1200), and rounding
27
+ * the design to the nearest named breakpoint moves 176px of behaviour to keep a
28
+ * string tidy.
29
+ */
30
+ export function atLeastRail(theme: Theme, breakpoint: SettingsRailBreakpoint): string {
31
+ return theme.breakpoints.up(breakpoint);
32
+ }
33
+
34
+ /**
35
+ * A `display` pair across that query: `narrow` below it, `wide` at and above.
36
+ *
37
+ * Everything that changes shape between the two navigation modes goes through
38
+ * this, so "which width am I?" is answered once, in CSS, for every part at
39
+ * once — rather than by each part asking a media query in JavaScript and
40
+ * answering slightly differently.
41
+ */
42
+ export function displayAcrossRail(
43
+ theme: Theme,
44
+ breakpoint: SettingsRailBreakpoint,
45
+ narrow: string,
46
+ wide: string,
47
+ ): CSSObject {
48
+ return { display: narrow, [atLeastRail(theme, breakpoint)]: { display: wide } };
49
+ }
50
+
51
+ /** Minimum touch target, in px. Held down to 320px-wide phones, deliberately. */
52
+ export const TOUCH_TARGET = 44;
53
+
54
+ /**
55
+ * Palette role per situation.
56
+ *
57
+ * Returned as theme keys rather than hex so a marker follows the host's theme —
58
+ * and so light and dark do not need two copies of this table.
59
+ */
60
+ export const STATUS_COLOR: Record<Exclude<SettingsNavStatus, 'locked'>, string> = {
61
+ ok: 'success.main',
62
+ off: 'text.disabled',
63
+ new: 'error.main',
64
+ };
@@ -1,46 +1,123 @@
1
1
  'use client';
2
2
 
3
- import { Box } from '@mui/material';
3
+ import { Box, type Theme } from '@mui/material';
4
4
  import React, { useMemo, useState } from 'react';
5
5
 
6
- import { activeItemLabel, filterGroups, SettingsRail } from './SettingsLayout.parts';
6
+ import { withDefaults } from '../../../utils/withDefaults';
7
+ import { activeItemLabel, filterGroups } from './SettingsLayout.filters';
8
+ import { SettingsRail } from './SettingsLayout.rail';
9
+ import { atLeastRail } from './SettingsLayout.styles';
10
+ import { SettingsPanel } from './SettingsPanel';
7
11
  import type { SettingsLayoutProps } from './SettingsLayout.types';
8
12
 
13
+ /**
14
+ * The props with every default already applied.
15
+ *
16
+ * A table through `withDefaults` rather than a `= default` per destructured
17
+ * prop: each of those is a branch, and seven of them put the component over the
18
+ * complexity bar before it renders anything. The single cast is what buys the
19
+ * body non-optional values — without it every use site would restate the
20
+ * default as a `??`, which is the same default written twice and free to drift.
21
+ */
22
+ type ResolvedProps = SettingsLayoutProps &
23
+ Required<
24
+ Pick<
25
+ SettingsLayoutProps,
26
+ | 'searchPlaceholder'
27
+ | 'defaultQuery'
28
+ | 'emptySearchLabel'
29
+ | 'testIdPrefix'
30
+ | 'railBreakpoint'
31
+ | 'navVariant'
32
+ | 'atIndex'
33
+ | 'backLabel'
34
+ >
35
+ >;
36
+
37
+ const DEFAULTS = {
38
+ searchPlaceholder: 'Search settings',
39
+ defaultQuery: '',
40
+ emptySearchLabel: 'No settings match your search.',
41
+ testIdPrefix: 'settings',
42
+ railBreakpoint: 'md',
43
+ navVariant: 'switcher',
44
+ atIndex: false,
45
+ backLabel: 'Back',
46
+ } satisfies Partial<SettingsLayoutProps>;
47
+
48
+ /** Stacked below the rail's breakpoint, two columns at and above it. */
49
+ function shellSx(breakpoint: SettingsLayoutProps['railBreakpoint']) {
50
+ return (theme: Theme) => ({
51
+ display: 'flex',
52
+ flexDirection: 'column' as const,
53
+ alignItems: 'stretch',
54
+ gap: 1.5,
55
+ width: '100%',
56
+ minWidth: 0,
57
+ [atLeastRail(theme, breakpoint ?? 'md')]: { flexDirection: 'row', gap: 3 },
58
+ });
59
+ }
60
+
9
61
  /**
10
62
  * A reusable, agnostic Facebook-style settings shell: a searchable left rail of
11
63
  * grouped categories/subcategories and a central panel that renders the selected
12
64
  * configuration screen (`children`). Navigation is data-driven via `groups`; the
13
65
  * host owns routing (pass `linkComponent` + item `href`, or handle `onSelectItem`).
14
66
  *
15
- * Below `md` the rail collapses into a compact section-switcher (tap to reveal the
16
- * search + groups) so the panel content stays at the top on phones.
67
+ * ## Two narrow-width shapes
68
+ *
69
+ * `navVariant="switcher"` (the default) folds the rail into a disclosure above
70
+ * the panel. `navVariant="drilldown"` gives the phone shape instead: at the
71
+ * area's index the LIST is the page, and inside a section the panel is the page
72
+ * with a back link and a scrollable strip of sibling sections above it.
73
+ *
74
+ * ## Why the width decision is CSS and not JavaScript
75
+ *
76
+ * Every part that changes between the two shapes does it through a `display`
77
+ * pair keyed on `railBreakpoint`. Nothing here asks a media query in JS and
78
+ * renders one branch.
79
+ *
80
+ * That is not a performance preference. A layout that RENDERS one navigation and
81
+ * not the other can offer the narrow width less than the wide one, and no test
82
+ * or review catches it — the missing control simply is not in the tree to be
83
+ * asserted about. With both forms always mounted, "the narrow width reaches
84
+ * everything the wide one does" holds by construction, and an audit that counts
85
+ * controls per width gets the same answer at both.
17
86
  */
18
- export const SettingsLayout: React.FC<SettingsLayoutProps> = ({
19
- title,
20
- groups,
21
- activeItemId,
22
- onSelectItem,
23
- searchPlaceholder = 'Search settings',
24
- emptySearchLabel = 'No settings match your search.',
25
- linkComponent,
26
- children,
27
- testIdPrefix = 'settings',
28
- }) => {
29
- const [query, setQuery] = useState('');
87
+ export const SettingsLayout: React.FC<SettingsLayoutProps> = (props) => {
88
+ const {
89
+ title,
90
+ groups,
91
+ activeItemId,
92
+ onSelectItem,
93
+ searchPlaceholder,
94
+ defaultQuery,
95
+ emptySearchLabel,
96
+ emptySearchAction,
97
+ linkComponent,
98
+ children,
99
+ testIdPrefix,
100
+ railBreakpoint,
101
+ navVariant,
102
+ atIndex,
103
+ indexHref,
104
+ backLabel,
105
+ sectionChips,
106
+ } = withDefaults(props, DEFAULTS) as ResolvedProps;
107
+ const [query, setQuery] = useState(defaultQuery);
30
108
  const [navOpen, setNavOpen] = useState(false);
31
109
  const filteredGroups = useMemo(() => filterGroups(groups, query), [groups, query]);
32
110
  const activeLabel = useMemo(() => activeItemLabel(groups, activeItemId), [groups, activeItemId]);
33
111
 
112
+ const drilldown = navVariant === 'drilldown';
113
+ const ariaLabel = typeof title === 'string' ? title : 'Settings';
114
+
34
115
  return (
35
116
  <Box
36
117
  data-testid={testIdPrefix}
37
- sx={{
38
- display: 'flex',
39
- flexDirection: { xs: 'column', md: 'row' },
40
- alignItems: 'stretch',
41
- gap: { xs: 1.5, md: 3 },
42
- width: '100%',
43
- }}
118
+ data-nav-variant={navVariant}
119
+ data-at-index={atIndex ? 'true' : 'false'}
120
+ sx={shellSx(railBreakpoint)}
44
121
  >
45
122
  <SettingsRail
46
123
  title={title}
@@ -51,17 +128,33 @@ export const SettingsLayout: React.FC<SettingsLayoutProps> = ({
51
128
  onQueryChange={setQuery}
52
129
  searchPlaceholder={searchPlaceholder}
53
130
  emptySearchLabel={emptySearchLabel}
131
+ emptySearchAction={emptySearchAction}
54
132
  filteredGroups={filteredGroups}
55
133
  activeItemId={activeItemId}
56
134
  linkComponent={linkComponent}
57
135
  onSelectItem={onSelectItem}
58
136
  onNavigate={() => setNavOpen(false)}
59
137
  testIdPrefix={testIdPrefix}
138
+ variant={navVariant}
139
+ breakpoint={railBreakpoint}
140
+ atIndex={atIndex}
60
141
  />
61
142
 
62
- <Box data-testid={`${testIdPrefix}-panel`} sx={{ flex: '1 1 auto', minWidth: 0, width: '100%' }}>
143
+ <SettingsPanel
144
+ inSection={drilldown && !atIndex}
145
+ hideOnNarrow={drilldown && atIndex}
146
+ breakpoint={railBreakpoint}
147
+ indexHref={indexHref}
148
+ backLabel={backLabel}
149
+ ariaLabel={ariaLabel}
150
+ sectionChips={sectionChips}
151
+ activeItemId={activeItemId}
152
+ linkComponent={linkComponent}
153
+ onSelectItem={onSelectItem}
154
+ testIdPrefix={testIdPrefix}
155
+ >
63
156
  {children}
64
- </Box>
157
+ </SettingsPanel>
65
158
  </Box>
66
159
  );
67
160
  };
@@ -1,5 +1,21 @@
1
1
  import type { ElementType, ReactNode } from 'react';
2
2
 
3
+ /**
4
+ * How an entry is doing, as a marker beside its label.
5
+ *
6
+ * The host RESOLVES this — the rail never derives it from the screen behind the
7
+ * row. A settings shell cannot know whether "Endereço" counts as configured, and
8
+ * a rail that guesses disagrees with the screen the moment the rule moves.
9
+ *
10
+ * - `ok` — set up and working.
11
+ * - `off` — exists, switched off.
12
+ * - `new` — never opened.
13
+ * - `locked` — the plan does not include it, so it has no configuration state at
14
+ * all. Rendered as a padlock rather than a coloured dot for exactly that
15
+ * reason: a locked row is not "off", and colouring it grey would say it is.
16
+ */
17
+ export type SettingsNavStatus = 'ok' | 'off' | 'new' | 'locked';
18
+
3
19
  /**
4
20
  * A single navigable configuration entry inside a {@link SettingsNavGroup}. The
5
21
  * `id` drives the active highlight and `data-testid`; `label` (plus optional
@@ -16,6 +32,23 @@ export interface SettingsNavItem {
16
32
  href?: string;
17
33
  /** Extra search terms so an item is findable beyond its visible label. */
18
34
  keywords?: string[];
35
+ /** Host-resolved situation marker. Omit to render no marker at all. */
36
+ status?: SettingsNavStatus;
37
+ /**
38
+ * What the marker MEANS, in the host's language — "Ligado", "Não visitado",
39
+ * "Incluído no plano Pro". Rendered as text only a screen reader reaches, so
40
+ * colour is never the sole carrier of the meaning. Required alongside
41
+ * `status`: a marker nobody can name is decoration.
42
+ */
43
+ statusLabel?: string;
44
+ /**
45
+ * Render the entry as inert text rather than a control.
46
+ *
47
+ * For a row that is listed but not reachable from here. It is deliberately not
48
+ * a disabled button: disabled says "you cannot", and the honest statement is
49
+ * "not from this screen".
50
+ */
51
+ inert?: boolean;
19
52
  }
20
53
 
21
54
  /** A labelled category grouping related items in the left rail. */
@@ -30,6 +63,43 @@ export interface SettingsNavGroup {
30
63
  items: SettingsNavItem[];
31
64
  }
32
65
 
66
+ /**
67
+ * Which width the rail appears at — and, in `drilldown`, where the whole shell
68
+ * changes shape. Below it the list is the page and a chip strip carries the
69
+ * sibling sections; at and above it the rail takes the left column.
70
+ *
71
+ * A raw pixel number is as valid as a breakpoint name, because a settings area's
72
+ * structural switch is a design decision and not always one of five named
73
+ * widths — 1024px, the tablet-landscape switch, is nowhere in MUI's defaults
74
+ * (`lg` is 1200), and rounding to the nearest name would move 176px of
75
+ * behaviour to keep a string tidy.
76
+ */
77
+ export type SettingsRailBreakpoint = 'sm' | 'md' | 'lg' | 'xl' | number;
78
+
79
+ /**
80
+ * How navigation behaves BELOW {@link SettingsLayoutProps.railBreakpoint}.
81
+ *
82
+ * - `switcher` (default) — the rail folds into a collapsed disclosure stacked
83
+ * above the panel. Backwards-compatible, and right for a shell whose panel is
84
+ * always the point.
85
+ * - `drilldown` — list first, then screen, with a way back. At the area's index
86
+ * the LIST is the page; inside a section the panel is the page and a scrollable
87
+ * chip strip carries its siblings. This is what every mobile settings app
88
+ * does, and what fits at 375px, where a 300px rail would leave 75px of content.
89
+ */
90
+ export type SettingsNavVariant = 'switcher' | 'drilldown';
91
+
92
+ /** The exit offered inside the empty-search state, so the dead end has a door. */
93
+ export interface SettingsEmptySearchAction {
94
+ /** Button label, e.g. `"Limpar a busca"`. */
95
+ label: ReactNode;
96
+ /**
97
+ * Extra work to do on top of clearing the field. The layout always clears its
98
+ * own query; a host that mirrors the term elsewhere clears that here.
99
+ */
100
+ onClear?: () => void;
101
+ }
102
+
33
103
  /**
34
104
  * Props for {@link SettingsLayout} — an agnostic, data-driven two-pane settings
35
105
  * shell (search + grouped left rail, central panel). It renders navigation from
@@ -46,8 +116,25 @@ export interface SettingsLayoutProps {
46
116
  onSelectItem?: (id: string) => void;
47
117
  /** Placeholder for the search field. Defaults to `"Search settings"`. */
48
118
  searchPlaceholder?: string;
119
+ /**
120
+ * The term the search field opens with. Uncontrolled — the layout owns the
121
+ * field from there, and typing (or the empty state's exit) changes it.
122
+ *
123
+ * For a host that deep-links into its own settings search (`?q=…`), and for a
124
+ * story or a test that needs to OPEN in a filtered — or empty — state rather
125
+ * than type its way there. A screen named for a state it cannot be opened in
126
+ * is a screen nobody looks at in that state.
127
+ */
128
+ defaultQuery?: string;
49
129
  /** Shown when the search matches no items. Defaults to a generic string. */
50
130
  emptySearchLabel?: ReactNode;
131
+ /**
132
+ * A way OUT of the empty-search state, rendered inside it.
133
+ *
134
+ * Without one the search that matches nothing is a screen with no next step —
135
+ * the operator's only move is to guess which characters to delete.
136
+ */
137
+ emptySearchAction?: SettingsEmptySearchAction;
51
138
  /**
52
139
  * Element type used to render an item that has an `href` (e.g. a Next.js
53
140
  * `Link`). Rendered as the `ListItemButton` `component`, so navigation stays a
@@ -58,4 +145,30 @@ export interface SettingsLayoutProps {
58
145
  children: ReactNode;
59
146
  /** Prefix for `data-testid` attributes. Defaults to `"settings"`. */
60
147
  testIdPrefix?: string;
148
+ /** Width at which the rail takes its own column. Defaults to `"md"`. */
149
+ railBreakpoint?: SettingsRailBreakpoint;
150
+ /** Narrow-width navigation shape. Defaults to `"switcher"`. */
151
+ navVariant?: SettingsNavVariant;
152
+ /**
153
+ * True when the router is at the area's index rather than inside a section.
154
+ *
155
+ * `drilldown` only. It decides which of the two — the list or the panel — is
156
+ * the page below the breakpoint. Both are always in the DOM; only `display`
157
+ * moves, so the two widths cannot drift apart in what they offer.
158
+ */
159
+ atIndex?: boolean;
160
+ /** Where "back" goes in `drilldown`: the area's index. */
161
+ indexHref?: string;
162
+ /** Label on the back control. Defaults to `"Back"`. */
163
+ backLabel?: string;
164
+ /**
165
+ * The sibling sections carried by the narrow-width chip strip — usually the
166
+ * open item's own group.
167
+ *
168
+ * The strip lives in the DOM at EVERY width and is hidden by CSS above the
169
+ * breakpoint, alongside the rail that replaces it. That is the whole point:
170
+ * navigation chosen by a JS media query is navigation that can exist at one
171
+ * width and not the other, and nothing catches it.
172
+ */
173
+ sectionChips?: SettingsNavItem[];
61
174
  }
@@ -0,0 +1,140 @@
1
+ 'use client';
2
+
3
+ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
4
+ import { Box } from '@mui/material';
5
+ import React from 'react';
6
+
7
+ import { displayAcrossRail, TOUCH_TARGET } from './SettingsLayout.styles';
8
+ import { SettingsSectionChips } from './SettingsSectionChips';
9
+ import type {
10
+ SettingsLayoutProps,
11
+ SettingsNavItem,
12
+ SettingsRailBreakpoint,
13
+ } from './SettingsLayout.types';
14
+
15
+ export interface SettingsPanelProps {
16
+ /** True in `drilldown`, inside a section rather than at the index. */
17
+ inSection: boolean;
18
+ /** True in `drilldown`, at the area's index. */
19
+ hideOnNarrow: boolean;
20
+ breakpoint: SettingsRailBreakpoint;
21
+ indexHref?: string;
22
+ backLabel: string;
23
+ ariaLabel: string;
24
+ sectionChips?: SettingsNavItem[];
25
+ activeItemId?: string;
26
+ linkComponent?: SettingsLayoutProps['linkComponent'];
27
+ onSelectItem?: (id: string) => void;
28
+ testIdPrefix: string;
29
+ children: React.ReactNode;
30
+ }
31
+
32
+ /** The way back to the area's index — narrow widths only. */
33
+ function BackLink({
34
+ href,
35
+ label,
36
+ breakpoint,
37
+ linkComponent,
38
+ testIdPrefix,
39
+ }: {
40
+ href: string;
41
+ label: string;
42
+ breakpoint: SettingsRailBreakpoint;
43
+ linkComponent: NonNullable<SettingsLayoutProps['linkComponent']>;
44
+ testIdPrefix: string;
45
+ }): React.JSX.Element {
46
+ return (
47
+ <Box
48
+ component={linkComponent}
49
+ href={href}
50
+ aria-label={label}
51
+ data-testid={`${testIdPrefix}-back`}
52
+ sx={(theme) => ({
53
+ ...displayAcrossRail(theme, breakpoint, 'inline-flex', 'none'),
54
+ alignItems: 'center',
55
+ gap: 0.5,
56
+ minHeight: TOUCH_TARGET,
57
+ pr: 1,
58
+ textDecoration: 'none',
59
+ color: 'text.secondary',
60
+ font: 'inherit',
61
+ fontSize: '0.875rem',
62
+ })}
63
+ >
64
+ <ChevronLeftIcon fontSize="small" />
65
+ {label}
66
+ </Box>
67
+ );
68
+ }
69
+
70
+ /**
71
+ * The central column: the open screen, and — in `drilldown`, inside a section —
72
+ * the back link and sibling-section strip above it.
73
+ *
74
+ * At the index in `drilldown` this whole column is `display: none` below the
75
+ * breakpoint and the rail becomes the page. Mounted either way: the panel and
76
+ * the list are one tree with `display` between them, not two branches of a
77
+ * render.
78
+ */
79
+ export function SettingsPanel({
80
+ inSection,
81
+ hideOnNarrow,
82
+ breakpoint,
83
+ indexHref,
84
+ backLabel,
85
+ ariaLabel,
86
+ sectionChips,
87
+ activeItemId,
88
+ linkComponent,
89
+ onSelectItem,
90
+ testIdPrefix,
91
+ children,
92
+ }: SettingsPanelProps): React.JSX.Element {
93
+ const showBack = inSection && indexHref !== undefined && linkComponent !== undefined;
94
+ const showChips = inSection && sectionChips !== undefined && sectionChips.length > 0;
95
+
96
+ return (
97
+ <Box
98
+ data-testid={`${testIdPrefix}-panel`}
99
+ sx={(theme) => ({
100
+ ...(hideOnNarrow
101
+ ? displayAcrossRail(theme, breakpoint, 'none', 'block')
102
+ : { display: 'block' }),
103
+ flex: '1 1 auto',
104
+ minWidth: 0,
105
+ width: '100%',
106
+ })}
107
+ >
108
+ {showBack ? (
109
+ <BackLink
110
+ href={indexHref}
111
+ label={backLabel}
112
+ breakpoint={breakpoint}
113
+ linkComponent={linkComponent}
114
+ testIdPrefix={testIdPrefix}
115
+ />
116
+ ) : null}
117
+
118
+ {showChips ? (
119
+ <Box
120
+ sx={(theme) => ({
121
+ ...displayAcrossRail(theme, breakpoint, 'block', 'none'),
122
+ mb: 1,
123
+ minWidth: 0,
124
+ })}
125
+ >
126
+ <SettingsSectionChips
127
+ items={sectionChips}
128
+ activeItemId={activeItemId}
129
+ ariaLabel={ariaLabel}
130
+ linkComponent={linkComponent}
131
+ onSelectItem={onSelectItem}
132
+ testIdPrefix={testIdPrefix}
133
+ />
134
+ </Box>
135
+ ) : null}
136
+
137
+ {children}
138
+ </Box>
139
+ );
140
+ }
@@ -0,0 +1,75 @@
1
+ 'use client';
2
+
3
+ import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
4
+ import { Box } from '@mui/material';
5
+ import React from 'react';
6
+
7
+ import { SR_ONLY_SX, STATUS_COLOR } from './SettingsLayout.styles';
8
+ import type { SettingsNavStatus } from './SettingsLayout.types';
9
+
10
+ export interface SettingsStatusMarkerProps {
11
+ /** Which situation to draw. */
12
+ status: SettingsNavStatus;
13
+ /** What it means, in the host's language — the only carrier a screen reader gets. */
14
+ label?: string;
15
+ /** `data-testid` for the marker element. */
16
+ testId?: string;
17
+ }
18
+
19
+ /**
20
+ * The situation marker beside a settings entry: a coloured dot, or a padlock
21
+ * when the plan does not include the section.
22
+ *
23
+ * The label is rendered as visually-hidden text INSIDE the marker rather than as
24
+ * an `aria-label` on it, so it lands in the accessible name of the row that
25
+ * contains it. A row reading "Endereço, ligado" is one utterance; a decorative
26
+ * dot with a title attribute beside it is two, and the second one is skipped.
27
+ *
28
+ * Colour alone never says which of the three states this is — that is the whole
29
+ * reason `label` exists, and why a marker without one is a marker with no
30
+ * meaning attached.
31
+ */
32
+ export function SettingsStatusMarker({
33
+ status,
34
+ label,
35
+ testId,
36
+ }: SettingsStatusMarkerProps): React.JSX.Element {
37
+ const hidden = label ? <Box component="span" sx={SR_ONLY_SX}>{label}</Box> : null;
38
+
39
+ if (status === 'locked') {
40
+ return (
41
+ <Box
42
+ component="span"
43
+ data-testid={testId}
44
+ data-status={status}
45
+ sx={{ display: 'inline-flex', alignItems: 'center', color: 'text.disabled' }}
46
+ >
47
+ <LockOutlinedIcon sx={{ fontSize: 16 }} />
48
+ {hidden}
49
+ </Box>
50
+ );
51
+ }
52
+
53
+ return (
54
+ <Box
55
+ component="span"
56
+ data-testid={testId}
57
+ data-status={status}
58
+ sx={{ display: 'inline-flex', alignItems: 'center' }}
59
+ >
60
+ <Box
61
+ component="span"
62
+ sx={{
63
+ width: 8,
64
+ height: 8,
65
+ borderRadius: '50%',
66
+ flex: '0 0 auto',
67
+ bgcolor: STATUS_COLOR[status],
68
+ }}
69
+ />
70
+ {hidden}
71
+ </Box>
72
+ );
73
+ }
74
+
75
+ SettingsStatusMarker.displayName = 'SettingsStatusMarker';
@@ -1,6 +1,14 @@
1
1
  export { SettingsLayout } from './SettingsLayout';
2
+ export { SettingsSectionChips } from './SettingsSectionChips';
3
+ export type { SettingsSectionChipsProps } from './SettingsSectionChips';
4
+ export { SettingsStatusMarker } from './SettingsStatusMarker';
5
+ export type { SettingsStatusMarkerProps } from './SettingsStatusMarker';
2
6
  export type {
7
+ SettingsEmptySearchAction,
3
8
  SettingsLayoutProps,
4
9
  SettingsNavGroup,
5
10
  SettingsNavItem,
11
+ SettingsNavStatus,
12
+ SettingsNavVariant,
13
+ SettingsRailBreakpoint,
6
14
  } from './SettingsLayout.types';
@@ -1 +0,0 @@
1
- {"version":3,"file":"SettingsLayout.parts.d.ts","sourceRoot":"","sources":["../../../../../src/components/layout/SettingsLayout/SettingsLayout.parts.tsx"],"names":[],"mappings":"AAgBA,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,KAAK,EAAE,mBAAmB,EAAE,gBAAgB,EAAmB,MAAM,wBAAwB,CAAC;AAKrG,gFAAgF;AAChF,wBAAgB,YAAY,CAAC,MAAM,EAAE,gBAAgB,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAS1F;AAED,6EAA6E;AAC7E,wBAAgB,eAAe,CAC7B,MAAM,EAAE,gBAAgB,EAAE,EAC1B,YAAY,CAAC,EAAE,MAAM,GACpB,MAAM,GAAG,SAAS,CAEpB;AAgMD,UAAU,aAAa;IACrB,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,gBAAgB,EAAE,KAAK,CAAC,SAAS,CAAC;IAClC,cAAc,EAAE,gBAAgB,EAAE,CAAC;IACnC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,mBAAmB,CAAC,eAAe,CAAC,CAAC;IACrD,YAAY,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;IACxB,YAAY,EAAE,MAAM,CAAC;CACtB;AAgED,qFAAqF;AACrF,MAAM,MAAM,iBAAiB,GAAG,aAAa,GAAG;IAC9C,KAAK,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,IAAI,CAAC;CACzB,CAAC;AAEF,gFAAgF;AAChF,wBAAgB,YAAY,CAAC,EAC3B,KAAK,EACL,WAAW,EACX,OAAO,EACP,WAAW,EACX,YAAY,EACZ,GAAG,SAAS,EACb,EAAE,iBAAiB,GAAG,KAAK,CAAC,GAAG,CAAC,OAAO,CAiCvC"}