@scalio-cloud/ui 0.17.0 → 0.18.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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.1
4
+ Page actions after Markus' feedback (2026-10-31, UISPEC section 2 "Page actions"). 0.18.0 was released before these
5
+ changes were merged; apps use 0.18.1.
6
+ - The slot is shown only when a page has actions and pushes the menu down; no reserved room, no separator line.
7
+ - 0.25rem above the buttons, 0.875rem from the last button to the next section heading (half the usual gap).
8
+ - Only Übersicht (first item of a first section without heading) is pinned; further items of that section stay in
9
+ the menu (Pulse: Geräte).
10
+ - Buttons in the slot: same height (2.25rem), one line, long labels end with "…". Labels without "Neu" (README).
11
+ - **Visible change:** only in apps that set `pageActions`. No token changes.
12
+
13
+ ## 0.18.0
14
+ Page actions in the sidebar, first version (Markus 2026-10-31, reference Scalio Kontor; scalio-docs ADR 0042 Nachtrag).
15
+ - New `react/actions.js`: `PageActions` puts a page's actions (Neu …, Import, Zurück) into a fixed slot in the
16
+ sidebar right below Übersicht from 768px; on phones they stay in the page header. `ActionSlot`, `useDesktop()`.
17
+ - `Sidebar` gets `pageActions`: Übersicht pinned on top, then the slot (room for two buttons, line below), then the
18
+ sections. `shell.css`: an empty `.scalio-page-actions` is hidden. `react-dom` is an optional peer dependency.
19
+ - **Visible change:** none until an app sets `pageActions`. No token changes.
20
+
3
21
  ## 0.17.0
4
22
  Scalio Pulse is built (scalio-docs ADR 0043, P1 started 2026-10-31).
5
23
  - Product switcher: "Pulse" is no longer marked "bald"; it behaves like the other apps (link from `SCALIO_APP_LINKS`).
package/README.md CHANGED
@@ -129,6 +129,20 @@ Pages that belong together under one menu item keep their own addresses: the ite
129
129
  on top. An app with two work areas (Nexus Identity and HR) puts `<WorkspaceSwitcher options value onSelect />`
130
130
  into the sidebar's `top`.
131
131
 
132
+ **Page actions in the sidebar** (from 0.18.0, UISPEC section 2): `<Sidebar pageActions ... />` keeps Übersicht (the
133
+ first item of a first section without heading) on top and adds a slot right below it. A page wraps its actions in
134
+ `<PageActions>`; from 768px they move into the slot, on phones they stay in the page header. The slot takes no
135
+ room on pages without actions; with actions it pushes the menu down.
136
+
137
+ ```jsx
138
+ <PageHeader title="Kontakte" actions={<PageActions><Button onClick={add}><Plus /> Kontakt</Button></PageActions>} />
139
+ <PageHeader title={doc.number} actions={<PageActions><Button variant="outline" asChild><Link to="/invoices"><ArrowLeft /> Zurück</Link></Button></PageActions>} />
140
+ ```
141
+
142
+ Only actions that start something or lead away go there (Neu …, Import, Scannen, Zurück), at most two per page.
143
+ Labels without "Neu"/"New": the + icon says it ("+ Kontakt", not "+ Neuer Kontakt").
144
+ Filters, search, tabs and Speichern stay on the page. Apps with their own sidebar place `<ActionSlot />` themselves.
145
+
132
146
  ## Settings page (React)
133
147
 
134
148
  Every area of the settings has its own address (`/settings/<area>`); the app's router picks the area, the
package/nav.css CHANGED
@@ -17,6 +17,24 @@
17
17
  .scalio-sidebar-top { padding: 0.75rem 0.75rem 0; }
18
18
  .scalio-sidebar-scroll { flex: 1 1 auto; overflow-y: auto; padding: 0.5rem 0 0.75rem; }
19
19
  .scalio-nav-section + .scalio-nav-section { margin-top: 0.25rem; }
20
+ /* Page actions (react/actions.js): Übersicht stays on top, then the slot for the page's actions. The slot takes no
21
+ room when a page has no actions; with actions it shows them and pushes the menu down. No separator line. */
22
+ .scalio-sidebar-pinned { flex-shrink: 0; padding-top: 0.5rem; }
23
+ .scalio-action-slot { flex-shrink: 0; margin: 0 0.75rem; }
24
+ .scalio-sidebar-scroll.has-action-slot { padding-top: 0; }
25
+ .scalio-side-actions, .scalio-side-actions > div { display: flex; flex-direction: column; align-items: stretch; gap: 0.375rem; }
26
+ .scalio-side-actions { padding-top: 0.25rem; }
27
+ /* Below the buttons: half the usual space before the next heading (0.875rem instead of 1.75rem), a little room
28
+ before a plain menu item. */
29
+ .scalio-action-slot:has(.scalio-side-actions:not(:empty)) + .scalio-sidebar-scroll > .scalio-nav-section:first-child > .scalio-nav-heading { padding-top: 0.875rem; }
30
+ .scalio-action-slot:has(.scalio-side-actions:not(:empty)) + .scalio-sidebar-scroll > .scalio-nav-section:first-child:not(:has(> .scalio-nav-heading)) { padding-top: 0.375rem; }
31
+ .scalio-side-actions:empty { display: none; }
32
+ /* Same height for every button (2.25rem), one line; a long label is cut with "…". */
33
+ .scalio-side-actions > :not(div), .scalio-side-actions > div > * {
34
+ box-sizing: border-box; width: 100%; height: 2.25rem; min-height: 0; margin: 0; justify-content: center;
35
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
36
+ }
37
+ .scalio-side-actions input[type="file"], .scalio-side-actions [hidden] { display: none; }
20
38
  .scalio-nav-heading {
21
39
  padding: 1.25rem 1.25rem 0.5rem; font-size: 11px; font-weight: 700; text-transform: uppercase;
22
40
  letter-spacing: 0.12em; color: hsl(var(--muted-foreground));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalio-cloud/ui",
3
- "version": "0.17.0",
3
+ "version": "0.18.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,
@@ -46,7 +46,8 @@
46
46
  },
