@scalio-cloud/ui 0.5.1 → 0.6.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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+ - New `SettingsLayout` and `SettingsSection` for the settings page (UISPEC "Einstellungen"): sub-navigation on
5
+ the left in fixed groups (Allgemein, app groups, Verbindungen, Benachrichtigungen, Sicherheit, System) with a
6
+ search, a select on phones, crumb and title of the area; areas look like the profile page. Below three areas
7
+ the sub-navigation is left out. `LABELS` gets the group names. No change for apps that do not use them.
8
+
9
+ ## 0.5.2
10
+ - Navigation items take `match` (further paths that mark the item active), for one menu item over several pages.
11
+ - New `SubNav` (tabs as links on top of a page) and `WorkspaceSwitcher` (Identity / HR in Nexus). No change for
12
+ apps that do not use them.
13
+
3
14
  ## 0.5.1
4
15
  - Phones: the context select (tenant, customer) is hidden in the header below 640px, there is no room next to
5
16
  the header buttons. `AppShell` takes `drawerTop` and shows it above the navigation in the phone drawer.
package/README.md CHANGED
@@ -117,6 +117,39 @@ sidebar renders plain links and marks the item for `activePath`. Item test ids d
117
117
  `EmptyState` (children = next step), `Loading`, `Notice` (`info`, `ok`, `warn`, `error`), `StatusBadge`
118
118
  (tones `neutral`, `running`, `ok`, `warn`, `error`) and `PageHeader` with `back` for detail pages.
119
119
 
120
+ Pages that belong together under one menu item keep their own addresses: the item lists them in `match`
121
+ (`{ to: "/groups", label: "Gruppen", match: ["/teams", "/distribution-lists"] }`, pass `activePath` with
122
+ `link`) and each page shows `<SubNav items={[{ to: "/groups", label: "Sicherheit", end: true }, ...]} link={NavLink} />`
123
+ on top. An app with two work areas (Nexus Identity and HR) puts `<WorkspaceSwitcher options value onSelect />`
124
+ into the sidebar's `top`.
125
+
126
+ ## Settings page (React)
127
+
128
+ Every area of the settings has its own address (`/settings/<area>`); the app's router picks the area, the
129
+ layout draws the sub-navigation, the crumb ("Einstellungen › Verbindungen") and the title. Groups `general`,
130
+ `connections`, `notifications`, `security` and `system` get their names from `LABELS` and always sit in this
131
+ order, the app's own groups (with `label`) come after "Allgemein". Hidden areas and empty groups disappear, below
132
+ three areas (`minItems`) there is no sub-navigation. The search looks at label, hint, group and `keywords`;
133
+ `search={false}` leaves it out, `search={<OwnSearch />}` puts the app's own search there.
134
+
135
+ ```jsx
136
+ import { SettingsLayout, SettingsSection } from "@scalio-cloud/ui/react";
137
+
138
+ const GROUPS = [
139
+ { id: "general", items: [{ id: "general", to: "/settings/general", label: "Instanz", hint: "Name, Sprache, Zeitzone" }] },
140
+ { id: "flows", label: "Abläufe", items: [{ id: "onboarding", to: "/settings/onboarding", label: "Onboarding" }] },
141
+ { id: "connections", items: [{ id: "sync", to: "/settings/sync", label: "Synchronisierung", keywords: ["Intervall"] }] },
142
+ { id: "system", items: [{ id: "updates", to: "/settings/updates", label: "Updates", hidden: saas }] },
143
+ ];
144
+
145
+ <SettingsLayout groups={GROUPS} active={area} link={NavLink} onSelect={(i) => navigate(i.to)} lang={lang}>
146
+ <SettingsSection id="cloud-sync" title="Cloud-Synchronisierung" description="Intervall und Umfang."
147
+ footer={<Button onClick={save}>Speichern</Button>}>
148
+ ...form fields (shadcn)...
149
+ </SettingsSection>
150
+ </SettingsLayout>
151
+ ```
152
+
120
153
  ## Sign-in page and profile (React)
121
154
 
122
155
  All sign-in pages use the same frame and order: Microsoft first, then "oder mit E-Mail", the password field with
package/nav.css CHANGED
@@ -32,6 +32,22 @@
32
32
  .scalio-nav-item.is-active { border-left-color: hsl(var(--primary)); background: hsl(var(--background)); color: hsl(var(--foreground)); font-weight: 600; }
33
33
  .scalio-nav-item.is-bold { font-weight: 700; }
