@scalio-cloud/ui 0.5.2 → 0.6.1

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.1
4
+ - **Visible change for apps using `SettingsLayout`:** the settings page has no outer padding of its own any more;
5
+ the app's content area brings it (0.6.0 had both, so the page got a double margin). No change for other apps.
6
+
7
+ ## 0.6.0
8
+ - New `SettingsLayout` and `SettingsSection` for the settings page (UISPEC "Einstellungen"): sub-navigation on
9
+ the left in fixed groups (Allgemein, app groups, Verbindungen, Benachrichtigungen, Sicherheit, System) with a
10
+ search, a select on phones, crumb and title of the area; areas look like the profile page. Below three areas
11
+ the sub-navigation is left out. `LABELS` gets the group names. No change for apps that do not use them.
12
+
3
13
  ## 0.5.2
4
14
  - Navigation items take `match` (further paths that mark the item active), for one menu item over several pages.
5
15
  - New `SubNav` (tabs as links on top of a page) and `WorkspaceSwitcher` (Identity / HR in Nexus). No change for
package/README.md CHANGED
@@ -123,6 +123,33 @@ Pages that belong together under one menu item keep their own addresses: the ite
123
123
  on top. An app with two work areas (Nexus Identity and HR) puts `<WorkspaceSwitcher options value onSelect />`
124
124
  into the sidebar's `top`.
125
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
+
126
153
  ## Sign-in page and profile (React)
127
154
 
128
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
@@ -204,3 +204,41 @@
204
204
  .dark .scalio-badge.is-running { color: #8ba6f7; border-color: rgb(139 166 247 / 0.4); }
205
205
  .dark .scalio-badge.is-ok, .dark .scalio-kpi.is-ok .scalio-kpi-value { color: #4ade80; }
206
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
+ /* No outer padding: the app's content area already has its own. */
210
+ .scalio-settings { display: flex; flex-direction: column; gap: 1.5rem; min-width: 0; }
211
+ @media (min-width: 768px) { .scalio-settings { flex-direction: row; align-items: flex-start; gap: 2.5rem; } }
212
+ .scalio-settings-nav { display: flex; flex-direction: column; gap: 0.75rem; min-width: 0; }
213
+ @media (min-width: 768px) { .scalio-settings-nav { width: 15rem; flex-shrink: 0; position: sticky; top: 1.5rem; } }
214
+ .scalio-settings-search {
215
+ display: flex; align-items: center; gap: 0.5rem; height: 2.25rem; padding: 0 0.625rem;
216
+ border: 1px solid hsl(var(--input)); border-radius: 2px; background: hsl(var(--background)); color: hsl(var(--muted-foreground));
217
+ }
218
+ .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)); }
219
+ .scalio-settings-search:focus-within { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; }
220
+ .scalio-settings-select {
221
+ display: block; height: 2.25rem; padding: 0 0.5rem; border: 1px solid hsl(var(--input)); border-radius: 2px;
222
+ background: hsl(var(--background)); color: hsl(var(--foreground)); font: inherit; font-size: 0.875rem;
223
+ }
224
+ /* Phones: only the select; the search filters the list, which phones do not show. */
225
+ .scalio-settings-list, .scalio-settings-search { display: none; }
226
+ @media (min-width: 768px) {
227
+ .scalio-settings-select { display: none; }
228
+ .scalio-settings-search { display: flex; }
229
+ .scalio-settings-list { display: flex; flex-direction: column; gap: 1rem; }
230
+ }
231
+ .scalio-settings-group { display: flex; flex-direction: column; }
232
+ .scalio-settings-group .scalio-nav-heading { padding: 0 0.75rem 0.375rem; }
233
+ .scalio-settings-item {
234
+ display: block; padding: 0.375rem 0.75rem; border-left: 2px solid transparent; font-size: 0.875rem;
235
+ color: hsl(var(--muted-foreground)); text-decoration: none; transition: color 150ms, background-color 150ms;
236
+ }
237
+ .scalio-settings-item:hover { color: hsl(var(--foreground)); background: hsl(var(--muted)); }
238
+ .scalio-settings-item.is-active { border-left-color: hsl(var(--primary)); color: hsl(var(--foreground)); font-weight: 600; background: hsl(var(--muted)); }
239
+ .scalio-settings-item:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: -2px; }
240
+ .scalio-settings-empty { margin: 0; padding: 0 0.75rem; font-size: 0.8125rem; color: hsl(var(--muted-foreground)); }
241
+ .scalio-settings-main { flex: 1 1 auto; min-width: 0; max-width: 64rem; }
242
+ .scalio-settings-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 1rem; margin-bottom: 1.5rem; }
243
+ .scalio-settings-body { display: flex; flex-direction: column; }
244
+ .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.2",
3
+ "version": "0.6.1",
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
 
@@ -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) };