@jxsuite/ui 0.0.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/LICENSE +21 -0
- package/README.md +11 -0
- package/components/jx-action-button.json +414 -0
- package/components/jx-button.json +310 -0
- package/components/jx-icon.json +81 -0
- package/components/jx-menu-item.json +265 -0
- package/components/jx-menu.json +122 -0
- package/icons/list.json +102 -0
- package/icons/manifest.json +348 -0
- package/package.json +46 -0
- package/project.json +167 -0
- package/src/behaviors/menu.ts +396 -0
- package/src/documents.ts +23 -0
- package/src/icons-build.ts +74 -0
- package/src/icons.ts +55 -0
- package/src/index.ts +70 -0
- package/src/testing/popover-shim.ts +139 -0
- package/src/theme.ts +63 -0
- package/stylebook/jx-action-button.json +108 -0
- package/stylebook/jx-button.json +104 -0
- package/stylebook/jx-icon.json +208 -0
- package/stylebook/jx-menu-item.json +221 -0
- package/stylebook/jx-menu.json +302 -0
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jxsuite/ui",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "The Jx UI kit: interface elements authored as Jx documents, with their theme and icons",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/jxsuite/jx.git",
|
|
9
|
+
"directory": "packages/ui"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src/",
|
|
13
|
+
"components/",
|
|
14
|
+
"theme/",
|
|
15
|
+
"icons/",
|
|
16
|
+
"stylebook/",
|
|
17
|
+
"project.json"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./src/index.ts",
|
|
22
|
+
"./theme": "./src/theme.ts",
|
|
23
|
+
"./icons": "./src/icons.ts",
|
|
24
|
+
"./documents": "./src/documents.ts",
|
|
25
|
+
"./behaviors/menu": "./src/behaviors/menu.ts",
|
|
26
|
+
"./testing/popover-shim": "./src/testing/popover-shim.ts",
|
|
27
|
+
"./icons/manifest.json": "./icons/manifest.json"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"provenance": false
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build:icons": "bun run scripts/build-icons.ts",
|
|
34
|
+
"test": "bun test --isolate",
|
|
35
|
+
"test:coverage": "bun test --isolate --coverage",
|
|
36
|
+
"upgrade": "bun update --latest && bun install"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@jxsuite/runtime": "workspace:^",
|
|
40
|
+
"@jxsuite/schema": "workspace:^"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@happy-dom/global-registrator": "^20.12.0",
|
|
44
|
+
"@phosphor-icons/core": "^2.1.1"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/project.json
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Jx UI Kit",
|
|
3
|
+
"url": "https://jxsuite.com/ui",
|
|
4
|
+
"defaults": {
|
|
5
|
+
"lang": "en"
|
|
6
|
+
},
|
|
7
|
+
"$media": {
|
|
8
|
+
"--md": "(max-width: 768px)",
|
|
9
|
+
"--sm": "(max-width: 640px)"
|
|
10
|
+
},
|
|
11
|
+
"style": {
|
|
12
|
+
"--jx-gray-50": "#f7f7f9",
|
|
13
|
+
"--jx-gray-100": "#eeeef2",
|
|
14
|
+
"--jx-gray-200": "#e1e1e7",
|
|
15
|
+
"--jx-gray-300": "#c9c9d2",
|
|
16
|
+
"--jx-gray-400": "#a2a2af",
|
|
17
|
+
"--jx-gray-500": "#7c7c8b",
|
|
18
|
+
"--jx-gray-600": "#5e5e6c",
|
|
19
|
+
"--jx-gray-700": "#45454f",
|
|
20
|
+
"--jx-gray-800": "#2f2f36",
|
|
21
|
+
"--jx-gray-850": "#26262c",
|
|
22
|
+
"--jx-gray-900": "#1d1d22",
|
|
23
|
+
"--jx-gray-925": "#17171b",
|
|
24
|
+
"--jx-gray-950": "#111114",
|
|
25
|
+
"--jx-gray-975": "#0b0b0d",
|
|
26
|
+
"--jx-blue-50": "#eef4ff",
|
|
27
|
+
"--jx-blue-100": "#dbe7fe",
|
|
28
|
+
"--jx-blue-200": "#bcd3fd",
|
|
29
|
+
"--jx-blue-300": "#8fb5fb",
|
|
30
|
+
"--jx-blue-400": "#5f95f7",
|
|
31
|
+
"--jx-blue-500": "#3b82f6",
|
|
32
|
+
"--jx-blue-600": "#2563eb",
|
|
33
|
+
"--jx-blue-700": "#1d4ed8",
|
|
34
|
+
"--jx-blue-800": "#1e40af",
|
|
35
|
+
"--jx-blue-900": "#172554",
|
|
36
|
+
"--jx-blue-950": "#0f172e",
|
|
37
|
+
"--jx-red-50": "#fdeeee",
|
|
38
|
+
"--jx-red-100": "#fbd9d9",
|
|
39
|
+
"--jx-red-200": "#f5b3b3",
|
|
40
|
+
"--jx-red-300": "#ee8a8a",
|
|
41
|
+
"--jx-red-400": "#e86460",
|
|
42
|
+
"--jx-red-500": "#dc3f3b",
|
|
43
|
+
"--jx-red-600": "#c22e2b",
|
|
44
|
+
"--jx-red-700": "#a12522",
|
|
45
|
+
"--jx-red-800": "#7d1e1c",
|
|
46
|
+
"--jx-red-900": "#5a1614",
|
|
47
|
+
"--jx-red-950": "#3a0f0e",
|
|
48
|
+
"--jx-green-50": "#e9f7ee",
|
|
49
|
+
"--jx-green-100": "#cdeedb",
|
|
50
|
+
"--jx-green-200": "#a6dfbd",
|
|
51
|
+
"--jx-green-300": "#74c996",
|
|
52
|
+
"--jx-green-400": "#4fb27a",
|
|
53
|
+
"--jx-green-500": "#2f9a5f",
|
|
54
|
+
"--jx-green-600": "#22804e",
|
|
55
|
+
"--jx-green-700": "#1a6640",
|
|
56
|
+
"--jx-green-800": "#144e32",
|
|
57
|
+
"--jx-green-900": "#0f3a26",
|
|
58
|
+
"--jx-green-950": "#0a2719",
|
|
59
|
+
"--jx-amber-50": "#fdf3e1",
|
|
60
|
+
"--jx-amber-100": "#fae4b5",
|
|
61
|
+
"--jx-amber-200": "#f2cc7c",
|
|
62
|
+
"--jx-amber-300": "#e5ae4a",
|
|
63
|
+
"--jx-amber-400": "#d59429",
|
|
64
|
+
"--jx-amber-500": "#b8791a",
|
|
65
|
+
"--jx-amber-600": "#96600f",
|
|
66
|
+
"--jx-amber-700": "#764b0b",
|
|
67
|
+
"--jx-amber-800": "#5a3908",
|
|
68
|
+
"--jx-amber-900": "#3f2806",
|
|
69
|
+
"--jx-amber-950": "#2a1a04",
|
|
70
|
+
"--jx-bg": "light-dark(var(--jx-gray-50), var(--jx-gray-950))",
|
|
71
|
+
"--jx-bg-panel": "light-dark(#ffffff, var(--jx-gray-925))",
|
|
72
|
+
"--jx-bg-input": "light-dark(#ffffff, var(--jx-gray-900))",
|
|
73
|
+
"--jx-bg-overlay": "light-dark(#ffffff, var(--jx-gray-850))",
|
|
74
|
+
"--jx-border": "light-dark(var(--jx-gray-200), var(--jx-gray-800))",
|
|
75
|
+
"--jx-border-strong": "light-dark(var(--jx-gray-300), var(--jx-gray-700))",
|
|
76
|
+
"--jx-fg": "light-dark(var(--jx-gray-900), var(--jx-gray-100))",
|
|
77
|
+
"--jx-fg-dim": "light-dark(var(--jx-gray-600), var(--jx-gray-400))",
|
|
78
|
+
"--jx-fg-muted": "var(--jx-gray-500)",
|
|
79
|
+
"--jx-accent": "light-dark(var(--jx-blue-600), var(--jx-blue-500))",
|
|
80
|
+
"--jx-accent-solid": "var(--jx-blue-600)",
|
|
81
|
+
"--jx-accent-hover": "light-dark(var(--jx-blue-700), var(--jx-blue-400))",
|
|
82
|
+
"--jx-accent-fg": "#ffffff",
|
|
83
|
+
"--jx-accent-8": "color-mix(in oklab, var(--jx-accent) 8%, transparent)",
|
|
84
|
+
"--jx-accent-10": "color-mix(in oklab, var(--jx-accent) 10%, transparent)",
|
|
85
|
+
"--jx-accent-15": "color-mix(in oklab, var(--jx-accent) 15%, transparent)",
|
|
86
|
+
"--jx-accent-20": "color-mix(in oklab, var(--jx-accent) 20%, transparent)",
|
|
87
|
+
"--jx-accent-50": "color-mix(in oklab, var(--jx-accent) 50%, transparent)",
|
|
88
|
+
"--jx-hover-bg": "color-mix(in oklab, var(--jx-fg) 6%, transparent)",
|
|
89
|
+
"--jx-danger": "light-dark(var(--jx-red-600), var(--jx-red-400))",
|
|
90
|
+
"--jx-success": "light-dark(var(--jx-green-600), var(--jx-green-400))",
|
|
91
|
+
"--jx-warning": "light-dark(var(--jx-amber-600), var(--jx-amber-300))",
|
|
92
|
+
"--jx-tag": "light-dark(var(--jx-blue-700), var(--jx-blue-300))",
|
|
93
|
+
"--jx-signal": "light-dark(var(--jx-amber-700), var(--jx-amber-200))",
|
|
94
|
+
"--jx-handler": "light-dark(#7c3aed, #c8a2e0)",
|
|
95
|
+
"--jx-map": "light-dark(#4f46b5, #8b83e6)",
|
|
96
|
+
"--jx-switch-c": "var(--jx-danger)",
|
|
97
|
+
"--jx-font-sans": "\"Inter Variable\", \"Inter\", system-ui, -apple-system, \"Segoe UI\", sans-serif",
|
|
98
|
+
"--jx-font-mono": "\"JetBrains Mono\", \"SF Mono\", \"Fira Code\", monospace",
|
|
99
|
+
"--jx-text-xs": "10px",
|
|
100
|
+
"--jx-text-sm": "11px",
|
|
101
|
+
"--jx-text-md": "12px",
|
|
102
|
+
"--jx-text-lg": "14px",
|
|
103
|
+
"--jx-leading-xs": "14px",
|
|
104
|
+
"--jx-leading-sm": "16px",
|
|
105
|
+
"--jx-leading-md": "18px",
|
|
106
|
+
"--jx-leading-lg": "20px",
|
|
107
|
+
"--jx-space-1": "2px",
|
|
108
|
+
"--jx-space-2": "4px",
|
|
109
|
+
"--jx-space-3": "8px",
|
|
110
|
+
"--jx-space-4": "12px",
|
|
111
|
+
"--jx-space-5": "16px",
|
|
112
|
+
"--jx-space-6": "24px",
|
|
113
|
+
"--jx-space-7": "32px",
|
|
114
|
+
"--jx-control-h": "24px",
|
|
115
|
+
"--jx-icon-size": "16px",
|
|
116
|
+
"--jx-radius-xs": "2px",
|
|
117
|
+
"--jx-radius-sm": "4px",
|
|
118
|
+
"--jx-radius-md": "6px",
|
|
119
|
+
"--jx-radius-lg": "10px",
|
|
120
|
+
"--jx-focus-ring": "2px solid var(--jx-accent)",
|
|
121
|
+
"--jx-shadow-popover": "0 0 0 1px var(--jx-border-strong), 0 8px 24px -8px rgb(0 0 0 / 0.6), 0 2px 6px rgb(0 0 0 / 0.3)",
|
|
122
|
+
"--jx-shadow-dialog": "0 0 0 1px var(--jx-border-strong), 0 24px 64px -16px rgb(0 0 0 / 0.7)",
|
|
123
|
+
"--jx-dur-1": "150ms",
|
|
124
|
+
"--jx-dur-2": "200ms",
|
|
125
|
+
"--jx-ease-out": "cubic-bezier(0.2, 0, 0, 1)",
|
|
126
|
+
"--jx-ease-in": "cubic-bezier(0.4, 0, 1, 1)",
|
|
127
|
+
"colorScheme": "light dark",
|
|
128
|
+
"&[data-theme=\"light\"]": {
|
|
129
|
+
"colorScheme": "light",
|
|
130
|
+
"--jx-shadow-popover": "0 0 0 1px var(--jx-border-strong), 0 8px 24px -8px rgb(0 0 0 / 0.18), 0 2px 6px rgb(0 0 0 / 0.1)",
|
|
131
|
+
"--jx-shadow-dialog": "0 0 0 1px var(--jx-border-strong), 0 24px 64px -16px rgb(0 0 0 / 0.25)"
|
|
132
|
+
},
|
|
133
|
+
"&[data-theme=\"dark\"]": {
|
|
134
|
+
"colorScheme": "dark"
|
|
135
|
+
},
|
|
136
|
+
"&[data-density=\"compact\"]": {
|
|
137
|
+
"--jx-control-h": "20px",
|
|
138
|
+
"--jx-text-md": "11px"
|
|
139
|
+
},
|
|
140
|
+
"&[data-density=\"comfortable\"]": {
|
|
141
|
+
"--jx-control-h": "28px"
|
|
142
|
+
},
|
|
143
|
+
"@(prefers-reduced-motion: reduce)": {
|
|
144
|
+
"--jx-dur-1": "0ms",
|
|
145
|
+
"--jx-dur-2": "0ms"
|
|
146
|
+
},
|
|
147
|
+
"@(forced-colors: active)": {
|
|
148
|
+
"--jx-focus-ring": "2px solid Highlight"
|
|
149
|
+
},
|
|
150
|
+
"@property --jx-accent": {
|
|
151
|
+
"syntax": "\"<color>\"",
|
|
152
|
+
"inherits": "true",
|
|
153
|
+
"initialValue": "#3b82f6"
|
|
154
|
+
},
|
|
155
|
+
"@property --jx-dur-1": {
|
|
156
|
+
"syntax": "\"<time>\"",
|
|
157
|
+
"inherits": "true",
|
|
158
|
+
"initialValue": "150ms"
|
|
159
|
+
},
|
|
160
|
+
"@property --jx-dur-2": {
|
|
161
|
+
"syntax": "\"<time>\"",
|
|
162
|
+
"inherits": "true",
|
|
163
|
+
"initialValue": "200ms"
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
"$elements": [{ "$ref": "./components/jx-icon.json" }]
|
|
167
|
+
}
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The menu behaviour: what a `jx-menu` needs that the closed operator set cannot express.
|
|
3
|
+
*
|
|
4
|
+
* Every function here is a `(state, event)` handler a `jx-menu` document names through `$src`, or a
|
|
5
|
+
* helper those handlers share. What they do beyond reading state is exactly the allowed set
|
|
6
|
+
* (specs/ui.md §2): move focus, measure, and call an element's own `showPopover()` /
|
|
7
|
+
* `hidePopover()`. Light dismissal, Escape on the topmost popover and focus restoration are the
|
|
8
|
+
* platform's; this module adds the roving caret, typeahead, the submenu stack and the viewport
|
|
9
|
+
* clamp.
|
|
10
|
+
*
|
|
11
|
+
* A submenu is a child `jx-menu` slotted into a row (`slot="submenu"`), so it is a DOM descendant
|
|
12
|
+
* of its parent panel and the platform treats the two as one popover hierarchy: opening the child
|
|
13
|
+
* does not close the parent, and a click outside closes both.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const ROW = "jx-menu-item";
|
|
17
|
+
const MENU = "jx-menu";
|
|
18
|
+
|
|
19
|
+
/** The reactive scope a `jx-menu` document hands its handlers. */
|
|
20
|
+
export interface MenuState {
|
|
21
|
+
open?: boolean;
|
|
22
|
+
x?: number;
|
|
23
|
+
y?: number;
|
|
24
|
+
floor?: number;
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A row element with the property accessors its document installs. */
|
|
29
|
+
type RowElement = HTMLElement & { expanded?: boolean; disabled?: boolean };
|
|
30
|
+
/** A menu element with the property accessors its document installs. */
|
|
31
|
+
type MenuElement = HTMLElement & { open?: boolean; x?: number; y?: number; floor?: number };
|
|
32
|
+
|
|
33
|
+
/** The menu an event's `currentTarget` is. */
|
|
34
|
+
function menuOf(event: Event): MenuElement | null {
|
|
35
|
+
const target = event.currentTarget;
|
|
36
|
+
return target instanceof HTMLElement && target.localName === MENU
|
|
37
|
+
? (target as MenuElement)
|
|
38
|
+
: null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether `menu` is the nearest menu around `node` — a key or a hover in a nested submenu is that
|
|
43
|
+
* submenu's.
|
|
44
|
+
*/
|
|
45
|
+
function ownsNode(menu: HTMLElement, node: EventTarget | null): boolean {
|
|
46
|
+
return node instanceof Element && node.closest(MENU) === menu;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The rows of one menu, in order, excluding the rows of any submenu it contains and any row that
|
|
51
|
+
* cannot act. Rows sit inside the panel's emulated `<slot>` and inside `role="none"` wrappers a
|
|
52
|
+
* surface may add, so this walks descendants and keeps the ones whose nearest menu is this one.
|
|
53
|
+
*/
|
|
54
|
+
export function rowsOf(
|
|
55
|
+
menu: HTMLElement,
|
|
56
|
+
options: { includeDisabled?: boolean } = {},
|
|
57
|
+
): RowElement[] {
|
|
58
|
+
const rows = [...menu.querySelectorAll<RowElement>(ROW)].filter(
|
|
59
|
+
(row) => row.closest(MENU) === menu,
|
|
60
|
+
);
|
|
61
|
+
return options.includeDisabled
|
|
62
|
+
? rows
|
|
63
|
+
: rows.filter((row) => row.getAttribute("aria-disabled") !== "true");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The submenu a row owns: the `jx-menu` slotted into it, if any. */
|
|
67
|
+
export function submenuOf(row: HTMLElement): MenuElement | null {
|
|
68
|
+
for (const menu of row.querySelectorAll<MenuElement>(MENU)) {
|
|
69
|
+
if (menu.parentElement?.closest(ROW) === row) {
|
|
70
|
+
return menu;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The row a submenu is slotted into, if it is a submenu. */
|
|
77
|
+
export function parentRowOf(menu: HTMLElement): RowElement | null {
|
|
78
|
+
return (menu.parentElement?.closest(ROW) as RowElement | null) ?? null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The outermost menu of a stack. */
|
|
82
|
+
export function rootMenuOf(menu: HTMLElement): HTMLElement {
|
|
83
|
+
let root = menu;
|
|
84
|
+
for (;;) {
|
|
85
|
+
const row = parentRowOf(root);
|
|
86
|
+
const above = row?.closest(MENU);
|
|
87
|
+
if (!row || !(above instanceof HTMLElement)) {
|
|
88
|
+
return root;
|
|
89
|
+
}
|
|
90
|
+
root = above;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isShowing(menu: MenuElement): boolean {
|
|
95
|
+
return menu.open === true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Show a menu panel through the platform, if it is not already showing. */
|
|
99
|
+
export function showMenu(menu: MenuElement): void {
|
|
100
|
+
if (!isShowing(menu) && typeof menu.showPopover === "function") {
|
|
101
|
+
menu.showPopover();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Hide a menu panel through the platform, if it is showing. */
|
|
106
|
+
export function hideMenu(menu: MenuElement): void {
|
|
107
|
+
if (isShowing(menu) && typeof menu.hidePopover === "function") {
|
|
108
|
+
menu.hidePopover();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Move the roving caret to `index`, wrapping at both ends. */
|
|
113
|
+
export function focusRow(rows: RowElement[], index: number): RowElement | null {
|
|
114
|
+
if (rows.length === 0) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const next = ((index % rows.length) + rows.length) % rows.length;
|
|
118
|
+
for (const [at, row] of rows.entries()) {
|
|
119
|
+
row.tabIndex = at === next ? 0 : -1;
|
|
120
|
+
}
|
|
121
|
+
const row = rows[next]!;
|
|
122
|
+
row.focus();
|
|
123
|
+
return row;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The row that holds the caret, as an index into `rows`; -1 when none does. */
|
|
127
|
+
function activeIndex(rows: RowElement[], target: EventTarget | null): number {
|
|
128
|
+
return rows.findIndex(
|
|
129
|
+
(row) => row === target || (target instanceof Node && row.contains(target)),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Open a row's submenu beside it and move the caret into it. */
|
|
134
|
+
export function openSubmenu(
|
|
135
|
+
row: RowElement,
|
|
136
|
+
options: { focus?: boolean } = {},
|
|
137
|
+
): MenuElement | null {
|
|
138
|
+
const sub = submenuOf(row);
|
|
139
|
+
if (!sub) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
const box = row.getBoundingClientRect();
|
|
143
|
+
sub.x = Math.round(box.right) - 2;
|
|
144
|
+
sub.y = Math.round(box.top) - 4;
|
|
145
|
+
row.expanded = true;
|
|
146
|
+
showMenu(sub);
|
|
147
|
+
if (options.focus) {
|
|
148
|
+
focusRow(rowsOf(sub), 0);
|
|
149
|
+
}
|
|
150
|
+
return sub;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Close every submenu open under `menu` except the one a given row owns. */
|
|
154
|
+
function closeSubmenus(menu: HTMLElement, except: RowElement | null = null): void {
|
|
155
|
+
for (const row of rowsOf(menu, { includeDisabled: true })) {
|
|
156
|
+
if (row === except) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const sub = submenuOf(row);
|
|
160
|
+
if (sub) {
|
|
161
|
+
hideMenu(sub);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A row's label text: its label part, else all of its text. */
|
|
167
|
+
function rowLabel(row: HTMLElement): string {
|
|
168
|
+
return row.querySelector('[part="label"]')?.textContent ?? row.textContent ?? "";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The next row after `active` whose label starts with `char`, wrapping; -1 when none does. */
|
|
172
|
+
function typeaheadHit(rows: RowElement[], active: number, char: string): number {
|
|
173
|
+
const labels = rows.map((row) => rowLabel(row).trim().toLowerCase());
|
|
174
|
+
for (let step = 1; step <= labels.length; step++) {
|
|
175
|
+
const at = (active + step) % labels.length;
|
|
176
|
+
if (labels[at]!.startsWith(char)) {
|
|
177
|
+
return at;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return -1;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A single printable character, for typeahead; `null` for anything else. */
|
|
184
|
+
function typeaheadChar(event: KeyboardEvent): string | null {
|
|
185
|
+
return event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey
|
|
186
|
+
? event.key.toLowerCase()
|
|
187
|
+
: null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The menu keyboard contract, on the menu whose row holds the caret. A key that lands in a nested
|
|
192
|
+
* submenu belongs to that submenu's own handler and is left alone here.
|
|
193
|
+
*
|
|
194
|
+
* @param {MenuState} _state
|
|
195
|
+
* @param {KeyboardEvent} event
|
|
196
|
+
*/
|
|
197
|
+
export function onMenuKeydown(_state: MenuState, event: KeyboardEvent): void {
|
|
198
|
+
const menu = menuOf(event);
|
|
199
|
+
if (!menu || !ownsNode(menu, event.target)) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const rows = rowsOf(menu);
|
|
203
|
+
const active = activeIndex(rows, event.target);
|
|
204
|
+
switch (event.key) {
|
|
205
|
+
case "ArrowDown": {
|
|
206
|
+
focusRow(rows, active + 1);
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
case "ArrowUp": {
|
|
210
|
+
focusRow(rows, active === -1 ? -1 : active - 1);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
case "Home": {
|
|
214
|
+
focusRow(rows, 0);
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
case "End": {
|
|
218
|
+
focusRow(rows, -1);
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
case "Enter":
|
|
222
|
+
case " ": {
|
|
223
|
+
rows[active]?.click();
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
case "ArrowRight": {
|
|
227
|
+
const row = rows[active];
|
|
228
|
+
if (!row || !submenuOf(row)) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
closeSubmenus(menu, row);
|
|
232
|
+
openSubmenu(row, { focus: true });
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case "ArrowLeft": {
|
|
236
|
+
const parent = parentRowOf(menu);
|
|
237
|
+
if (!parent) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
hideMenu(menu);
|
|
241
|
+
parent.focus();
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
case "Escape": {
|
|
245
|
+
// One level at a time: the submenu with the caret, else the whole menu.
|
|
246
|
+
const parent = parentRowOf(menu);
|
|
247
|
+
hideMenu(menu);
|
|
248
|
+
parent?.focus();
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
case "Tab": {
|
|
252
|
+
hideMenu(rootMenuOf(menu) as MenuElement);
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
default: {
|
|
256
|
+
const char = typeaheadChar(event);
|
|
257
|
+
if (char === null) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const hit = typeaheadHit(rows, active, char);
|
|
261
|
+
if (hit === -1) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
focusRow(rows, hit);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
event.preventDefault();
|
|
268
|
+
event.stopPropagation();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Mirror the platform's toggle into state; on show, clamp into the viewport once laid out and move
|
|
273
|
+
* the caret to the first row; on hide, close whatever submenus were open and clear the parent row's
|
|
274
|
+
* expanded state.
|
|
275
|
+
*
|
|
276
|
+
* @param {MenuState} state
|
|
277
|
+
* @param {Event} event The platform's ToggleEvent
|
|
278
|
+
*/
|
|
279
|
+
export function onMenuToggle(state: MenuState, event: Event): void {
|
|
280
|
+
const menu = menuOf(event);
|
|
281
|
+
if (!menu) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const opening = (event as { newState?: string }).newState === "open";
|
|
285
|
+
state.open = opening;
|
|
286
|
+
const parent = parentRowOf(menu);
|
|
287
|
+
if (!opening) {
|
|
288
|
+
closeSubmenus(menu);
|
|
289
|
+
if (parent) {
|
|
290
|
+
parent.expanded = false;
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
clampIntoViewport(state, menu);
|
|
295
|
+
if (!menu.contains(document.activeElement)) {
|
|
296
|
+
focusRow(rowsOf(menu), 0);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The lowest edge a stack may reach: the root menu's `floor`, else the viewport's bottom. */
|
|
301
|
+
function floorOf(menu: HTMLElement): number {
|
|
302
|
+
const floor = Number((rootMenuOf(menu) as MenuElement).floor ?? 0);
|
|
303
|
+
return floor > 0 ? floor : window.innerHeight;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Keep a shown panel inside its area. Measured after a frame, because an unlaid-out panel is zero
|
|
308
|
+
* wide and would never appear to overflow anything; a zero-size box (a test DOM) is left where it
|
|
309
|
+
* is.
|
|
310
|
+
*
|
|
311
|
+
* A submenu that would leave the viewport on the right FLIPS to its parent menu's left rather than
|
|
312
|
+
* sliding over the parent; a root menu slides. Both are floored at the stack's `floor` — the root
|
|
313
|
+
* menu's, so one floor holds for every level.
|
|
314
|
+
*
|
|
315
|
+
* @param {MenuState} state
|
|
316
|
+
* @param {HTMLElement} menu
|
|
317
|
+
*/
|
|
318
|
+
export function clampIntoViewport(state: MenuState, menu: HTMLElement): void {
|
|
319
|
+
const measure = (): void => {
|
|
320
|
+
const box = menu.getBoundingClientRect();
|
|
321
|
+
if (box.width === 0 && box.height === 0) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const margin = 4;
|
|
325
|
+
const bottom = floorOf(menu);
|
|
326
|
+
if (box.right > window.innerWidth - margin) {
|
|
327
|
+
const parent = parentRowOf(menu)?.closest<HTMLElement>(MENU) ?? null;
|
|
328
|
+
const flipped = parent ? Math.round(parent.getBoundingClientRect().left) - box.width + 2 : -1;
|
|
329
|
+
state.x =
|
|
330
|
+
flipped >= margin
|
|
331
|
+
? flipped
|
|
332
|
+
: Math.max(margin, Math.round(window.innerWidth - box.width - margin));
|
|
333
|
+
}
|
|
334
|
+
if (box.bottom > bottom) {
|
|
335
|
+
state.y = Math.max(margin, Math.round(bottom - box.height));
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
if (typeof requestAnimationFrame === "function") {
|
|
339
|
+
requestAnimationFrame(measure);
|
|
340
|
+
} else {
|
|
341
|
+
measure();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Put the caret somewhere after the rows changed under it: when focus is no longer inside `menu`,
|
|
347
|
+
* the first row of the deepest open submenu takes it — and a submenu left with no rows at all is
|
|
348
|
+
* closed, its parent row taking the caret instead.
|
|
349
|
+
*
|
|
350
|
+
* @param {HTMLElement} menu The root menu
|
|
351
|
+
*/
|
|
352
|
+
export function ensureCaret(menu: HTMLElement): void {
|
|
353
|
+
if (menu.contains(document.activeElement)) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
let deepest = menu as MenuElement;
|
|
357
|
+
for (;;) {
|
|
358
|
+
const open = rowsOf(deepest, { includeDisabled: true })
|
|
359
|
+
.map((row) => submenuOf(row))
|
|
360
|
+
.find((sub) => sub !== null && isShowing(sub));
|
|
361
|
+
if (!open) {
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
deepest = open;
|
|
365
|
+
}
|
|
366
|
+
const parent = parentRowOf(deepest);
|
|
367
|
+
if (rowsOf(deepest).length === 0 && parent) {
|
|
368
|
+
hideMenu(deepest);
|
|
369
|
+
parent.focus();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
focusRow(rowsOf(deepest), 0);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Hover: entering a row that owns a submenu opens it (closing any sibling's); entering one that
|
|
377
|
+
* does not closes whatever submenu was open.
|
|
378
|
+
*
|
|
379
|
+
* @param {MenuState} _state
|
|
380
|
+
* @param {Event} event
|
|
381
|
+
*/
|
|
382
|
+
export function onMenuPointerOver(_state: MenuState, event: Event): void {
|
|
383
|
+
const menu = menuOf(event);
|
|
384
|
+
if (!menu || !(event.target instanceof Element)) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const row = event.target.closest<RowElement>(ROW);
|
|
388
|
+
if (!row || row.closest(MENU) !== menu) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const sub = submenuOf(row);
|
|
392
|
+
closeSubmenus(menu, row);
|
|
393
|
+
if (sub && !isShowing(sub) && row.getAttribute("aria-disabled") !== "true") {
|
|
394
|
+
openSubmenu(row);
|
|
395
|
+
}
|
|
396
|
+
}
|
package/src/documents.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kit's element documents, as bundled JSON. This is the whole authored surface of the kit:
|
|
3
|
+
* every element is one of these files, interpreted by the runtime, and the canvas edits the same
|
|
4
|
+
* bytes the shell runs.
|
|
5
|
+
*/
|
|
6
|
+
import type { JxDocument } from "@jxsuite/schema/types";
|
|
7
|
+
import jxActionButton from "../components/jx-action-button.json";
|
|
8
|
+
import jxButton from "../components/jx-button.json";
|
|
9
|
+
import jxIcon from "../components/jx-icon.json";
|
|
10
|
+
import jxMenu from "../components/jx-menu.json";
|
|
11
|
+
import jxMenuItem from "../components/jx-menu-item.json";
|
|
12
|
+
|
|
13
|
+
/** Tag name → document, in registration order (a document's `$elements` come before it). */
|
|
14
|
+
export const documents: Readonly<Record<string, JxDocument>> = {
|
|
15
|
+
"jx-icon": jxIcon as unknown as JxDocument,
|
|
16
|
+
"jx-button": jxButton as unknown as JxDocument,
|
|
17
|
+
"jx-action-button": jxActionButton as unknown as JxDocument,
|
|
18
|
+
"jx-menu-item": jxMenuItem as unknown as JxDocument,
|
|
19
|
+
"jx-menu": jxMenu as unknown as JxDocument,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Every tag the kit defines. */
|
|
23
|
+
export const KIT_TAGS: readonly string[] = Object.keys(documents);
|