@scalio-cloud/ui 0.5.0 → 0.5.2

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.2
4
+ - Navigation items take `match` (further paths that mark the item active), for one menu item over several pages.
5
+ - New `SubNav` (tabs as links on top of a page) and `WorkspaceSwitcher` (Identity / HR in Nexus). No change for
6
+ apps that do not use them.
7
+
8
+ ## 0.5.1
9
+ - Phones: the context select (tenant, customer) is hidden in the header below 640px, there is no room next to
10
+ the header buttons. `AppShell` takes `drawerTop` and shows it above the navigation in the phone drawer.
11
+
3
12
  ## 0.5.0
4
13
  - **Visible change:** `rounded-sm` is now 2px as UISPEC says (was 0px from the preset). Buttons, fields, cards and
5
14
  badges in Nexus, Citadel and Foundry get barely visible round corners; Rosetta already had 2px and can drop its
package/README.md CHANGED
@@ -102,6 +102,7 @@ const apps = useScalioApps(() => api.get("/apps").then((r) => r.data)); // onc
102
102
  sidebar={<Sidebar nav={NAV} link={NavLink} lang={lang} version={VERSION} commit={COMMIT}
103
103
  search={{ onOpen: openPalette, shortcut: "⌘K" }} primaryAction={{ label: t("Scan starten"), icon: Play, onSelect: scan }} />}
104
104
  drawerOpen={drawer} onDrawerClose={() => setDrawer(false)} // phones: same sidebar in a drawer (test ids with "m-")
105
+ drawerTop={<ContextSelect ... testId="m-tenant-switcher" />} // below 640px the header hides the select, the drawer shows it
105
106
  >
106
107
  <Routes>...</Routes>
107
108
  </AppShell>
@@ -116,6 +117,12 @@ sidebar renders plain links and marks the item for `activePath`. Item test ids d
116
117
  `EmptyState` (children = next step), `Loading`, `Notice` (`info`, `ok`, `warn`, `error`), `StatusBadge`
117
118
  (tones `neutral`, `running`, `ok`, `warn`, `error`) and `PageHeader` with `back` for detail pages.
118
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
+
119
126
  ## Sign-in page and profile (React)
120
127
 
121
128
  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 {
@@ -97,8 +113,13 @@
97
113
  .scalio-switcher-icon { width: 0.75rem; height: 0.75rem; }
98
114
  .scalio-switcher-soon { margin-left: 0.125rem; padding: 0 0.25rem; border: 1px solid #3f3f46; border-radius: 2px; font-size: 9px; line-height: 14px; text-transform: none; letter-spacing: normal; color: #71717a; }
99
115
 
100
- /* Context select (tenant, customer, organisation) in the header */
116
+ /* Context select (tenant, customer, organisation) in the header; below 640px it moves into the drawer (drawerTop) */
101
117
  .scalio-context { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
118
+ .scalio-header .scalio-context { display: none; }
119
+ @media (min-width: 640px) { .scalio-header .scalio-context { display: flex; } }
120
+ .scalio-sidebar-top .scalio-context { flex-wrap: wrap; }
121
+ .scalio-sidebar-top .scalio-context-label { display: block; width: 100%; }
122
+ .scalio-sidebar-top .scalio-context-field, .scalio-sidebar-top .scalio-context-select { width: 100%; max-width: none; }
102
123
  .scalio-context-label { display: none; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: hsl(var(--muted-foreground)); }
103
124
  @media (min-width: 1024px) { .scalio-context-label { display: inline; } }
104
125
  .scalio-context-field { position: relative; min-width: 0; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalio-cloud/ui",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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/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);
@@ -190,7 +239,9 @@ function NavDrawer({ open, onClose, children, lang, labels, testId }) {
190
239
 
191
240
  // Whole frame: switcher, header, banners, then sidebar + content. The same sidebar element is shown
192
241
  // inside the drawer on phones (variant "drawer", closes on navigation).
193
- function AppShell({ switcher, header, banner, sidebar, children, drawerOpen, onDrawerClose, lang, labels, testId }) {
242
+ // drawerTop: shown above the navigation in the phone drawer, e.g. the context select (the header hides it
243
+ // below 640px, there is no room next to the header buttons).
244
+ function AppShell({ switcher, header, banner, sidebar, children, drawerOpen, onDrawerClose, drawerTop, lang, labels, testId }) {
194
245
  return h(
195
246
  "div",
196
247
  { className: "scalio-shell", "data-testid": testId || "app-shell" },
@@ -207,7 +258,7 @@ function AppShell({ switcher, header, banner, sidebar, children, drawerOpen, onD
207
258
  ? h(
208
259
  NavDrawer,
209
260
  { open: !!drawerOpen, onClose: onDrawerClose, lang, labels },
210
- React.cloneElement(sidebar, { variant: "drawer", onNavigate: onDrawerClose })
261
+ React.cloneElement(sidebar, drawerTop ? { variant: "drawer", onNavigate: onDrawerClose, top: drawerTop } : { variant: "drawer", onNavigate: onDrawerClose })
211
262
  )
212
263
  : null
213
264
  );
@@ -441,6 +492,8 @@ module.exports = {
441
492
  Sidebar,
442
493
  NavItem,
443
494
  NavDrawer,
495
+ SubNav,
496
+ WorkspaceSwitcher,
444
497
  AppShell,
445
498
  ContextSelect,
446
499
  ProductSwitcher,