@scalio-cloud/ui 0.8.1 → 0.10.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 +14 -0
- package/README.md +21 -1
- package/nav.css +46 -0
- package/package.json +1 -1
- package/react/icons.js +6 -1
- package/react/index.js +1 -1
- package/react/labels.js +14 -0
- package/react/license.js +40 -15
- package/react/palette.js +346 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0
|
|
4
|
+
- `LicensePanel` takes more from an app's own `map`: a `hint` per usage bar (e.g. base + add-on seats), add-ons with
|
|
5
|
+
`badges`, `detail`, `tone` (`info`/`warn`) and `removable: false`, `addonErrors` (invalid add-ons, removable),
|
|
6
|
+
`addonsHidden`, `addonsReadOnly` and `keyReadOnly` (a note instead of the form) and a `copied` text per row.
|
|
7
|
+
Lets Nexus keep its trials, demo mode and badges on the shared panel. **No visible change** for apps that use
|
|
8
|
+
`mapScalioLicense`.
|
|
9
|
+
|
|
10
|
+
## 0.9.0
|
|
11
|
+
- New `CommandPalette` (⌘K / Strg+K): finds every page of the sidebar data (`nav`) plus extra `pages`, remembers the
|
|
12
|
+
last visited pages, and takes app commands as `groups` (Nexus: AI and guarded actions). Arrow keys, Enter, Esc,
|
|
13
|
+
no dialog library. Helpers `openCommandPalette()` (for the sidebar search button), `paletteShortcut(lang)`,
|
|
14
|
+
`paletteGroups`, `palettePages`. `LABELS` get the palette texts, `Icons` get History and CornerDownLeft.
|
|
15
|
+
**No visible change** until an app uses it; Nexus keeps its test ids (`command-palette-input`, `cmd-nav-*`).
|
|
16
|
+
|
|
3
17
|
## 0.8.1
|
|
4
18
|
- `SupportConnection` fills in the portal address of the instance's stage (`default_portal` from scalio-connect
|
|
5
19
|
0.5.0: dev -> portaldev, test -> portaltest, others -> portal.scaliocloud.com). **Visible change:** the address
|
package/README.md
CHANGED
|
@@ -214,7 +214,27 @@ const license = licenseApi(api);
|
|
|
214
214
|
`securityApi` reads snake_case and camelCase answers. `notify(text, tone)` gets `"success"` or `"error"`; without it
|
|
215
215
|
the blocks show the message themselves. `confirm` is the app's dialog (`{title, action, danger}` -> Promise<boolean>),
|
|
216
216
|
`window.confirm` otherwise. `formatError(err)` lets the app phrase API errors (for example license limit codes). A license backend with other routes passes its own `client` and a `map(license, {L})`
|
|
217
|
-
that returns the view model described in `react/license.js`.
|
|
217
|
+
that returns the view model described in `react/license.js`. Nexus does this: its `map` adds trial badges, seat hints, invalid
|
|
218
|
+
add-ons and read-only notes (demo mode), and passes its trial notices as `statusExtra` and the reminder settings as `children`.
|
|
219
|
+
|
|
220
|
+
## Command palette (React)
|
|
221
|
+
|
|
222
|
+
Reads the same navigation data as the sidebar. Put it once into the layout; the sidebar search button opens it.
|
|
223
|
+
|
|
224
|
+
```jsx
|
|
225
|
+
import { CommandPalette, openCommandPalette, paletteShortcut } from "@scalio-cloud/ui/react";
|
|
226
|
+
|
|
227
|
+
<Sidebar nav={nav} search={{ onOpen: openCommandPalette, shortcut: paletteShortcut(lang) }} ... />
|
|
228
|
+
<CommandPalette nav={nav} activePath={location.pathname} onNavigate={navigate} lang={lang}
|
|
229
|
+
pages={[{ to: "/profile", label: "Profil" }]}
|
|
230
|
+
groups={(query) => [{ id: "ai", label: "KI", order: 10, items: [{ id: "ask", label: `Fragen: ${query}`, onSelect: ask, hidden: !query }] }]} />
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Pages: every visible sidebar item plus `pages` (each path once), searched by label, `keywords`, section and path.
|
|
234
|
+
Groups sort by `order` (default 60); recent pages (50) show only while the query is empty, pages come last (100).
|
|
235
|
+
An item has `label`, `icon`, `to` or `onSelect`, and optional `keywords`, `hint`, `tone` (`primary`, `danger`),
|
|
236
|
+
`testId` and `alwaysShow`. Without `open` the palette handles ⌘K / Ctrl+K and `openCommandPalette()` itself;
|
|
237
|
+
pass `open` + `onOpenChange` to control it. `recentKey` sets the localStorage key (`false`: no recents).
|
|
218
238
|
|
|
219
239
|
## Installing
|
|
220
240
|
|
package/nav.css
CHANGED
|
@@ -362,3 +362,49 @@
|
|
|
362
362
|
.scalio-license-form textarea { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; }
|
|
363
363
|
.scalio-license-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
|
364
364
|
.scalio-license-addon-error { margin-bottom: 0.5rem; font-size: 0.75rem; color: hsl(var(--destructive)); }
|
|
365
|
+
|
|
366
|
+
/* Command palette (⌘K) */
|
|
367
|
+
.scalio-palette-root { position: fixed; inset: 0; z-index: 60; display: flex; justify-content: center; align-items: flex-start; padding: 12vh 1rem 1rem; }
|
|
368
|
+
.scalio-palette-backdrop { position: fixed; inset: 0; background: rgb(9 9 11 / 0.45); }
|
|
369
|
+
.scalio-palette {
|
|
370
|
+
position: relative; width: min(40rem, 100%); max-height: min(32rem, 76vh); display: flex; flex-direction: column;
|
|
371
|
+
background: hsl(var(--background)); color: hsl(var(--foreground)); border: 1px solid hsl(var(--border)); border-radius: 2px;
|
|
372
|
+
box-shadow: 0 16px 48px rgb(0 0 0 / 0.25); overflow: hidden;
|
|
373
|
+
}
|
|
374
|
+
.scalio-palette-search { display: flex; align-items: center; gap: 0.5rem; padding: 0 0.75rem; border-bottom: 1px solid hsl(var(--border)); color: hsl(var(--muted-foreground)); }
|
|
375
|
+
.scalio-palette-input {
|
|
376
|
+
flex: 1; min-width: 0; height: 3rem; border: 0; outline: none; background: transparent; color: hsl(var(--foreground));
|
|
377
|
+
font: inherit; font-size: 0.9375rem;
|
|
378
|
+
}
|
|
379
|
+
.scalio-palette-input::placeholder { color: hsl(var(--muted-foreground)); }
|
|
380
|
+
.scalio-palette-list { flex: 1 1 auto; overflow-y: auto; padding: 0.25rem 0 0.5rem; overscroll-behavior: contain; }
|
|
381
|
+
.scalio-palette-heading {
|
|
382
|
+
padding: 0.625rem 0.875rem 0.25rem; font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 600;
|
|
383
|
+
letter-spacing: 0.14em; text-transform: uppercase; color: hsl(var(--muted-foreground));
|
|
384
|
+
}
|
|
385
|
+
.scalio-palette-item {
|
|
386
|
+
display: flex; align-items: center; gap: 0.625rem; margin: 0 0.375rem; padding: 0.5rem 0.5rem; border-radius: 2px;
|
|
387
|
+
font-size: 0.875rem; cursor: pointer; border-left: 2px solid transparent;
|
|
388
|
+
}
|
|
389
|
+
.scalio-palette-item.is-active { background: hsl(var(--muted)); border-left-color: hsl(var(--primary)); }
|
|
390
|
+
.scalio-palette-icon { width: 15px; height: 15px; flex-shrink: 0; color: hsl(var(--muted-foreground)); }
|
|
391
|
+
.scalio-palette-item.is-primary .scalio-palette-icon { color: hsl(var(--primary)); }
|
|
392
|
+
.scalio-palette-item.is-danger .scalio-palette-icon { color: hsl(var(--destructive)); }
|
|
393
|
+
.scalio-palette-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
394
|
+
.scalio-palette-hint { flex-shrink: 0; font-size: 0.75rem; color: hsl(var(--muted-foreground)); }
|
|
395
|
+
.scalio-palette-enter { width: 14px; height: 14px; flex-shrink: 0; color: hsl(var(--muted-foreground)); }
|
|
396
|
+
.scalio-palette-empty { padding: 2rem 1rem; text-align: center; font-size: 0.875rem; color: hsl(var(--muted-foreground)); }
|
|
397
|
+
.scalio-palette-foot { padding: 0.5rem 0.875rem; border-top: 1px solid hsl(var(--border)); font-size: 0.6875rem; color: hsl(var(--muted-foreground)); }
|
|
398
|
+
@media (max-width: 640px) {
|
|
399
|
+
.scalio-palette-root { padding: 0.5rem; padding-top: max(0.5rem, env(safe-area-inset-top, 0px)); }
|
|
400
|
+
.scalio-palette { max-height: 85vh; }
|
|
401
|
+
.scalio-palette-foot, .scalio-palette-enter { display: none; }
|
|
402
|
+
}
|
|
403
|
+
.scalio-license-bar-hint { margin: 0.25rem 0 0; font-size: 0.6875rem; color: hsl(var(--primary)); }
|
|
404
|
+
.scalio-license-addon-name { display: flex; flex-wrap: wrap; align-items: center; gap: 0.375rem; }
|
|
405
|
+
.scalio-license-addon-name .scalio-badge { font-size: 10px; }
|
|
406
|
+
.scalio-license-addon.tone-info { border-color: hsl(var(--primary) / 0.35); background: hsl(var(--primary) / 0.05); }
|
|
407
|
+
.scalio-license-addon.tone-warn { border-color: #fcd34d; background: rgb(254 243 199 / 0.5); }
|
|
408
|
+
.dark .scalio-license-addon.tone-warn { border-color: rgb(251 191 36 / 0.4); background: rgb(251 191 36 / 0.08); }
|
|
409
|
+
.scalio-license-addon-error { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
|
|
410
|
+
.scalio-license-link { padding: 0; border: 0; background: none; color: inherit; font: inherit; text-decoration: underline; cursor: pointer; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scalio-cloud/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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/icons.js
CHANGED
|
@@ -93,6 +93,11 @@ const Alert = icon([
|
|
|
93
93
|
["path", { d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" }],
|
|
94
94
|
["path", { d: "M12 9v4M12 17h.01" }],
|
|
95
95
|
]);
|
|
96
|
+
const History = icon([
|
|
97
|
+
["path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }],
|
|
98
|
+
["path", { d: "M3 3v5h5M12 7v5l4 2" }],
|
|
99
|
+
]);
|
|
100
|
+
const CornerDownLeft = icon([["path", { d: "M20 4v7a4 4 0 0 1-4 4H4" }], ["path", { d: "m9 10-5 5 5 5" }]]);
|
|
96
101
|
|
|
97
102
|
module.exports = { Sun, Moon, Monitor, LifeBuoy, LogOut, Menu, Check, X, Search, ChevronDown, Fingerprint, FileStack, ShieldCheck, Hammer, Activity,
|
|
98
|
-
Smartphone, KeyRound, Trash, Copy, Puzzle, Plus, Alert };
|
|
103
|
+
Smartphone, KeyRound, Trash, Copy, Puzzle, Plus, Alert, History, CornerDownLeft };
|
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, require("./settings"), require("./support"), require("./security"), require("./license"));
|
|
336
|
+
Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./security"), require("./license"), require("./palette"));
|
|
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
|
@@ -38,6 +38,13 @@ const LABELS = {
|
|
|
38
38
|
search: "Suchen",
|
|
39
39
|
navigation: "Navigation",
|
|
40
40
|
closeMenu: "Menü schliessen",
|
|
41
|
+
// Command palette (⌘K)
|
|
42
|
+
paletteTitle: "Suche",
|
|
43
|
+
palettePlaceholder: "Seite oder Befehl suchen …",
|
|
44
|
+
palettePages: "Seiten",
|
|
45
|
+
paletteRecent: "Zuletzt besucht",
|
|
46
|
+
paletteEmpty: "Nichts gefunden",
|
|
47
|
+
paletteHint: "Pfeiltasten zum Wählen, Enter zum Öffnen, Esc zum Schliessen",
|
|
41
48
|
tenant: "Tenant",
|
|
42
49
|
customer: "Kunde",
|
|
43
50
|
// Settings page (fixed groups, same order in every app)
|
|
@@ -223,6 +230,13 @@ const LABELS = {
|
|
|
223
230
|
search: "Search",
|
|
224
231
|
navigation: "Navigation",
|
|
225
232
|
closeMenu: "Close menu",
|
|
233
|
+
// Command palette (⌘K)
|
|
234
|
+
paletteTitle: "Search",
|
|
235
|
+
palettePlaceholder: "Find a page or command …",
|
|
236
|
+
palettePages: "Pages",
|
|
237
|
+
paletteRecent: "Recently visited",
|
|
238
|
+
paletteEmpty: "Nothing found",
|
|
239
|
+
paletteHint: "Arrow keys to choose, Enter to open, Esc to close",
|
|
226
240
|
tenant: "Tenant",
|
|
227
241
|
customer: "Customer",
|
|
228
242
|
settingsGroups: { general: "General", connections: "Connections", notifications: "Notifications", security: "Security", system: "System" },
|
package/react/license.js
CHANGED
|
@@ -36,8 +36,11 @@ function licenseApi(api) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// View model:
|
|
39
|
-
// { edition, licensed, customer, managed, messages[], rows[{label, value, tone, mono, copy, testId}],
|
|
40
|
-
// features[{key, label}] | null, usage[{key, label, used, max
|
|
39
|
+
// { edition, licensed, customer, managed, messages[], rows[{label, value, tone, mono, copy, copied, testId}],
|
|
40
|
+
// features[{key, label}] | null, usage[{key, label, used, max, hint}],
|
|
41
|
+
// addons[{id, name, seats, exp, detail, badges[{label, tone}], tone, removable}], addonErrors[{key, error, addon}], keyPresent,
|
|
42
|
+
// addonsHidden, addonsReadOnly (note instead of the add-on form), keyReadOnly (note instead of the key form) }
|
|
43
|
+
// Optional fields let an app with its own license model (Nexus: trials, demo) show more without its own panel.
|
|
41
44
|
function mapScalioLicense(lic, opts) {
|
|
42
45
|
const o = opts || {};
|
|
43
46
|
const L = o.L || labelsFor(o.lang).license;
|
|
@@ -94,7 +97,8 @@ function UsageBar({ item }) {
|
|
|
94
97
|
h("span", { className: "scalio-license-bar-label" }, item.label),
|
|
95
98
|
h("span", { className: cx("scalio-license-bar-value", full && "is-error") }, used === null ? "-" : used, " / ", unlimited ? "∞" : item.max)
|
|
96
99
|
),
|
|
97
|
-
h("div", { className: "scalio-license-bar" }, h("div", { className: cx(full && "is-full", unlimited && "is-unlimited"), style: { width: `${pct}%` } }))
|
|
100
|
+
h("div", { className: "scalio-license-bar" }, h("div", { className: cx(full && "is-full", unlimited && "is-unlimited"), style: { width: `${pct}%` } })),
|
|
101
|
+
item.hint ? h("p", { className: "scalio-license-bar-hint", "data-testid": `license-limit-${item.key}-hint` }, item.hint) : null
|
|
98
102
|
);
|
|
99
103
|
}
|
|
100
104
|
|
|
@@ -155,10 +159,10 @@ function LicensePanel(props) {
|
|
|
155
159
|
const addAddon = async () => {
|
|
156
160
|
if (await run(() => client.addAddon(addon.trim()), L.addonActivated)) setAddon("");
|
|
157
161
|
};
|
|
158
|
-
const copy = async (value) => {
|
|
162
|
+
const copy = async (value, copied) => {
|
|
159
163
|
try {
|
|
160
164
|
await navigator.clipboard.writeText(value);
|
|
161
|
-
say(L.idCopied);
|
|
165
|
+
say(copied || L.idCopied);
|
|
162
166
|
} catch (e) {
|
|
163
167
|
say(L.copyFailed, "error");
|
|
164
168
|
}
|
|
@@ -170,7 +174,7 @@ function LicensePanel(props) {
|
|
|
170
174
|
{ key: r.label + i },
|
|
171
175
|
h("dt", null, r.label),
|
|
172
176
|
r.copy
|
|
173
|
-
? h("dd", null, h("button", { type: "button", className: "scalio-license-copy", title: L.copy, onClick: () => copy(r.value), "data-testid": r.testId },
|
|
177
|
+
? h("dd", null, h("button", { type: "button", className: "scalio-license-copy", title: L.copy, onClick: () => copy(r.value, r.copied), "data-testid": r.testId },
|
|
174
178
|
r.value, h(Icons.Copy, { className: "scalio-icon", width: 12, height: 12 })))
|
|
175
179
|
: h("dd", { className: cx(r.mono && "is-mono"), "data-testid": r.testId }, r.tone ? h("span", { className: `is-${r.tone}` }, r.value) : r.value)
|
|
176
180
|
);
|
|
@@ -219,7 +223,7 @@ function LicensePanel(props) {
|
|
|
219
223
|
? h("div", { className: "scalio-license-usage" }, h("div", { className: "scalio-license-sub" }, L.usage), m.usage.map((u) => h(UsageBar, { key: u.key, item: u })))
|
|
220
224
|
: null
|
|
221
225
|
),
|
|
222
|
-
h(
|
|
226
|
+
m.addonsHidden ? null : h(
|
|
223
227
|
"div",
|
|
224
228
|
{ className: "scalio-license-part", "data-testid": "license-addons" },
|
|
225
229
|
h("div", { className: "scalio-license-sub" }, h(Icons.Puzzle, { className: "scalio-icon", width: 14, height: 14 }), L.addons),
|
|
@@ -230,16 +234,23 @@ function LicensePanel(props) {
|
|
|
230
234
|
m.addons.map((a) =>
|
|
231
235
|
h(
|
|
232
236
|
"div",
|
|
233
|
-
{ key: a.id, className: "scalio-license-addon", "data-testid": `addon-card-${a.id}` },
|
|
237
|
+
{ key: a.id, className: cx("scalio-license-addon", a.tone && `tone-${a.tone}`), "data-testid": `addon-card-${a.id}` },
|
|
234
238
|
h(
|
|
235
239
|
"div",
|
|
236
240
|
{ style: { minWidth: 0 } },
|
|
237
|
-
h(
|
|
241
|
+
h(
|
|
242
|
+
"div",
|
|
243
|
+
{ className: "scalio-license-addon-name" },
|
|
244
|
+
a.name,
|
|
245
|
+
(a.badges || []).map((b) =>
|
|
246
|
+
h("span", { key: b.label, className: cx("scalio-badge", b.tone && `is-${b.tone}`), "data-testid": b.testId }, b.label))
|
|
247
|
+
),
|
|
238
248
|
h("p", { className: "scalio-license-addon-detail" },
|
|
239
|
-
a.
|
|
240
|
-
|
|
249
|
+
a.detail !== undefined
|
|
250
|
+
? a.detail
|
|
251
|
+
: [a.seats ? `+${a.seats}` : h("span", { key: "i", className: "is-ok" }, L.included), a.exp ? ` · ${L.maintenanceUntil} ${a.exp}` : ""])
|
|
241
252
|
),
|
|
242
|
-
m.managed
|
|
253
|
+
m.managed || m.addonsReadOnly || a.removable === false
|
|
243
254
|
? null
|
|
244
255
|
: h("button", { type: "button", className: "scalio-btn is-ghost is-danger", disabled: busy, title: L.remove, "aria-label": L.remove,
|
|
245
256
|
"data-testid": `addon-remove-${a.id}`, onClick: () => run(() => client.removeAddon(a), L.addonRemoved) },
|
|
@@ -248,8 +259,20 @@ function LicensePanel(props) {
|
|
|
248
259
|
)
|
|
249
260
|
)
|
|
250
261
|
: h("p", { className: "scalio-license-empty" }, L.noAddons),
|
|
251
|
-
m.addonErrors.map((e) =>
|
|
252
|
-
|
|
262
|
+
m.addonErrors.map((e) =>
|
|
263
|
+
h(
|
|
264
|
+
"div",
|
|
265
|
+
{ key: e.key, className: "scalio-license-addon-error", "data-testid": e.testId },
|
|
266
|
+
h("span", null, e.error),
|
|
267
|
+
e.addon && !m.managed && !m.addonsReadOnly
|
|
268
|
+
? h("button", { type: "button", className: "scalio-license-link", disabled: busy, "data-testid": `addon-remove-${e.addon.id}`,
|
|
269
|
+
onClick: () => run(() => client.removeAddon(e.addon), L.addonRemoved) }, L.remove)
|
|
270
|
+
: null
|
|
271
|
+
)
|
|
272
|
+
),
|
|
273
|
+
m.addonsReadOnly
|
|
274
|
+
? h("div", { className: "scalio-notice is-info", "data-testid": "license-addon-readonly" }, m.addonsReadOnly)
|
|
275
|
+
: m.managed
|
|
253
276
|
? null
|
|
254
277
|
: h(
|
|
255
278
|
"div",
|
|
@@ -261,7 +284,9 @@ function LicensePanel(props) {
|
|
|
261
284
|
h(Icons.Plus, { className: "scalio-icon" }), L.add))
|
|
262
285
|
)
|
|
263
286
|
),
|
|
264
|
-
m.managed
|
|
287
|
+
m.keyReadOnly && !m.managed
|
|
288
|
+
? h("div", { className: "scalio-license-part" }, h("div", { className: "scalio-notice is-info", "data-testid": "license-key-readonly" }, m.keyReadOnly))
|
|
289
|
+
: m.managed
|
|
265
290
|
? null
|
|
266
291
|
: h(
|
|
267
292
|
"div",
|
package/react/palette.js
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Command palette (⌘K / Ctrl+K). Reads the same navigation data as the sidebar, so every page the
|
|
3
|
+
// sidebar shows can also be found here. Apps add their own commands as groups (Nexus: AI, actions).
|
|
4
|
+
// No dialog library (N8): plain overlay, focus stays in the input, arrow keys move the selection.
|
|
5
|
+
const React = require("react");
|
|
6
|
+
const h = React.createElement;
|
|
7
|
+
const Icons = require("./icons");
|
|
8
|
+
const { labelsFor } = require("./labels");
|
|
9
|
+
const { visibleSections } = require("./nav");
|
|
10
|
+
|
|
11
|
+
const OPEN_EVENT = "open-command-palette";
|
|
12
|
+
|
|
13
|
+
function cx() {
|
|
14
|
+
return Array.prototype.slice.call(arguments).filter(Boolean).join(" ");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function slug(s) {
|
|
18
|
+
return String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Apple devices show ⌘K, everything else Ctrl+K (German keyboards: Strg+K). Both keys work everywhere.
|
|
22
|
+
function isApplePlatform(nav) {
|
|
23
|
+
const n = nav || (typeof navigator !== "undefined" ? navigator : undefined);
|
|
24
|
+
if (!n) return false;
|
|
25
|
+
const platform = (n.userAgentData && n.userAgentData.platform) || n.platform || n.userAgent || "";
|
|
26
|
+
return /mac|iphone|ipad|ipod/i.test(platform);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function paletteShortcut(lang, nav) {
|
|
30
|
+
if (isApplePlatform(nav)) return "⌘K";
|
|
31
|
+
return lang === "en" ? "Ctrl+K" : "Strg+K";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Opens the palette from anywhere (sidebar search button, header, help texts).
|
|
35
|
+
function openCommandPalette() {
|
|
36
|
+
if (typeof window !== "undefined") window.dispatchEvent(new window.CustomEvent(OPEN_EVENT));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pageTestId(item) {
|
|
40
|
+
return item.testId || `nav-${item.id || slug(item.to === "/" ? "overview" : item.to)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Every visible sidebar item plus extra pages (e.g. profile or settings areas), each path once.
|
|
44
|
+
function palettePages(nav, pages) {
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
const out = [];
|
|
47
|
+
visibleSections(nav).forEach((s) => s.items.forEach((i) => add(i, s.label)));
|
|
48
|
+
(pages || []).filter((p) => p && !p.hidden).forEach((p) => add(p, p.section));
|
|
49
|
+
function add(item, section) {
|
|
50
|
+
if (!item.to || seen.has(item.to)) return;
|
|
51
|
+
seen.add(item.to);
|
|
52
|
+
out.push(Object.assign({}, item, { section: item.section || section }));
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalize(s) {
|
|
58
|
+
return String(s || "").toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Every word of the query must appear in the label, the keywords, the section or the path.
|
|
62
|
+
function matches(item, query) {
|
|
63
|
+
const words = normalize(query).split(/\s+/).filter(Boolean);
|
|
64
|
+
if (!words.length) return true;
|
|
65
|
+
const hay = normalize([item.label, item.keywords, item.section, item.to].flat().filter(Boolean).join(" "));
|
|
66
|
+
return words.every((w) => hay.includes(w));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readRecent(key) {
|
|
70
|
+
try {
|
|
71
|
+
const v = JSON.parse(window.localStorage.getItem(key) || "[]");
|
|
72
|
+
return Array.isArray(v) ? v.filter((p) => typeof p === "string") : [];
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function writeRecent(key, list) {
|
|
79
|
+
try {
|
|
80
|
+
window.localStorage.setItem(key, JSON.stringify(list));
|
|
81
|
+
} catch (e) {
|
|
82
|
+
// Private windows can refuse storage; the palette works without recents.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Builds the visible groups for a query. Exported for tests and for apps that render their own list.
|
|
87
|
+
// groups: [{ id, label, order, items: [{ id, label, icon, to | onSelect, keywords, testId, alwaysShow, tone, hint }] }]
|
|
88
|
+
// Order: app groups by `order` (default 60), recent pages (50, only without a query), pages (100).
|
|
89
|
+
function paletteGroups({ nav, pages, groups, query, recent, activePath, labels, lang }) {
|
|
90
|
+
const L = labelsFor(lang, labels);
|
|
91
|
+
const all = palettePages(nav, pages);
|
|
92
|
+
const q = String(query || "").trim();
|
|
93
|
+
const out = [];
|
|
94
|
+
const extra = typeof groups === "function" ? groups(q) : groups;
|
|
95
|
+
(extra || []).forEach((g, gi) => {
|
|
96
|
+
if (!g) return;
|
|
97
|
+
const items = (g.items || []).filter((i) => i && !i.hidden && (i.alwaysShow || matches(Object.assign({ section: g.label }, i), q)));
|
|
98
|
+
if (items.length) {
|
|
99
|
+
out.push({
|
|
100
|
+
id: g.id || `group-${gi}`,
|
|
101
|
+
label: g.label,
|
|
102
|
+
order: g.order != null ? g.order : 60,
|
|
103
|
+
items: items.map((i, ii) => Object.assign({}, i, { key: `${g.id || gi}:${i.id || i.to || ii}`, testId: i.testId || `cmd-${slug(g.id || gi)}-${slug(i.id || i.to || ii)}` })),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
if (!q && recent && recent.length) {
|
|
108
|
+
const items = recent
|
|
109
|
+
.filter((p) => p !== activePath)
|
|
110
|
+
.map((p) => all.find((i) => i.to === p))
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.slice(0, 4)
|
|
113
|
+
.map((i) => Object.assign({}, i, { key: `recent:${i.to}`, icon: Icons.History, testId: `cmd-recent-${pageTestId(i)}` }));
|
|
114
|
+
if (items.length) out.push({ id: "recent", label: L.paletteRecent, order: 50, items });
|
|
115
|
+
}
|
|
116
|
+
const found = all.filter((i) => matches(i, q));
|
|
117
|
+
if (found.length) {
|
|
118
|
+
out.push({
|
|
119
|
+
id: "pages",
|
|
120
|
+
label: L.palettePages,
|
|
121
|
+
order: 100,
|
|
122
|
+
items: found.map((i) => Object.assign({}, i, { key: `page:${i.to}`, testId: `cmd-nav-${pageTestId(i)}` })),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return out.sort((a, b) => a.order - b.order);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ⌘K / Ctrl+K toggles, the open event opens. Returns [open, setOpen].
|
|
129
|
+
function useCommandPaletteHotkey(enabled) {
|
|
130
|
+
const [open, setOpen] = React.useState(false);
|
|
131
|
+
React.useEffect(() => {
|
|
132
|
+
if (enabled === false) return undefined;
|
|
133
|
+
function onKey(e) {
|
|
134
|
+
if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && (e.key === "k" || e.key === "K")) {
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
setOpen((o) => !o);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function onOpen() {
|
|
140
|
+
setOpen(true);
|
|
141
|
+
}
|
|
142
|
+
document.addEventListener("keydown", onKey);
|
|
143
|
+
window.addEventListener(OPEN_EVENT, onOpen);
|
|
144
|
+
return () => {
|
|
145
|
+
document.removeEventListener("keydown", onKey);
|
|
146
|
+
window.removeEventListener(OPEN_EVENT, onOpen);
|
|
147
|
+
};
|
|
148
|
+
}, [enabled]);
|
|
149
|
+
return [open, setOpen];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The palette. Uncontrolled by default (own ⌘K handling); pass open + onOpenChange to control it.
|
|
153
|
+
// onNavigate(to): the router's navigate; without it the browser follows the path.
|
|
154
|
+
// recentKey: localStorage key of the visited pages (false = no recents).
|
|
155
|
+
function CommandPalette(props) {
|
|
156
|
+
const {
|
|
157
|
+
nav, pages, groups, onNavigate, activePath, recentKey, lang, labels, placeholder, empty,
|
|
158
|
+
hotkey, testId, onQueryChange,
|
|
159
|
+
} = props;
|
|
160
|
+
const controlled = props.open !== undefined;
|
|
161
|
+
const [ownOpen, setOwnOpen] = useCommandPaletteHotkey(!controlled && hotkey !== false);
|
|
162
|
+
const open = controlled ? !!props.open : ownOpen;
|
|
163
|
+
const setOpen = React.useCallback(
|
|
164
|
+
(v) => {
|
|
165
|
+
if (controlled) {
|
|
166
|
+
if (props.onOpenChange) props.onOpenChange(v);
|
|
167
|
+
} else setOwnOpen(v);
|
|
168
|
+
},
|
|
169
|
+
[controlled, props.onOpenChange, setOwnOpen] // eslint-disable-line react-hooks/exhaustive-deps
|
|
170
|
+
);
|
|
171
|
+
const L = labelsFor(lang, labels);
|
|
172
|
+
const key = recentKey === false ? null : recentKey || "scalio.recentPages";
|
|
173
|
+
const [query, setQuery] = React.useState("");
|
|
174
|
+
const [active, setActive] = React.useState(0);
|
|
175
|
+
const [recent, setRecent] = React.useState([]);
|
|
176
|
+
const inputRef = React.useRef(null);
|
|
177
|
+
const listRef = React.useRef(null);
|
|
178
|
+
const restoreRef = React.useRef(null);
|
|
179
|
+
const idBase = React.useRef(`scalio-palette-${Math.random().toString(36).slice(2, 8)}`).current;
|
|
180
|
+
|
|
181
|
+
// Remember visited pages that the palette knows (only paths, nothing else).
|
|
182
|
+
React.useEffect(() => {
|
|
183
|
+
if (!key || !activePath) return;
|
|
184
|
+
if (!palettePages(nav, pages).some((i) => i.to === activePath)) return;
|
|
185
|
+
const cur = readRecent(key);
|
|
186
|
+
writeRecent(key, [activePath].concat(cur.filter((p) => p !== activePath)).slice(0, 6));
|
|
187
|
+
}, [activePath, key]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
188
|
+
|
|
189
|
+
React.useEffect(() => {
|
|
190
|
+
if (!open) return undefined;
|
|
191
|
+
restoreRef.current = typeof document !== "undefined" ? document.activeElement : null;
|
|
192
|
+
setQuery("");
|
|
193
|
+
setActive(0);
|
|
194
|
+
if (onQueryChange) onQueryChange("");
|
|
195
|
+
if (key) setRecent(readRecent(key));
|
|
196
|
+
const t = setTimeout(() => inputRef.current && inputRef.current.focus(), 0);
|
|
197
|
+
return () => {
|
|
198
|
+
clearTimeout(t);
|
|
199
|
+
const back = restoreRef.current;
|
|
200
|
+
if (back && typeof back.focus === "function" && document.contains(back)) back.focus();
|
|
201
|
+
};
|
|
202
|
+
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
203
|
+
|
|
204
|
+
const shown = open ? paletteGroups({ nav, pages, groups, query, recent, activePath, labels, lang }) : [];
|
|
205
|
+
const flat = [];
|
|
206
|
+
shown.forEach((g) => g.items.forEach((i) => flat.push(i)));
|
|
207
|
+
const current = Math.min(active, Math.max(flat.length - 1, 0));
|
|
208
|
+
|
|
209
|
+
React.useEffect(() => {
|
|
210
|
+
if (!open || !listRef.current) return;
|
|
211
|
+
const el = listRef.current.querySelector(`[data-index="${current}"]`);
|
|
212
|
+
if (el && el.scrollIntoView) el.scrollIntoView({ block: "nearest" });
|
|
213
|
+
}, [current, open]);
|
|
214
|
+
|
|
215
|
+
if (!open) return null;
|
|
216
|
+
|
|
217
|
+
function choose(item) {
|
|
218
|
+
if (!item) return;
|
|
219
|
+
setOpen(false);
|
|
220
|
+
if (item.onSelect) item.onSelect(item);
|
|
221
|
+
else if (item.to) {
|
|
222
|
+
if (onNavigate) onNavigate(item.to);
|
|
223
|
+
else if (typeof window !== "undefined") window.location.assign(item.to);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function onKeyDown(e) {
|
|
228
|
+
if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) {
|
|
229
|
+
e.preventDefault();
|
|
230
|
+
setActive(flat.length ? (current + 1) % flat.length : 0);
|
|
231
|
+
} else if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "p")) {
|
|
232
|
+
e.preventDefault();
|
|
233
|
+
setActive(flat.length ? (current - 1 + flat.length) % flat.length : 0);
|
|
234
|
+
} else if (e.key === "Home") {
|
|
235
|
+
e.preventDefault();
|
|
236
|
+
setActive(0);
|
|
237
|
+
} else if (e.key === "End") {
|
|
238
|
+
e.preventDefault();
|
|
239
|
+
setActive(Math.max(flat.length - 1, 0));
|
|
240
|
+
} else if (e.key === "Enter") {
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
choose(flat[current]);
|
|
243
|
+
} else if (e.key === "Escape") {
|
|
244
|
+
e.preventDefault();
|
|
245
|
+
e.stopPropagation();
|
|
246
|
+
setOpen(false);
|
|
247
|
+
} else if (e.key === "Tab") {
|
|
248
|
+
// Focus stays in the input; Tab moves through the results like the arrow keys.
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
if (flat.length) setActive(e.shiftKey ? (current - 1 + flat.length) % flat.length : (current + 1) % flat.length);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let index = -1;
|
|
255
|
+
const activeId = flat.length ? `${idBase}-opt-${current}` : undefined;
|
|
256
|
+
return h(
|
|
257
|
+
"div",
|
|
258
|
+
{ className: "scalio-palette-root", "data-testid": testId || "command-palette" },
|
|
259
|
+
h("div", { className: "scalio-palette-backdrop", onMouseDown: () => setOpen(false), "data-testid": "command-palette-backdrop" }),
|
|
260
|
+
h(
|
|
261
|
+
"div",
|
|
262
|
+
{ className: "scalio-palette", role: "dialog", "aria-modal": "true", "aria-label": L.paletteTitle },
|
|
263
|
+
h(
|
|
264
|
+
"div",
|
|
265
|
+
{ className: "scalio-palette-search" },
|
|
266
|
+
h(Icons.Search, { className: "scalio-icon" }),
|
|
267
|
+
h("input", {
|
|
268
|
+
ref: inputRef,
|
|
269
|
+
className: "scalio-palette-input",
|
|
270
|
+
type: "text",
|
|
271
|
+
role: "combobox",
|
|
272
|
+
"aria-expanded": "true",
|
|
273
|
+
"aria-controls": `${idBase}-list`,
|
|
274
|
+
"aria-activedescendant": activeId,
|
|
275
|
+
"aria-autocomplete": "list",
|
|
276
|
+
autoComplete: "off",
|
|
277
|
+
spellCheck: false,
|
|
278
|
+
placeholder: placeholder || L.palettePlaceholder,
|
|
279
|
+
value: query,
|
|
280
|
+
onChange: (e) => {
|
|
281
|
+
setQuery(e.target.value);
|
|
282
|
+
setActive(0);
|
|
283
|
+
if (onQueryChange) onQueryChange(e.target.value);
|
|
284
|
+
},
|
|
285
|
+
onKeyDown,
|
|
286
|
+
"data-testid": "command-palette-input",
|
|
287
|
+
}),
|
|
288
|
+
h("kbd", { className: "scalio-kbd" }, "Esc")
|
|
289
|
+
),
|
|
290
|
+
h(
|
|
291
|
+
"div",
|
|
292
|
+
{ className: "scalio-palette-list", id: `${idBase}-list`, role: "listbox", "aria-label": L.paletteTitle, ref: listRef, "data-testid": "command-palette-list" },
|
|
293
|
+
flat.length === 0
|
|
294
|
+
? h("div", { className: "scalio-palette-empty", "data-testid": "command-palette-empty" }, empty || L.paletteEmpty)
|
|
295
|
+
: shown.map((g) =>
|
|
296
|
+
h(
|
|
297
|
+
"div",
|
|
298
|
+
{ key: g.id, className: "scalio-palette-group", role: "group", "aria-labelledby": `${idBase}-g-${g.id}`, "data-group": g.id },
|
|
299
|
+
g.label ? h("div", { className: "scalio-palette-heading", id: `${idBase}-g-${g.id}`, role: "presentation" }, g.label) : null,
|
|
300
|
+
g.items.map((item) => {
|
|
301
|
+
index += 1;
|
|
302
|
+
const i = index;
|
|
303
|
+
const Icon = item.icon;
|
|
304
|
+
const on = i === current;
|
|
305
|
+
return h(
|
|
306
|
+
"div",
|
|
307
|
+
{
|
|
308
|
+
key: item.key,
|
|
309
|
+
id: `${idBase}-opt-${i}`,
|
|
310
|
+
role: "option",
|
|
311
|
+
"aria-selected": on ? "true" : "false",
|
|
312
|
+
"data-index": i,
|
|
313
|
+
className: cx("scalio-palette-item", on && "is-active", item.tone && `is-${item.tone}`),
|
|
314
|
+
onMouseMove: () => {
|
|
315
|
+
if (!on) setActive(i);
|
|
316
|
+
},
|
|
317
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
318
|
+
onClick: () => choose(item),
|
|
319
|
+
"data-testid": item.testId,
|
|
320
|
+
},
|
|
321
|
+
Icon ? h(Icon, { className: "scalio-palette-icon", "aria-hidden": "true" }) : h("span", { className: "scalio-palette-icon" }),
|
|
322
|
+
h("span", { className: "scalio-palette-label" }, item.label),
|
|
323
|
+
item.hint || (g.id === "pages" && item.section) || (g.id === "recent" && item.section)
|
|
324
|
+
? h("span", { className: "scalio-palette-hint" }, item.hint || item.section)
|
|
325
|
+
: null,
|
|
326
|
+
on ? h(Icons.CornerDownLeft, { className: "scalio-palette-enter" }) : null
|
|
327
|
+
);
|
|
328
|
+
})
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
),
|
|
332
|
+
h("div", { className: "scalio-palette-foot" }, L.paletteHint)
|
|
333
|
+
)
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
module.exports = {
|
|
338
|
+
CommandPalette,
|
|
339
|
+
useCommandPaletteHotkey,
|
|
340
|
+
openCommandPalette,
|
|
341
|
+
paletteGroups,
|
|
342
|
+
palettePages,
|
|
343
|
+
paletteShortcut,
|
|
344
|
+
isApplePlatform,
|
|
345
|
+
OPEN_PALETTE_EVENT: OPEN_EVENT,
|
|
346
|
+
};
|