34
34
  .scalio-nav-item:focus-visible, .scalio-nav-cta:focus-visible, .scalio-nav-search:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: -2px; }
35
+ .scalio-workspace { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 0.25rem; padding: 0.25rem; border-radius: 0.375rem; background: hsl(var(--border) / 0.6); }
36
+ .scalio-workspace-item {
37
+ display: flex; align-items: center; justify-content: center; gap: 0.375rem; padding: 0.375rem 0.5rem; border: 0; border-radius: 0.25rem;
38
+ background: transparent; font: inherit; font-size: 0.75rem; font-weight: 600; color: hsl(var(--muted-foreground)); cursor: pointer;
39
+ }
40
+ .scalio-workspace-item.is-active { background: hsl(var(--background)); color: hsl(var(--primary)); box-shadow: 0 1px 2px rgb(0 0 0 / 0.08); }
41
+ .scalio-workspace-item:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; }
42
+ .scalio-workspace-item .scalio-icon { width: 0.875rem; height: 0.875rem; }
43
+ .scalio-subnav { display: flex; gap: 0.25rem; margin-bottom: 1.5rem; border-bottom: 1px solid hsl(var(--border)); overflow-x: auto; }
44
+ .scalio-subnav-item {
45
+ display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; margin-bottom: -1px; white-space: nowrap;
46
+ border-bottom: 2px solid transparent; font-size: 0.875rem; color: hsl(var(--muted-foreground)); text-decoration: none;
47
+ }
48
+ .scalio-subnav-item:hover { color: hsl(var(--foreground)); }
49
+ .scalio-subnav-item.is-active { border-bottom-color: hsl(var(--primary)); color: hsl(var(--foreground)); font-weight: 600; }
50
+ .scalio-subnav-item:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: -2px; }
35
51
  .scalio-nav-icon { width: 1rem; height: 1rem; flex-shrink: 0; }
36
52
  .scalio-nav-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
