@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 +11 -0
- package/README.md +33 -0
- package/nav.css +53 -0
- package/package.json +1 -1
- package/react/index.js +1 -1
- package/react/labels.js +10 -0
- package/react/nav.js +53 -2
- package/react/settings.js +216 -0
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.
|
|
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) };
|