47
47
  "peerDependencies": {
48
48
  "react": ">=18",
49
- "tailwindcss": ">=3.3 <4"
49
+ "tailwindcss": ">=3.3 <4",
50
+ "react-dom": ">=18"
50
51
  },
51
52
  "peerDependenciesMeta": {
52
53
  "tailwindcss": {
@@ -54,6 +55,9 @@
54
55
  },
55
56
  "react": {
56
57
  "optional": true
58
+ },
59
+ "react-dom": {
60
+ "optional": true
57
61
  }
58
62
  },
59
63
  "scripts": {
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ // Page actions in the sidebar (UISPEC section 2, "Seitenaktionen", from 0.18.0). Buttons such as "Neu ...",
3
+ // "Import" or "Zurück" sit right below Übersicht, close to the navigation, instead of far right in the page
4
+ // header. The slot takes no room when a page has no actions; with actions it pushes the menu down.
5
+ // Phones have no sidebar: there the actions stay in the page header.
6
+ //
7
+ // The app turns the slot on with <Sidebar pageActions />; a page wraps its actions in <PageActions>.
8
+ // No provider: the slot registers itself in a small store, so pages need no wiring.
9
+ const React = require("react");
10
+ const h = React.createElement;
11
+
12
+ const DESKTOP_QUERY = "(min-width: 768px)";
13
+
14
+ let slotEl = null;
15
+ const listeners = new Set();
16
+
17
+ function setActionSlot(el) {
18
+ if (slotEl === el) return;
19
+ slotEl = el;
20
+ listeners.forEach((fn) => fn());
21
+ }
22
+
23
+ function subscribe(fn) {
24
+ listeners.add(fn);
25
+ return () => listeners.delete(fn);
26
+ }
27
+
28
+ const getSlot = () => slotEl;
29
+ const getServerSlot = () => null;
30
+
31
+ function useActionSlot() {
32
+ return React.useSyncExternalStore(subscribe, getSlot, getServerSlot);
33
+ }
34
+
35
+ function matchesDesktop() {
36
+ return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(DESKTOP_QUERY).matches;
37
+ }
38
+
39
+ // True from 768px, the width from which the sidebar is shown (nav.css).
40
+ function useDesktop() {
41
+ const [desktop, setDesktop] = React.useState(matchesDesktop);
42
+ React.useEffect(() => {
43
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return undefined;
44
+ const mq = window.matchMedia(DESKTOP_QUERY);
45
+ const on = () => setDesktop(mq.matches);
46
+ on();
47
+ if (mq.addEventListener) {
48
+ mq.addEventListener("change", on);
49
+ return () => mq.removeEventListener("change", on);
50
+ }
51
+ mq.addListener(on);
52
+ return () => mq.removeListener(on);
53
+ }, []);
54
+ return desktop;
55
+ }
56
+
57
+ // The empty slot in the sidebar. Sidebar renders it when pageActions is set; apps with their own sidebar
58
+ // can place it themselves.
59
+ function ActionSlot({ testId }) {
60
+ return h("div", { ref: setActionSlot, className: "scalio-action-slot", "data-testid": testId || "action-slot" });
61
+ }
62
+
63
+ // Wrap a page's actions: in the sidebar slot from 768px, otherwise where it stands (page header).
64
+ // Only for actions that start something or lead away (Neu, Import, Scannen, Zurück); filters, search and
65
+ // Speichern stay on the page.
66
+ function PageActions({ children, testId }) {
67
+ const slot = useActionSlot();
68
+ const desktop = useDesktop();
69
+ if (children == null || children === false) return null;
70
+ if (slot && desktop) {
71
+ const ReactDOM = require("react-dom");
72
+ return ReactDOM.createPortal(h("div", { className: "scalio-side-actions", "data-testid": testId || "page-actions" }, children), slot);
73
+ }
74
+ return h(React.Fragment, null, children);
75
+ }
76
+
77
+ module.exports = { PageActions, ActionSlot, useActionSlot, useDesktop, setActionSlot };
package/react/index.js CHANGED
@@ -378,6 +378,6 @@ module.exports = {
378
378
  TEST_IDS,
379
379
  initials,
380
380
  };
381
- Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./security"), require("./license"), require("./palette"), require("./unify"), require("./profile"));
381
+ Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./security"), require("./license"), require("./palette"), require("./unify"), require("./profile"), require("./actions"));
382
382
  // After the exports above: auth.js uses LanguageToggle and ThemeToggle from this module.
383
383
  Object.assign(module.exports, require("./auth"));
package/react/nav.js CHANGED
@@ -6,6 +6,7 @@ const React = require("react");
6
6
  const h = React.createElement;
7
7
  const Icons = require("./icons");
8
8
  const { labelsFor } = require("./labels");
9
+ const { ActionSlot } = require("./actions");
9
10
 
10
11
  function cx() {
11
12
  return Array.prototype.slice.call(arguments).filter(Boolean).join(" ");
@@ -101,14 +102,28 @@ function SearchButton({ search, lang, labels, testIdPrefix, onNavigate }) {
101
102
 
102
103
  // Sidebar. variant "desktop" shows from 768px, "drawer" is the same content inside NavDrawer.
103
104
  // link: the router's NavLink (gets to, end, className(fn), onClick); without it plain <a> + activePath.
105
+ // pageActions: a slot for the page's actions (react/actions.js) right below Übersicht, desktop only.
106
+ // Übersicht (first item of the first section when it has no heading) then stays on top and does not scroll.
104
107
  function Sidebar(props) {
105
108
  const {
106
109
  nav, link, activePath, top, search, primaryAction, footer, version, commit, lang, labels,
107
- variant, onNavigate, testId, testIdPrefix,
110
+ variant, onNavigate, testId, testIdPrefix, pageActions,
108
111
  } = props;
109
112
  const prefix = testIdPrefix != null ? testIdPrefix : variant === "drawer" ? "m-" : "";
110
113
  const L = labelsFor(lang, labels);
111
114
  const sections = visibleSections(nav);
115
+ const slot = !!pageActions && variant !== "drawer";
116
+ // Only Übersicht (first item of a first section without heading) is pinned; the rest of that section scrolls.
117
+ const first = sections[0];
118
+ const pinned = slot && first && !first.label ? Object.assign({}, first, { items: first.items.slice(0, 1) }) : null;
119
+ const rest = !pinned ? sections : first.items.length > 1 ? [Object.assign({}, first, { items: first.items.slice(1) })].concat(sections.slice(1)) : sections.slice(1);
120
+ const section = (sec, i) =>
121
+ h(
122
+ "div",
123
+ { key: sec.id || i, className: "scalio-nav-section", "data-section": sec.id },
124
+ sec.label ? h("div", { className: cx("scalio-nav-heading", sec.tone && `is-${sec.tone}`) }, sec.label) : null,
125
+ sec.items.map((item) => h(NavItem, { key: item.to, item, link, activePath, onNavigate, testIdPrefix: prefix }))
126
+ );
112
127
  return h(
113
128
  "nav",
114
129
  {
@@ -119,18 +134,9 @@ function Sidebar(props) {
119
134
  top ? h("div", { className: "scalio-sidebar-top" }, top) : null,
120
135
  h(SearchButton, { search, lang, labels, testIdPrefix: prefix, onNavigate }),
121
136
  h(PrimaryAction, { action: primaryAction, link, onNavigate, testIdPrefix: prefix }),
122
- h(
123
- "div",
124
- { className: "scalio-sidebar-scroll" },
125
- sections.map((sec, i) =>
126
- h(
127
- "div",
128
- { key: sec.id || i, className: "scalio-nav-section", "data-section": sec.id },
129
- sec.label ? h("div", { className: cx("scalio-nav-heading", sec.tone && `is-${sec.tone}`) }, sec.label) : null,
130
- sec.items.map((item) => h(NavItem, { key: item.to, item, link, activePath, onNavigate, testIdPrefix: prefix }))
131
- )
132
- )
133
- ),
137
+ pinned ? h("div", { className: "scalio-sidebar-pinned" }, section(pinned, 0)) : null,
138
+ slot ? h(ActionSlot, { testId: `${prefix}action-slot` }) : null,
139
+ h("div", { className: cx("scalio-sidebar-scroll", slot && "has-action-slot") }, rest.map(section)),
134
140
  footer || version
135
141
  ? h(
136
142
  "div",
package/shell.css CHANGED
@@ -102,6 +102,7 @@ a.scalio-btn { text-decoration: none; }
102
102
  @media (min-width: 640px) { .scalio-page-title { font-size: 1.875rem; } }
103
103
  .scalio-page-subtitle { margin: 0.25rem 0 0; font-size: 0.875rem; color: hsl(var(--muted-foreground)); }
104
104
  .scalio-page-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
105
+ .scalio-page-actions:empty { display: none; }
105
106
 
106
107
  .scalio-version {
107
108
  padding: 0.5rem 0.75rem; font-family: 'JetBrains Mono', monospace; font-size: 10px;