37
53
  .scalio-nav-badge {
@@ -188,3 +204,40 @@
188
204
  .dark .scalio-badge.is-running { color: #8ba6f7; border-color: rgb(139 166 247 / 0.4); }
189
205
  .dark .scalio-badge.is-ok, .dark .scalio-kpi.is-ok .scalio-kpi-value { color: #4ade80; }
190
206
  .dark .scalio-badge.is-warn, .dark .scalio-kpi.is-warn .scalio-kpi-value { color: #fbbf24; }
207
+
208
+ /* Settings page: sub-navigation left (from 768px), areas right; phones get a select instead. */
209
+ .scalio-settings { display: flex; flex-direction: column; gap: 1.5rem; padding: 1.5rem; min-width: 0; }
210
+ @media (min-width: 768px) { .scalio-settings { flex-direction: row; align-items: flex-start; gap: 2.5rem; padding: 2rem 2.5rem; } }
211
+ .scalio-settings-nav { display: flex; flex-direction: column; gap: 0.75rem; min-width: 0; }
212
+ @media (min-width: 768px) { .scalio-settings-nav { width: 15rem; flex-shrink: 0; position: sticky; top: 1.5rem; } }
213
+ .scalio-settings-search {
214
+ display: flex; align-items: center; gap: 0.5rem; height: 2.25rem; padding: 0 0.625rem;
215
+ border: 1px solid hsl(var(--input)); border-radius: 2px; background: hsl(var(--background)); color: hsl(var(--muted-foreground));
216
+ }
217
+ .scalio-settings-search input { flex: 1 1 auto; min-width: 0; border: 0; outline: 0; background: transparent; font: inherit; font-size: 0.8125rem; color: hsl(var(--foreground)); }
218
+ .scalio-settings-search:focus-within { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; }
219
+ .scalio-settings-select {
220
+ display: block; height: 2.25rem; padding: 0 0.5rem; border: 1px solid hsl(var(--input)); border-radius: 2px;
221
+ background: hsl(var(--background)); color: hsl(var(--foreground)); font: inherit; font-size: 0.875rem;
222
+ }
223
+ /* Phones: only the select; the search filters the list, which phones do not show. */
224
+ .scalio-settings-list, .scalio-settings-search { display: none; }
225
+ @media (min-width: 768px) {
226
+ .scalio-settings-select { display: none; }
227
+ .scalio-settings-search { display: flex; }
228
+ .scalio-settings-list { display: flex; flex-direction: column; gap: 1rem; }
229
+ }
230
+ .scalio-settings-group { display: flex; flex-direction: column; }
231
+ .scalio-settings-group .scalio-nav-heading { padding: 0 0.75rem 0.375rem; }
232
+ .scalio-settings-item {
233
+ display: block; padding: 0.375rem 0.75rem; border-left: 2px solid transparent; font-size: 0.875rem;
234
+ color: hsl(var(--muted-foreground)); text-decoration: none; transition: color 150ms, background-color 150ms;
235
+ }
236
+ .scalio-settings-item:hover { color: hsl(var(--foreground)); background: hsl(var(--muted)); }
237
+ .scalio-settings-item.is-active { border-left-color: hsl(var(--primary)); color: hsl(var(--foreground)); font-weight: 600; background: hsl(var(--muted)); }
238
+ .scalio-settings-item:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: -2px; }
239
+ .scalio-settings-empty { margin: 0; padding: 0 0.75rem; font-size: 0.8125rem; color: hsl(var(--muted-foreground)); }
240
+ .scalio-settings-main { flex: 1 1 auto; min-width: 0; max-width: 64rem; }
241
+ .scalio-settings-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 1rem; margin-bottom: 1.5rem; }
242
+ .scalio-settings-body { display: flex; flex-direction: column; }
243
+ .scalio-settings-section-footer { display: flex; justify-content: flex-end; gap: 0.5rem; padding-top: 0.25rem; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalio-cloud/ui",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Scalio Control Room design tokens, Tailwind preset, self-hosted fonts, the shared app header, sign-in layout, sidebar and page blocks",
5
5
  "license": "SEE LICENSE IN README.md",
6
6
  "private": false,
package/react/index.js CHANGED
@@ -333,6 +333,6 @@ module.exports = {
333
333
  TEST_IDS,
334
334
  initials,
335
335
  };
336
- Object.assign(module.exports, Nav, Blocks);
336
+ Object.assign(module.exports, Nav, Blocks, require("./settings"));
337
337
  // After the exports above: auth.js uses LanguageToggle and ThemeToggle from this module.
338
338
  Object.assign(module.exports, require("./auth"));
package/react/labels.js CHANGED
@@ -40,6 +40,11 @@ const LABELS = {
40
40
  closeMenu: "Menü schliessen",
41
41
  tenant: "Tenant",
42
42
  customer: "Kunde",
43
+ // Settings page (fixed groups, same order in every app)
44
+ settingsGroups: { general: "Allgemein", connections: "Verbindungen", notifications: "Benachrichtigungen", security: "Sicherheit", system: "System" },
45
+ searchSettings: "Einstellung suchen",
46
+ noSettingsMatch: "Kein Bereich gefunden",
47
+ settingsArea: "Bereich",
43
48
  // Switcher
44
49
  soon: "bald",
45
50
  switchTo: "Zu dieser Anwendung wechseln",
@@ -109,6 +114,10 @@ const LABELS = {
109
114
  closeMenu: "Close menu",
110
115
  tenant: "Tenant",
111
116
  customer: "Customer",
117
+ settingsGroups: { general: "General", connections: "Connections", notifications: "Notifications", security: "Security", system: "System" },
118
+ searchSettings: "Find a setting",
119
+ noSettingsMatch: "No area found",
120
+ settingsArea: "Area",
112
121
  soon: "soon",
113
122
  switchTo: "Switch to this application",
114
123
  activeApp: "Active application",
@@ -146,6 +155,7 @@ function labelsFor(lang, overrides) {
146
155
  return Object.assign({}, base, o, {
147
156
  sections: Object.assign({}, base.sections, o.sections),
148
157
  sectionHints: Object.assign({}, base.sectionHints, o.sectionHints),
158
+ settingsGroups: Object.assign({}, base.settingsGroups, o.settingsGroups),
149
159
  });
150
160
  }
151
161
 
package/react/nav.js CHANGED
@@ -47,10 +47,12 @@ function NavItem({ item, link, activePath, onNavigate, testIdPrefix }) {
47
47
  : null,
48
48
  ];
49
49
  const testId = itemTestId(item, testIdPrefix);
50
+ // match: further paths that belong to this item (e.g. "Gruppen" also for /teams); needs activePath.
51
+ const matched = (item.match || []).some((m) => isActivePath(activePath, m));
50
52
  if (link) {
51
- return h(link, { to: item.to, end: item.end, className: ({ isActive }) => cls(isActive), onClick: onNavigate, "data-testid": testId }, content);
53
+ return h(link, { to: item.to, end: item.end, className: ({ isActive }) => cls(isActive || matched), onClick: onNavigate, "data-testid": testId }, content);
52
54
  }
53
- const active = isActivePath(activePath, item.to, item.end);
55
+ const active = isActivePath(activePath, item.to, item.end) || matched;
54
56
  return h("a", { href: item.to, className: cls(active), "aria-current": active ? "page" : undefined, onClick: onNavigate, "data-testid": testId }, content);
55
57
  }
56
58
 
@@ -140,6 +142,53 @@ function Sidebar(props) {
140
142
  );
141
143
  }
142
144
 
145
+ // Tabs as links on top of a page, for pages that belong together under one menu item
146
+ // (e.g. Gruppen: Sicherheit, Verteiler, Microsoft 365, Teams). Each tab keeps its own address.
147
+ function SubNav({ items, link, activePath, label, testId }) {
148
+ const visible = (items || []).filter((i) => i && !i.hidden);
149
+ if (visible.length < 2) return null;
150
+ return h(
151
+ "nav",
152
+ { className: "scalio-subnav", "aria-label": label || undefined, "data-testid": testId || "subnav" },
153
+ visible.map((item) => {
154
+ const tid = item.testId || `subnav-${slug(item.to)}`;
155
+ const cls = (active) => cx("scalio-subnav-item", active && "is-active");
156
+ const content = [item.label, item.badge != null && item.badge !== false ? h("span", { key: "b", className: "scalio-nav-badge" }, item.badge) : null];
157
+ if (link) {
158
+ return h(link, { key: item.to, to: item.to, end: item.end, className: ({ isActive }) => cls(isActive), "data-testid": tid }, content);
159
+ }
160
+ const active = isActivePath(activePath, item.to, item.end);
161
+ return h("a", { key: item.to, href: item.to, className: cls(active), "aria-current": active ? "page" : undefined, "data-testid": tid }, content);
162
+ })
163
+ );
164
+ }
165
+
166
+ // Two (or more) work areas of one app, e.g. Nexus Identity and HR. Sits in the sidebar's top slot.
167
+ // options: [{ id, label, icon }]; onSelect(id) switches (the app navigates).
168
+ function WorkspaceSwitcher({ options, value, onSelect, testIdPrefix, testId }) {
169
+ const prefix = testIdPrefix || "";
170
+ if (!options || options.length < 2) return null;
171
+ return h(
172
+ "div",
173
+ { className: "scalio-workspace", role: "group", "data-testid": prefix + (testId || "workspace-switcher") },
174
+ options.map((o) =>
175
+ h(
176
+ "button",
177
+ {
178
+ key: o.id,
179
+ type: "button",
180
+ className: cx("scalio-workspace-item", o.id === value && "is-active"),
181
+ "aria-pressed": o.id === value,
182
+ onClick: () => onSelect && onSelect(o.id),
183
+ "data-testid": `${prefix}ws-${o.id}`,
184
+ },
185
+ o.icon ? h(o.icon, { className: "scalio-icon", "aria-hidden": "true" }) : null,
186
+ o.label
187
+ )
188
+ )
189
+ );
190
+ }
191
+
143
192
  // "Version 1.2.4 · 3f2a1bc" at the bottom of the sidebar.
144
193
  function VersionTag({ version, commit, lang, labels, testId }) {
145
194
  const L = labelsFor(lang, labels);
@@ -443,6 +492,8 @@ module.exports = {
443
492
  Sidebar,
444
493
  NavItem,
445
494
  NavDrawer,
495
+ SubNav,
496
+ WorkspaceSwitcher,
446
497
  AppShell,
447
498
  ContextSelect,
448
499
  ProductSwitcher,
@@ -0,0 +1,216 @@
1
+ "use strict";
2
+ // Settings page (UISPEC "Einstellungen"): sub-navigation on the left in fixed groups, the areas on the
3
+ // right in the style of the profile page (title and hint left, card right). Every area has its own
4
+ // address (/settings/<area>), the app's router decides which one is shown.
5
+ const React = require("react");
6
+ const h = React.createElement;
7
+ const Icons = require("./icons");
8
+ const { labelsFor } = require("./labels");
9
+
10
+ function cx() {
11
+ return Array.prototype.slice.call(arguments).filter(Boolean).join(" ");
12
+ }
13
+
14
+ // Fixed order: Allgemein first, then the app's own groups, then Verbindungen, Benachrichtigungen,
15
+ // Sicherheit, System. Labels of the fixed groups come from LABELS; app groups bring their own.
16
+ const FIXED_HEAD = ["general"];
17
+ const FIXED_TAIL = ["connections", "notifications", "security", "system"];
18
+
19
+ function orderGroups(groups) {
20
+ const list = (groups || []).filter(Boolean);
21
+ const pick = (ids) => ids.map((id) => list.find((g) => g.id === id)).filter(Boolean);
22
+ const fixed = FIXED_HEAD.concat(FIXED_TAIL);
23
+ return pick(FIXED_HEAD).concat(list.filter((g) => fixed.indexOf(g.id) < 0), pick(FIXED_TAIL));
24
+ }
25
+
26
+ // Visible groups with their visible items, in the fixed order. Empty groups disappear.
27
+ function settingsGroups(groups, L) {
28
+ return orderGroups(groups)
29
+ .map((g) => Object.assign({}, g, {
30
+ label: g.label || (L && L.settingsGroups && L.settingsGroups[g.id]) || g.id,
31
+ items: (g.items || []).filter((i) => i && !i.hidden),
32
+ }))
33
+ .filter((g) => g.items.length > 0);
34
+ }
35
+
36
+ function norm(s) {
37
+ return String(s || "").toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
38
+ }
39
+
40
+ // Search over label, hint, group label and keywords; every word must match somewhere.
41
+ function matchSetting(item, group, query) {
42
+ const words = norm(query).split(/\s+/).filter(Boolean);
43
+ if (!words.length) return true;
44
+ const hay = norm([item.label, item.hint, group && group.label].concat(item.keywords || []).join(" "));
45
+ return words.every((w) => hay.indexOf(w) >= 0);
46
+ }
47
+
48
+ function findSetting(groups, id) {
49
+ for (const g of groups) {
50
+ for (const i of g.items) if (i.id === id) return { group: g, item: i };
51
+ }
52
+ return null;
53
+ }
54
+
55
+ // groups: [{ id, label?, items: [{ id, to, label, hint?, keywords?, hidden? }] }]
56
+ // active: id of the area shown; onSelect(item): the app navigates (used by search and the phone select);
57
+ // link: the router's NavLink for the list (optional, without it plain links + onSelect).
58
+ // minItems: below this many areas the sub-navigation is left out (Citadel, Foundry), default 3.
59
+ // search: true (default, filters the list), false, or the app's own search element.
60
+ function SettingsLayout(props) {
61
+ const { groups, active, onSelect, link, lang, labels, title, hint, actions, children, minItems, search, testId } = props;
62
+ const L = labelsFor(lang, labels);
63
+ const visible = settingsGroups(groups, L);
64
+ const [query, setQuery] = React.useState("");
65
+ const current = findSetting(visible, active);
66
+ const count = visible.reduce((n, g) => n + g.items.length, 0);
67
+ const showNav = count >= (minItems != null ? minItems : 3);
68
+ const filtered = visible
69
+ .map((g) => Object.assign({}, g, { items: g.items.filter((i) => matchSetting(i, g, query)) }))
70
+ .filter((g) => g.items.length > 0);
71
+
72
+ const go = (item) => {
73
+ setQuery("");
74
+ if (onSelect) onSelect(item);
75
+ };
76
+
77
+ const navItem = (item) => {
78
+ const isActive = item.id === active;
79
+ const cls = cx("scalio-settings-item", isActive && "is-active");
80
+ const tid = `settings-nav-${item.id}`;
81
+ if (link) {
82
+ return h(link, { key: item.id, to: item.to, className: () => cls, onClick: () => setQuery(""), "data-testid": tid, "aria-current": isActive ? "page" : undefined }, item.label);
83
+ }
84
+ return h(
85
+ "a",
86
+ {
87
+ key: item.id,
88
+ href: item.to,
89
+ className: cls,
90
+ "aria-current": isActive ? "page" : undefined,
91
+ "data-testid": tid,
92
+ onClick: (e) => {
93
+ if (onSelect) {
94
+ e.preventDefault();
95
+ go(item);
96
+ }
97
+ },
98
+ },
99
+ item.label
100
+ );
101
+ };
102
+
103
+ // search: false = none, an element = the app's own search (e.g. Nexus jumps to single settings).
104
+ const searchBox = search === false ? null : React.isValidElement(search) ? h("div", { className: "scalio-settings-search-slot" }, search) : h(
105
+ "label",
106
+ { className: "scalio-settings-search" },
107
+ h(Icons.Search, { className: "scalio-icon", "aria-hidden": "true" }),
108
+ h("input", {
109
+ type: "search",
110
+ value: query,
111
+ placeholder: L.searchSettings,
112
+ "aria-label": L.searchSettings,
113
+ "data-testid": "settings-search",
114
+ onChange: (e) => setQuery(e.target.value),
115
+ onKeyDown: (e) => {
116
+ // Enter opens the first match, Escape clears the search.
117
+ if (e.key === "Enter" && filtered.length) {
118
+ e.preventDefault();
119
+ go(filtered[0].items[0]);
120
+ }
121
+ if (e.key === "Escape") setQuery("");
122
+ },
123
+ })
124
+ );
125
+
126
+ const nav = showNav
127
+ ? h(
128
+ "nav",
129
+ { className: "scalio-settings-nav", "aria-label": L.settings, "data-testid": "settings-nav" },
130
+ searchBox,
131
+ // Phones: one select instead of the list.
132
+ h(
133
+ "select",
134
+ {
135
+ className: "scalio-settings-select",
136
+ value: active || "",
137
+ "aria-label": L.settingsArea,
138
+ "data-testid": "settings-select",
139
+ onChange: (e) => {
140
+ const hit = findSetting(visible, e.target.value);
141
+ if (hit) go(hit.item);
142
+ },
143
+ },
144
+ visible.map((g) =>
145
+ h("optgroup", { key: g.id, label: g.label }, g.items.map((i) => h("option", { key: i.id, value: i.id }, i.label)))
146
+ )
147
+ ),
148
+ h(
149
+ "div",
150
+ { className: "scalio-settings-list" },
151
+ filtered.length
152
+ ? filtered.map((g) =>
153
+ h(
154
+ "div",
155
+ { key: g.id, className: "scalio-settings-group", "data-group": g.id },
156
+ h("div", { className: "scalio-nav-heading" }, g.label),
157
+ g.items.map(navItem)
158
+ )
159
+ )
160
+ : h("p", { className: "scalio-settings-empty", "data-testid": "settings-search-empty" }, L.noSettingsMatch)
161
+ )
162
+ )
163
+ : null;
164
+
165
+ const heading = title || (current && current.item.label) || L.settings;
166
+ const sub = hint !== undefined ? hint : current && current.item.hint;
167
+ return h(
168
+ "div",
169
+ { className: cx("scalio-settings", !showNav && "is-single"), "data-testid": testId || "settings-layout" },
170
+ nav,
171
+ h(
172
+ "div",
173
+ { className: "scalio-settings-main" },
174
+ h(
175
+ "div",
176
+ { className: "scalio-settings-head" },
177
+ h(
178
+ "div",
179
+ { className: "scalio-settings-head-text" },
180
+ h(
181
+ "div",
182
+ { className: "scalio-eyebrow", "data-testid": "settings-crumb" },
183
+ current && showNav ? `${L.settings} › ${current.group.label}` : L.settings
184
+ ),
185
+ h("h1", { className: "scalio-page-title", "data-testid": "settings-title" }, heading),
186
+ sub ? h("p", { className: "scalio-page-subtitle" }, sub) : null
187
+ ),
188
+ actions ? h("div", { className: "scalio-page-actions" }, actions) : null
189
+ ),
190
+ h("div", { className: "scalio-settings-body" }, children)
191
+ )
192
+ );
193
+ }
194
+
195
+ // One block of an area: title and hint left, the form in a card right (same as ProfileSection).
196
+ // footer: e.g. the area's save button, under the card content.
197
+ function SettingsSection({ id, title, description, children, footer, testId }) {
198
+ return h(
199
+ "section",
200
+ { className: "scalio-profile-section scalio-settings-section", id: id ? `settings-${id}` : undefined, "data-testid": testId || (id ? `settings-section-${id}` : undefined) },
201
+ h(
202
+ "div",
203
+ { className: "scalio-profile-section-text" },
204
+ h("h2", { className: "scalio-profile-section-title" }, title),
205
+ description ? h("p", { className: "scalio-profile-section-desc" }, description) : null
206
+ ),
207
+ h(
208
+ "div",
209
+ { className: "scalio-profile-section-body" },
210
+ children,
211
+ footer ? h("div", { className: "scalio-settings-section-footer" }, footer) : null
212
+ )
213
+ );
214
+ }
215
+
216
+ module.exports = { SettingsLayout, SettingsSection, settingsGroups, matchSetting, SETTINGS_GROUP_ORDER: FIXED_HEAD.concat(["<app>"], FIXED_TAIL) };