@scalio-cloud/ui 0.32.0 → 0.33.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/README.md CHANGED
@@ -151,6 +151,33 @@ Only actions that start something or lead away go there (Neu …, Import, Scanne
151
151
  Labels without "Neu"/"New": the + icon says it ("+ Kontakt", not "+ Neuer Kontakt").
152
152
  Filters, search, tabs and Speichern stay on the page. Apps with their own sidebar place `<ActionSlot />` themselves.
153
153
 
154
+ ## Page templates (React, from 0.33.0)
155
+
156
+ UISPEC section 2 «Page templates» (`nav/N10` B): every page is an overview, a list, a detail or the settings
157
+ (`SettingsLayout`, below). The templates draw the header, put `actions` into `<PageActions>` (sidebar slot from
158
+ 768px), and show `Loading` or `EmptyState` where the content would be.
159
+
160
+ ```jsx
161
+ <OverviewPage title="Übersicht" kpis={[{ label: "Tenants", value: 3 }, { label: "Abweichungen", value: 2, tone: "error" }]}>
162
+ <Panel title="Letzte Scans" showAll={<Link to="/scans">Alle anzeigen</Link>}>…</Panel>
163
+ </OverviewPage>
164
+ <ListPage title="Kontakte" actions={<Button><Plus /> Kontakt</Button>} loading={loading} empty={!rows.length}
165
+ emptyText="Noch keine Kontakte." emptyAction={<Button>Ersten Kontakt erfassen</Button>}
166
+ toolbar={<Toolbar search={q} onSearch={setQ} filters={[{ id: "open", label: "Offen", active, count: 4 }]} onFilter={toggle} menu={<ExportMenu />} />}>
167
+ <Table>…</Table>
168
+ </ListPage>
169
+ <DetailPage title={t.name} status={{ tone: "ok", label: "Verbunden" }} back={<Link to="/tenants">Zurück</Link>}
170
+ pageActions={<Button><Upload /> Hochladen</Button>} actions={<Button>Freigeben</Button>}
171
+ tabs={[{ id: "info", label: "Info" }, { id: "log", label: "Protokoll" }]} tab={tab} onTab={setTab}
172
+ facts={[{ label: "Tenant-ID", value: t.id }]}>…</DetailPage>
173
+ ```
174
+
175
+ On a detail page `back` and `pageActions` (start something, lead away) go to the sidebar slot; `actions` are the
176
+ record's own actions (Freigeben, Stornieren) and stay in the header. `badge` takes an app's own status badge.
177
+
178
+ Panels sit in a grid with one column, two from 1024px and three from 1536px; key facts of a detail page move to
179
+ a right column from 1024px and leave no gap when there are none.
180
+
154
181
  ## Settings page (React)
155
182
 
156
183
  Every area of the settings has its own address (`/settings/<area>`); the app's router picks the area, the
package/nav.css CHANGED
@@ -777,3 +777,39 @@
777
777
  .scalio-mail-preview-subject { padding: 0.5rem 0.75rem; border-bottom: 1px solid hsl(var(--border)); font-size: 0.875rem; }
778
778
  .scalio-mail-preview-subject span { font-weight: 600; }
779
779
  .scalio-mail-preview iframe { flex: 1; width: 100%; min-height: 16rem; border: 0; background: #fff; }
780
+ /* Page templates (pages.js, 0.33.0): Overview, List, Detail. Full width (ADR 0024), grids add columns. */
781
+ .scalio-template { display: flex; flex-direction: column; gap: 1.5rem; }
782
+ .scalio-template > .scalio-page-header { margin-bottom: 0; }
783
+ .scalio-kpi-row { display: grid; gap: 0.75rem; grid-template-columns: repeat(2, minmax(0, 1fr)); }
784
+ @media (min-width: 768px) { .scalio-kpi-row { gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); } }
785
+ .scalio-panels { display: grid; gap: 1rem; grid-template-columns: 1fr; }
786
+ @media (min-width: 1024px) { .scalio-panels { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
787
+ @media (min-width: 1536px) { .scalio-panels { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
788
+ .scalio-panel { display: flex; flex-direction: column; min-width: 0; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--card)); color: hsl(var(--card-foreground)); }
789
+ .scalio-panel-head { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.875rem 1.25rem; border-bottom: 1px solid hsl(var(--border)); }
790
+ .scalio-panel-title { margin: 0; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: hsl(var(--muted-foreground)); }
791
+ .scalio-panel-more, .scalio-panel-more a, .scalio-panel-more button { border: 0; background: none; padding: 0; font: inherit; font-size: 0.8125rem; font-weight: 600; color: hsl(var(--primary)); text-decoration: none; cursor: pointer; }
792
+ .scalio-panel-more:hover, .scalio-panel-more a:hover, .scalio-panel-more button:hover { text-decoration: underline; }
793
+ .scalio-panel-body { padding: 1rem 1.25rem; min-width: 0; }
794
+ .scalio-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; }
795
+ .scalio-toolbar-search { width: 100%; max-width: 20rem; margin: 0; }
796
+ .scalio-chips { display: flex; flex-wrap: wrap; gap: 0.375rem; }
797
+ .scalio-chip {
798
+ display: inline-flex; align-items: center; gap: 0.375rem; height: 1.875rem; padding: 0 0.75rem; border: 1px solid hsl(var(--border));
799
+ border-radius: 999px; background: hsl(var(--background)); color: hsl(var(--muted-foreground)); font: inherit; font-size: 0.8125rem; cursor: pointer;
800
+ }
801
+ .scalio-chip:hover { color: hsl(var(--foreground)); border-color: hsl(var(--foreground) / 0.4); }
802
+ .scalio-chip.is-active { border-color: hsl(var(--primary)); background: hsl(var(--primary) / 0.12); color: hsl(var(--foreground)); font-weight: 600; }
803
+ .scalio-chip:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; }
804
+ .scalio-chip-count { font-family: 'JetBrains Mono', monospace; font-size: 11px; }
805
+ .scalio-toolbar-extra { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
806
+ .scalio-toolbar-menu { margin-left: auto; }
807
+ .scalio-list-body { min-width: 0; overflow-x: auto; }
808
+ .scalio-detail-body { display: grid; gap: 1.5rem; grid-template-columns: minmax(0, 1fr); }
809
+ @media (min-width: 1024px) { .scalio-detail-body.has-facts { grid-template-columns: minmax(0, 1fr) 18rem; } }
810
+ .scalio-detail-main { min-width: 0; }
811
+ .scalio-facts { align-self: start; padding: 1rem 1.25rem; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--card)); }
812
+ .scalio-facts dl { display: grid; grid-template-columns: auto 1fr; gap: 0.5rem 1rem; margin: 0.75rem 0 0; font-size: 0.875rem; }
813
+ .scalio-facts dt { color: hsl(var(--muted-foreground)); }
814
+ .scalio-facts dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
815
+ .scalio-page-title .scalio-badge { vertical-align: middle; margin-left: 0.25rem; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalio-cloud/ui",
3
- "version": "0.32.0",
3
+ "version": "0.33.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
@@ -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("./beacon"), require("./security"), require("./license"), require("./palette"), require("./unify"), require("./profile"), require("./actions"), require("./selftest"), require("./users"), require("./roles"), require("./wizard"), require("./maintenance"), require("./audit"), require("./mail"), require("./saas_updates"));
381
+ Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./beacon"), require("./security"), require("./license"), require("./palette"), require("./unify"), require("./profile"), require("./actions"), require("./selftest"), require("./users"), require("./roles"), require("./wizard"), require("./maintenance"), require("./audit"), require("./mail"), require("./saas_updates"), require("./pages"));
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/labels.js CHANGED
@@ -385,6 +385,7 @@ const LABELS = {
385
385
  // States
386
386
  loading: "Wird geladen …",
387
387
  empty: "Noch keine Einträge.",
388
+ pages: { showAll: "Alle anzeigen", filter: "Filter", sections: "Bereiche" },
388
389
  sections: {
389
390
  account: "Konto",
390
391
  appearance: "Darstellung",
@@ -795,6 +796,7 @@ const LABELS = {
795
796
  },
796
797
  loading: "Loading …",
797
798
  empty: "No entries yet.",
799
+ pages: { showAll: "Show all", filter: "Filter", sections: "Sections" },
798
800
  sections: {
799
801
  account: "Account",
800
802
  appearance: "Appearance",
package/react/pages.js ADDED
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ // Page templates (UISPEC section 2 «Page templates», nav/N10 B, from 0.33.0): Overview, List and Detail. The
3
+ // Settings template is SettingsLayout (settings.js). A page passes its data; header, page actions in the sidebar
4
+ // slot, grids, toolbar, loading and empty state look the same in every app.
5
+ const React = require("react");
6
+ const h = React.createElement;
7
+ const { labelsFor } = require("./labels");
8
+ const { KpiTile, EmptyState, Loading, StatusBadge } = require("./blocks");
9
+ const { PageActions } = require("./actions");
10
+
11
+ function cx() {
12
+ return Array.prototype.slice.call(arguments).filter(Boolean).join(" ");
13
+ }
14
+
15
+ // Same layout as PageHeader in index.js (not required from there: index.js requires this module).
16
+ function Header({ title, subtitle, eyebrow, back, actions, extra }) {
17
+ const right = actions || extra ? h(React.Fragment, null, extra || null, actions ? h(PageActions, null, actions) : null) : null;
18
+ return h(
19
+ "div",
20
+ { className: "scalio-page-header", "data-testid": "page-header" },
21
+ h(
22
+ "div",
23
+ { className: "scalio-page-header-text" },
24
+ back ? h("div", { className: "scalio-page-back" }, back) : null,
25
+ eyebrow ? h("div", { className: "scalio-eyebrow" }, eyebrow) : null,
26
+ h("h1", { className: "scalio-page-title" }, title),
27
+ subtitle ? h("p", { className: "scalio-page-subtitle" }, subtitle) : null
28
+ ),
29
+ right ? h("div", { className: "scalio-page-actions" }, right) : null
30
+ );
31
+ }
32
+
33
+ // Box on an overview page: title, optional «Alle anzeigen» (link element or onShowAll), content or empty text.
34
+ function Panel({ title, showAll, onShowAll, empty, emptyText, children, lang, labels, testId, className }) {
35
+ const L = labelsFor(lang, labels);
36
+ let more = null;
37
+ if (showAll) more = h("span", { className: "scalio-panel-more" }, showAll);
38
+ else if (onShowAll) more = h("button", { type: "button", className: "scalio-panel-more", onClick: onShowAll }, L.pages.showAll);
39
+ return h(
40
+ "section",
41
+ { className: cx("scalio-panel", className), "data-testid": testId },
42
+ title || more
43
+ ? h("div", { className: "scalio-panel-head" }, title ? h("h2", { className: "scalio-panel-title" }, title) : h("span"), more)
44
+ : null,
45
+ h("div", { className: "scalio-panel-body" }, empty ? h(EmptyState, { text: emptyText, lang, labels }) : children)
46
+ );
47
+ }
48
+
49
+ // Overview: header, row of KPI tiles (tone "error" = needs attention), panels in a grid that adds columns.
50
+ function OverviewPage({ title, subtitle, actions, kpis, loading, children, lang, labels, testId }) {
51
+ const tiles = (kpis || []).filter(Boolean);
52
+ return h(
53
+ "div",
54
+ { className: "scalio-template is-overview", "data-testid": testId || "overview-page" },
55
+ h(Header, { title, subtitle, actions }),
56
+ loading ? h(Loading, { lang, labels }) : null,
57
+ tiles.length
58
+ ? h("div", { className: "scalio-kpi-row", "data-testid": "kpi-row" }, tiles.map((k, i) => h(KpiTile, Object.assign({ key: k.key || k.testId || i }, k))))
59
+ : null,
60
+ children ? h("div", { className: "scalio-panels" }, children) : null
61
+ );
62
+ }
63
+
64
+ // Toolbar of a list: search, filter chips, extra controls and a menu (import/export) on the right.
65
+ function Toolbar({ search, onSearch, searchLabel, filters, onFilter, menu, children, lang, labels, testId }) {
66
+ const L = labelsFor(lang, labels);
67
+ const chips = (filters || []).filter(Boolean);
68
+ return h(
69
+ "div",
70
+ { className: "scalio-toolbar", "data-testid": testId || "list-toolbar" },
71
+ onSearch
72
+ ? h("input", {
73
+ type: "search",
74
+ className: "scalio-input scalio-toolbar-search",
75
+ value: search || "",
76
+ placeholder: searchLabel || L.search,
77
+ "aria-label": searchLabel || L.search,
78
+ onChange: (e) => onSearch(e.target.value),
79
+ "data-testid": "list-search",
80
+ })
81
+ : null,
82
+ chips.length
83
+ ? h(
84
+ "div",
85
+ { className: "scalio-chips", role: "group", "aria-label": L.pages.filter },
86
+ chips.map((f) =>
87
+ h(
88
+ "button",
89
+ {
90
+ key: f.id,
91
+ type: "button",
92
+ className: cx("scalio-chip", f.active && "is-active"),
93
+ "aria-pressed": f.active ? "true" : "false",
94
+ onClick: () => onFilter && onFilter(f.id),
95
+ "data-testid": `filter-${f.id}`,
96
+ },
97
+ f.label,
98
+ f.count != null ? h("span", { className: "scalio-chip-count" }, f.count) : null
99
+ )
100
+ )
101
+ )
102
+ : null,
103
+ children ? h("div", { className: "scalio-toolbar-extra" }, children) : null,
104
+ menu ? h("div", { className: "scalio-toolbar-menu" }, menu) : null
105
+ );
106
+ }
107
+
108
+ // List: header with the main action (sidebar slot from 768px), toolbar, then the table or the empty state with
109
+ // the next step. `empty` true shows the empty state instead of children.
110
+ function ListPage({ title, subtitle, actions, toolbar, loading, empty, emptyText, emptyAction, children, lang, labels, testId }) {
111
+ let body = children;
112
+ if (loading) body = h(Loading, { lang, labels });
113
+ else if (empty) body = h(EmptyState, { text: emptyText, lang, labels }, emptyAction || null);
114
+ return h(
115
+ "div",
116
+ { className: "scalio-template is-list", "data-testid": testId || "list-page" },
117
+ h(Header, { title, subtitle, actions }),
118
+ toolbar || null,
119
+ h("div", { className: "scalio-list-body" }, body)
120
+ );
121
+ }
122
+
123
+ // Key facts on the right of a detail page: [{label, value}] as a definition list.
124
+ function KeyFacts({ items, title, testId }) {
125
+ const rows = (items || []).filter((i) => i && i.value != null && i.value !== "");
126
+ if (!rows.length) return null;
127
+ return h(
128
+ "aside",
129
+ { className: "scalio-facts", "data-testid": testId || "key-facts" },
130
+ title ? h("h2", { className: "scalio-panel-title" }, title) : null,
131
+ h(
132
+ "dl",
133
+ null,
134
+ rows.map((r, i) =>
135
+ h(React.Fragment, { key: r.key || i }, h("dt", null, r.label), h("dd", null, r.value))
136
+ )
137
+ )
138
+ );
139
+ }
140
+
141
+ // Detail: back link and actions that lead away (`pageActions`) in the sidebar slot from 768px, header with status
142
+ // badge and the record's own actions (`actions`: Freigeben, Stornieren; they stay on the page), tabs, key facts on
143
+ // the right. tabs: [{id, label}] with tab/onTab, or leave them out for one section. status: {tone, label}, or
144
+ // `badge` for an app's own badge element.
145
+ function DetailPage({ title, subtitle, back, pageActions, status, badge: ownBadge, actions, tabs, tab, onTab, facts, factsTitle, loading, children, lang, labels, testId }) {
146
+ const L = labelsFor(lang, labels);
147
+ const list = (tabs || []).filter(Boolean);
148
+ const badge = ownBadge || (status && status.label ? h(StatusBadge, { tone: status.tone, testId: "detail-status" }, status.label) : null);
149
+ const slot = back || pageActions
150
+ ? h(React.Fragment, null, back ? h("span", { className: "scalio-detail-back", "data-testid": "detail-back" }, back) : null, pageActions || null)
151
+ : null;
152
+ return h(
153
+ "div",
154
+ { className: "scalio-template is-detail", "data-testid": testId || "detail-page" },
155
+ h(Header, {
156
+ title: badge ? h(React.Fragment, null, title, " ", badge) : title,
157
+ subtitle,
158
+ actions: slot,
159
+ extra: actions || null,
160
+ }),
161
+ list.length
162
+ ? h(
163
+ "div",
164
+ { className: "scalio-tabs", role: "tablist", "aria-label": L.pages.sections },
165
+ list.map((t) =>
166
+ h(
167
+ "button",
168
+ {
169
+ key: t.id,
170
+ type: "button",
171
+ role: "tab",
172
+ className: cx("scalio-tab", t.id === tab && "is-active"),
173
+ "aria-selected": t.id === tab ? "true" : "false",
174
+ onClick: () => onTab && onTab(t.id),
175
+ "data-testid": t.testId || `tab-${t.id}`,
176
+ },
177
+ t.label
178
+ )
179
+ )
180
+ )
181
+ : null,
182
+ h(
183
+ "div",
184
+ { className: cx("scalio-detail-body", facts && facts.length && "has-facts") },
185
+ h("div", { className: "scalio-detail-main" }, loading ? h(Loading, { lang, labels }) : children),
186
+ h(KeyFacts, { items: facts, title: factsTitle })
187
+ )
188
+ );
189
+ }
190
+
191
+ module.exports = { OverviewPage, Panel, ListPage, Toolbar, DetailPage, KeyFacts };