@scalio-cloud/ui 0.8.0 → 0.9.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 +12 -0
- package/README.md +19 -0
- package/nav.css +38 -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/palette.js +346 -0
- package/react/support.js +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0
|
|
4
|
+
- New `CommandPalette` (⌘K / Strg+K): finds every page of the sidebar data (`nav`) plus extra `pages`, remembers the
|
|
5
|
+
last visited pages, and takes app commands as `groups` (Nexus: AI and guarded actions). Arrow keys, Enter, Esc,
|
|
6
|
+
no dialog library. Helpers `openCommandPalette()` (for the sidebar search button), `paletteShortcut(lang)`,
|
|
7
|
+
`paletteGroups`, `palettePages`. `LABELS` get the palette texts, `Icons` get History and CornerDownLeft.
|
|
8
|
+
**No visible change** until an app uses it; Nexus keeps its test ids (`command-palette-input`, `cmd-nav-*`).
|
|
9
|
+
|
|
10
|
+
## 0.8.1
|
|
11
|
+
- `SupportConnection` fills in the portal address of the instance's stage (`default_portal` from scalio-connect
|
|
12
|
+
0.5.0: dev -> portaldev, test -> portaltest, others -> portal.scaliocloud.com). **Visible change:** the address
|
|
13
|
+
field is no longer empty on the settings card. No change with older backends.
|
|
14
|
+
|
|
3
15
|
## 0.8.0
|
|
4
16
|
- New `SecurityFactors` (profile: authenticator app, backup codes, passkeys) with `securityApi(api, webauthn)`, and
|
|
5
17
|
`LicensePanel` (status, usage, add-ons, key) with `licenseApi(api)` and `mapScalioLicense`. They replace the copies
|
package/README.md
CHANGED
|
@@ -216,6 +216,25 @@ the blocks show the message themselves. `confirm` is the app's dialog (`{title,
|
|
|
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
217
|
that returns the view model described in `react/license.js`.
|
|
218
218
|
|
|
219
|
+
## Command palette (React)
|
|
220
|
+
|
|
221
|
+
Reads the same navigation data as the sidebar. Put it once into the layout; the sidebar search button opens it.
|
|
222
|
+
|
|
223
|
+
```jsx
|
|
224
|
+
import { CommandPalette, openCommandPalette, paletteShortcut } from "@scalio-cloud/ui/react";
|
|
225
|
+
|
|
226
|
+
<Sidebar nav={nav} search={{ onOpen: openCommandPalette, shortcut: paletteShortcut(lang) }} ... />
|
|
227
|
+
<CommandPalette nav={nav} activePath={location.pathname} onNavigate={navigate} lang={lang}
|
|
228
|
+
pages={[{ to: "/profile", label: "Profil" }]}
|
|
229
|
+
groups={(query) => [{ id: "ai", label: "KI", order: 10, items: [{ id: "ask", label: `Fragen: ${query}`, onSelect: ask, hidden: !query }] }]} />
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Pages: every visible sidebar item plus `pages` (each path once), searched by label, `keywords`, section and path.
|
|
233
|
+
Groups sort by `order` (default 60); recent pages (50) show only while the query is empty, pages come last (100).
|
|
234
|
+
An item has `label`, `icon`, `to` or `onSelect`, and optional `keywords`, `hint`, `tone` (`primary`, `danger`),
|
|
235
|
+
`testId` and `alwaysShow`. Without `open` the palette handles ⌘K / Ctrl+K and `openCommandPalette()` itself;
|
|
236
|
+
pass `open` + `onOpenChange` to control it. `recentKey` sets the localStorage key (`false`: no recents).
|
|
237
|
+
|
|
219
238
|
## Installing
|
|
220
239
|
|
|
221
240
|
Public on npmjs.com, no token needed: `yarn add @scalio-cloud/ui` (pin the exact version).
|
package/nav.css
CHANGED
|
@@ -362,3 +362,41 @@
|
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scalio-cloud/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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/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
|
+
};
|
package/react/support.js
CHANGED
|
@@ -257,7 +257,11 @@ function SupportConnection({ client, lang, labels, onChange }) {
|
|
|
257
257
|
const [note, setNote] = React.useState("");
|
|
258
258
|
React.useEffect(() => {
|
|
259
259
|
let alive = true;
|
|
260
|
-
client.connection().then((d) =>
|
|
260
|
+
client.connection().then((d) => {
|
|
261
|
+
if (!alive) return;
|
|
262
|
+
setSt(d);
|
|
263
|
+
if (d && d.default_portal) setPortal((p) => p || d.default_portal); // the portal of this instance's stage
|
|
264
|
+
}).catch((err) => alive && (setError(errorText(err)), setSt(false)));
|
|
261
265
|
return () => { alive = false; };
|
|
262
266
|
}, [client]);
|
|
263
267
|
const run = async (fn, ok) => {
|