@pauloricca/spagbol 0.0.0-stage → 0.1.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.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +330 -2
  3. package/dist/controls.css +122 -0
  4. package/dist/core/controller.d.ts +89 -0
  5. package/dist/core/controller.d.ts.map +1 -0
  6. package/dist/core/controller.js +732 -0
  7. package/dist/core/controller.js.map +1 -0
  8. package/dist/core/graph.d.ts +30 -0
  9. package/dist/core/graph.d.ts.map +1 -0
  10. package/dist/core/graph.js +318 -0
  11. package/dist/core/graph.js.map +1 -0
  12. package/dist/core/grouping.d.ts +10 -0
  13. package/dist/core/grouping.d.ts.map +1 -0
  14. package/dist/core/grouping.js +130 -0
  15. package/dist/core/grouping.js.map +1 -0
  16. package/dist/core/groups.d.ts +40 -0
  17. package/dist/core/groups.d.ts.map +1 -0
  18. package/dist/core/groups.js +164 -0
  19. package/dist/core/groups.js.map +1 -0
  20. package/dist/core/index.d.ts +8 -0
  21. package/dist/core/index.d.ts.map +1 -0
  22. package/dist/core/index.js +8 -0
  23. package/dist/core/index.js.map +1 -0
  24. package/dist/core/model.d.ts +182 -0
  25. package/dist/core/model.d.ts.map +1 -0
  26. package/dist/core/model.js +149 -0
  27. package/dist/core/model.js.map +1 -0
  28. package/dist/core/observations.d.ts +10 -0
  29. package/dist/core/observations.d.ts.map +1 -0
  30. package/dist/core/observations.js +36 -0
  31. package/dist/core/observations.js.map +1 -0
  32. package/dist/core/serialization.d.ts +9 -0
  33. package/dist/core/serialization.d.ts.map +1 -0
  34. package/dist/core/serialization.js +148 -0
  35. package/dist/core/serialization.js.map +1 -0
  36. package/dist/index.d.ts +4 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +4 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/link-controls.css +123 -0
  41. package/dist/react/Controls.d.ts +18 -0
  42. package/dist/react/Controls.d.ts.map +1 -0
  43. package/dist/react/Controls.js +301 -0
  44. package/dist/react/Controls.js.map +1 -0
  45. package/dist/react/SpagbolCanvas.d.ts +38 -0
  46. package/dist/react/SpagbolCanvas.d.ts.map +1 -0
  47. package/dist/react/SpagbolCanvas.js +1210 -0
  48. package/dist/react/SpagbolCanvas.js.map +1 -0
  49. package/dist/react/SpagbolCanvasControls.d.ts +36 -0
  50. package/dist/react/SpagbolCanvasControls.d.ts.map +1 -0
  51. package/dist/react/SpagbolCanvasControls.js +113 -0
  52. package/dist/react/SpagbolCanvasControls.js.map +1 -0
  53. package/dist/react/SpagbolCanvasFlow.d.ts +14 -0
  54. package/dist/react/SpagbolCanvasFlow.d.ts.map +1 -0
  55. package/dist/react/SpagbolCanvasFlow.js +6 -0
  56. package/dist/react/SpagbolCanvasFlow.js.map +1 -0
  57. package/dist/react/SpagbolCanvasLinkControls.d.ts +16 -0
  58. package/dist/react/SpagbolCanvasLinkControls.d.ts.map +1 -0
  59. package/dist/react/SpagbolCanvasLinkControls.js +7 -0
  60. package/dist/react/SpagbolCanvasLinkControls.js.map +1 -0
  61. package/dist/react/SpagbolCanvasMenu.d.ts +12 -0
  62. package/dist/react/SpagbolCanvasMenu.d.ts.map +1 -0
  63. package/dist/react/SpagbolCanvasMenu.js +65 -0
  64. package/dist/react/SpagbolCanvasMenu.js.map +1 -0
  65. package/dist/react/SpagbolControlNumber.d.ts +25 -0
  66. package/dist/react/SpagbolControlNumber.d.ts.map +1 -0
  67. package/dist/react/SpagbolControlNumber.js +209 -0
  68. package/dist/react/SpagbolControlNumber.js.map +1 -0
  69. package/dist/react/SpagbolControlNumberLink.d.ts +6 -0
  70. package/dist/react/SpagbolControlNumberLink.d.ts.map +1 -0
  71. package/dist/react/SpagbolControlNumberLink.js +159 -0
  72. package/dist/react/SpagbolControlNumberLink.js.map +1 -0
  73. package/dist/react/SpagbolControlZoom.d.ts +11 -0
  74. package/dist/react/SpagbolControlZoom.d.ts.map +1 -0
  75. package/dist/react/SpagbolControlZoom.js +24 -0
  76. package/dist/react/SpagbolControlZoom.js.map +1 -0
  77. package/dist/react/SpagbolNodeFrame.d.ts +35 -0
  78. package/dist/react/SpagbolNodeFrame.d.ts.map +1 -0
  79. package/dist/react/SpagbolNodeFrame.js +169 -0
  80. package/dist/react/SpagbolNodeFrame.js.map +1 -0
  81. package/dist/react/SpagbolNodeFrameTypePicker.d.ts +9 -0
  82. package/dist/react/SpagbolNodeFrameTypePicker.d.ts.map +1 -0
  83. package/dist/react/SpagbolNodeFrameTypePicker.js +68 -0
  84. package/dist/react/SpagbolNodeFrameTypePicker.js.map +1 -0
  85. package/dist/react/SpagbolNodeGroupPort.d.ts +15 -0
  86. package/dist/react/SpagbolNodeGroupPort.d.ts.map +1 -0
  87. package/dist/react/SpagbolNodeGroupPort.js +62 -0
  88. package/dist/react/SpagbolNodeGroupPort.js.map +1 -0
  89. package/dist/react/SpagbolNodeGroupPortAdd.d.ts +5 -0
  90. package/dist/react/SpagbolNodeGroupPortAdd.d.ts.map +1 -0
  91. package/dist/react/SpagbolNodeGroupPortAdd.js +27 -0
  92. package/dist/react/SpagbolNodeGroupPortAdd.js.map +1 -0
  93. package/dist/react/context.d.ts +84 -0
  94. package/dist/react/context.d.ts.map +1 -0
  95. package/dist/react/context.js +43 -0
  96. package/dist/react/context.js.map +1 -0
  97. package/dist/react/duplicateDrag.d.ts +19 -0
  98. package/dist/react/duplicateDrag.d.ts.map +1 -0
  99. package/dist/react/duplicateDrag.js +71 -0
  100. package/dist/react/duplicateDrag.js.map +1 -0
  101. package/dist/react/flowPresentation.d.ts +11 -0
  102. package/dist/react/flowPresentation.d.ts.map +1 -0
  103. package/dist/react/flowPresentation.js +50 -0
  104. package/dist/react/flowPresentation.js.map +1 -0
  105. package/dist/react/geometry.d.ts +13 -0
  106. package/dist/react/geometry.d.ts.map +1 -0
  107. package/dist/react/geometry.js +43 -0
  108. package/dist/react/geometry.js.map +1 -0
  109. package/dist/react/index.d.ts +11 -0
  110. package/dist/react/index.d.ts.map +1 -0
  111. package/dist/react/index.js +11 -0
  112. package/dist/react/index.js.map +1 -0
  113. package/dist/react/portState.d.ts +10 -0
  114. package/dist/react/portState.d.ts.map +1 -0
  115. package/dist/react/portState.js +35 -0
  116. package/dist/react/portState.js.map +1 -0
  117. package/dist/react/viewport.d.ts +24 -0
  118. package/dist/react/viewport.d.ts.map +1 -0
  119. package/dist/react/viewport.js +55 -0
  120. package/dist/react/viewport.js.map +1 -0
  121. package/dist/styles.css +748 -0
  122. package/package.json +58 -4
  123. package/src/core/controller.ts +844 -0
  124. package/src/core/graph.ts +451 -0
  125. package/src/core/grouping.ts +159 -0
  126. package/src/core/groups.ts +125 -0
  127. package/src/core/index.ts +7 -0
  128. package/src/core/model.ts +304 -0
  129. package/src/core/observations.ts +31 -0
  130. package/src/core/serialization.ts +184 -0
  131. package/src/index.ts +9 -0
  132. package/src/react/Controls.tsx +355 -0
  133. package/src/react/SpagbolCanvas.tsx +1859 -0
  134. package/src/react/SpagbolCanvasControls.tsx +255 -0
  135. package/src/react/SpagbolCanvasFlow.tsx +38 -0
  136. package/src/react/SpagbolCanvasLinkControls.tsx +21 -0
  137. package/src/react/SpagbolCanvasMenu.tsx +121 -0
  138. package/src/react/SpagbolControlNumber.tsx +297 -0
  139. package/src/react/SpagbolControlNumberLink.tsx +209 -0
  140. package/src/react/SpagbolControlZoom.tsx +102 -0
  141. package/src/react/SpagbolNodeFrame.tsx +329 -0
  142. package/src/react/SpagbolNodeFrameTypePicker.tsx +51 -0
  143. package/src/react/SpagbolNodeGroupPort.tsx +111 -0
  144. package/src/react/SpagbolNodeGroupPortAdd.tsx +48 -0
  145. package/src/react/context.tsx +146 -0
  146. package/src/react/controls.css +122 -0
  147. package/src/react/duplicateDrag.ts +97 -0
  148. package/src/react/flowPresentation.ts +56 -0
  149. package/src/react/geometry.ts +53 -0
  150. package/src/react/index.ts +61 -0
  151. package/src/react/link-controls.css +123 -0
  152. package/src/react/portState.ts +24 -0
  153. package/src/react/styles.css +748 -0
  154. package/src/react/viewport.ts +90 -0
@@ -0,0 +1,255 @@
1
+ import { useReactFlow } from "@xyflow/react";
2
+ import { isGroupBoundary } from "../core/index.js";
3
+ import { useRef, useState, type ReactNode } from "react";
4
+ import { SpagbolControlZoom } from "./SpagbolControlZoom.js";
5
+ import { SpagbolCanvasMenu } from "./SpagbolCanvasMenu.js";
6
+ import { useEditorSnapshot, useSpagbol } from "./context.js";
7
+
8
+ export interface SpagbolCanvasAction {
9
+ id: string;
10
+ label: ReactNode;
11
+ ariaLabel?: string;
12
+ title?: string;
13
+ disabled?: boolean;
14
+ checked?: boolean;
15
+ onSelect(): void | Promise<void>;
16
+ }
17
+ export interface SpagbolCanvasMenuSeparator {
18
+ id: string;
19
+ separator: true;
20
+ }
21
+ export type SpagbolCanvasMenuItem =
22
+ | SpagbolCanvasAction
23
+ | SpagbolCanvasMenuSeparator;
24
+ export interface SpagbolCanvasControlsOptions {
25
+ /** Host actions beside the menu; history buttons follow these by default. */
26
+ buttons?: readonly SpagbolCanvasAction[];
27
+ menuItems?: readonly SpagbolCanvasMenuItem[];
28
+ menuLabel?: string;
29
+ leading?: ReactNode;
30
+ zoomExtra?: ReactNode;
31
+ showZoom?: boolean;
32
+ showHistory?: boolean;
33
+ showLayoutLock?: boolean;
34
+ showBreadcrumbs?: boolean;
35
+ onActionError?(error: unknown, action: SpagbolCanvasAction): void;
36
+ }
37
+ const labelFor = (action: SpagbolCanvasAction) =>
38
+ action.ariaLabel ??
39
+ (typeof action.label === "string" ? action.label : action.id);
40
+
41
+ /** Standard zoom stack and percentage/reset button, independent of app actions. */
42
+ export function SpagbolCanvasControlsZoom({ extra }: { extra?: ReactNode }) {
43
+ const { zoom } = useSpagbol();
44
+ return (
45
+ <SpagbolControlZoom
46
+ className="spagbol-canvas-controls-zoom"
47
+ zoom={zoom}
48
+ extra={extra}
49
+ layout="corner"
50
+ />
51
+ );
52
+ }
53
+
54
+ /** Teia/visual-visual corner menu. Domain actions stay in the consumer. */
55
+ export function SpagbolCanvasActions(options: SpagbolCanvasControlsOptions) {
56
+ const flow = useReactFlow();
57
+ const scale = (factor: number) => editor.scaleSelection(factor, new Map(flow.getNodes().filter(node => node.id.startsWith("node:")).map(node => [node.id.slice(5), { width: node.measured?.width ?? node.width ?? 180, height: node.measured?.height ?? node.height ?? 100 }])));
58
+ const { editor } = useSpagbol();
59
+ const snapshot = useEditorSnapshot(editor);
60
+ const [busy, setBusy] = useState<ReadonlySet<string>>(new Set());
61
+ const busyRef = useRef(new Set<string>());
62
+ const [error, setError] = useState<string | null>(null);
63
+ const buttons: SpagbolCanvasAction[] = [
64
+ ...(snapshot.path.length
65
+ ? [
66
+ {
67
+ id: "spagbol:exit",
68
+ label: "EXIT",
69
+ ariaLabel: "Exit nested graph",
70
+ onSelect: () => editor.exit(),
71
+ },
72
+ ]
73
+ : []),
74
+ ...(options.buttons ?? []),
75
+ ...(options.showHistory === false
76
+ ? []
77
+ : [
78
+ {
79
+ id: "spagbol:undo",
80
+ label: "UNDO",
81
+ ariaLabel: "Undo",
82
+ title: "Undo (Cmd/Ctrl+Z)",
83
+ disabled: !snapshot.canUndo || !editor.editing,
84
+ onSelect: () => editor.undo(),
85
+ },
86
+ {
87
+ id: "spagbol:redo",
88
+ label: "REDO",
89
+ ariaLabel: "Redo",
90
+ title: "Redo (Cmd/Ctrl+Shift+Z)",
91
+ disabled: !snapshot.canRedo || !editor.editing,
92
+ onSelect: () => editor.redo(),
93
+ },
94
+ ]),
95
+ ];
96
+ const members = editor.graph.nodes.filter(node => snapshot.selection.nodeIds.includes(node.id) && !isGroupBoundary(node));
97
+ const exposed = editor.graph.data?.exposedNodeIds;
98
+ const exposureChecked = members.length > 0 && Array.isArray(exposed) && members.every(node => exposed.includes(node.id));
99
+ const items: SpagbolCanvasMenuItem[] = [
100
+ { id: 'spagbol:group', label: 'GROUP TO SUBPATCH', ariaLabel: 'Group to subpatch', disabled: !editor.editing || !members.length, onSelect: () => { editor.groupSelection(); } },
101
+
102
+ { id: 'spagbol:scale-up', label: 'SCALE UP SELECTION', disabled: !editor.editing || !snapshot.selection.nodeIds.length, onSelect: () => scale(2) },
103
+ { id: 'spagbol:scale-down', label: 'SCALE DOWN SELECTION', disabled: !editor.editing || !snapshot.selection.nodeIds.length, onSelect: () => scale(0.5) },
104
+ ...(options.menuItems ?? []),
105
+ ...(options.showLayoutLock === false
106
+ ? []
107
+ : [
108
+ {
109
+ id: "spagbol:lock",
110
+ label: "LOCK UI",
111
+ ariaLabel: snapshot.mode === "edit" ? "Lock UI" : "Unlock UI",
112
+ checked: snapshot.mode !== "edit",
113
+ disabled: snapshot.mode === "readonly",
114
+ onSelect: () =>
115
+ editor.setMode(editor.editing ? "controls" : "edit"),
116
+ },
117
+ ]),
118
+ ];
119
+ const hasMenu = items.some((item) => !("separator" in item));
120
+ async function invoke(action: SpagbolCanvasAction, fromMenu: boolean) {
121
+ if (action.disabled || busyRef.current.has(action.id)) return;
122
+ busyRef.current.add(action.id);
123
+ setBusy(new Set(busyRef.current));
124
+ setError(null);
125
+ try {
126
+ await action.onSelect();
127
+ } catch (cause) {
128
+ setError(cause instanceof Error ? cause.message : String(cause));
129
+ options.onActionError?.(cause, action);
130
+ } finally {
131
+ busyRef.current.delete(action.id);
132
+ setBusy(new Set(busyRef.current));
133
+ }
134
+ }
135
+ function actionButton(action: SpagbolCanvasAction, fromMenu = false) {
136
+ return (
137
+ <button
138
+ key={action.id}
139
+ type="button"
140
+ role={
141
+ fromMenu
142
+ ? action.checked === undefined
143
+ ? "menuitem"
144
+ : "menuitemcheckbox"
145
+ : undefined
146
+ }
147
+ aria-label={labelFor(action)}
148
+ title={action.title ?? labelFor(action)}
149
+ disabled={action.disabled || busy.has(action.id)}
150
+ aria-busy={busy.has(action.id) || undefined}
151
+ aria-checked={fromMenu ? action.checked : undefined}
152
+ aria-pressed={fromMenu ? undefined : action.checked}
153
+ onClick={() => {
154
+ void invoke(action, fromMenu);
155
+ }}
156
+ >
157
+ {action.checked !== undefined && fromMenu ? (
158
+ <span className="spagbol-canvas-menu-check" aria-hidden="true">
159
+ {action.checked ? "✓" : ""}
160
+ </span>
161
+ ) : null}
162
+ {action.label}
163
+ </button>
164
+ );
165
+ }
166
+ return (
167
+ <div
168
+ className="spagbol-canvas-actions nodrag nopan"
169
+ role="group"
170
+ aria-label="Canvas actions"
171
+ onPointerDown={(event) => event.stopPropagation()}
172
+ onDoubleClick={(event) => event.stopPropagation()}
173
+ onKeyDown={(event) => event.stopPropagation()}
174
+ >
175
+ {options.leading && (
176
+ <div className="spagbol-canvas-actions-leading">{options.leading}</div>
177
+ )}
178
+ {buttons.map((action) => actionButton(action))}
179
+ {hasMenu && (
180
+ <SpagbolCanvasMenu
181
+ label={
182
+ <>
183
+ {options.menuLabel ?? "MORE"} <span aria-hidden="true">ˆ</span>
184
+ </>
185
+ }
186
+ >
187
+ {items.map((item) =>
188
+ "separator" in item ? (
189
+ <div
190
+ key={item.id}
191
+ role="separator"
192
+ className="spagbol-canvas-menu-separator"
193
+ />
194
+ ) : (
195
+ actionButton(item, true)
196
+ ),
197
+ )}
198
+ </SpagbolCanvasMenu>
199
+ )}
200
+ {error && (
201
+ <div className="spagbol-canvas-actions-error" role="alert">
202
+ {error}
203
+ <button
204
+ type="button"
205
+ aria-label="Dismiss action error"
206
+ onClick={() => setError(null)}
207
+ >
208
+ ×
209
+ </button>
210
+ </div>
211
+ )}
212
+ </div>
213
+ );
214
+ }
215
+
216
+ export function SpagbolCanvasControls(options: SpagbolCanvasControlsOptions) {
217
+ const { editor } = useSpagbol();
218
+ const snapshot = useEditorSnapshot(editor);
219
+ return (
220
+ <>
221
+ {options.showBreadcrumbs !== false && snapshot.path.length > 0 && (
222
+ <nav
223
+ className="spagbol-canvas-breadcrumbs nodrag nopan"
224
+ aria-label="Graph navigation"
225
+ onPointerDown={(event) => event.stopPropagation()}
226
+ onKeyDown={(event) => event.stopPropagation()}
227
+ >
228
+ <button type="button" onClick={() => editor.navigate([])}>
229
+ ROOT
230
+ </button>
231
+ {snapshot.path.map((id, index) => (
232
+ <button
233
+ key={`${index}:${id}`}
234
+ type="button"
235
+ aria-current={
236
+ index === snapshot.path.length - 1 ? "page" : undefined
237
+ }
238
+ onClick={() => editor.navigate(snapshot.path.slice(0, index + 1))}
239
+ >
240
+ {id}
241
+ </button>
242
+ ))}
243
+ </nav>
244
+ )}
245
+ <div
246
+ className={`spagbol-canvas-controls${options.showZoom === false ? " spagbol-canvas-controls-no-zoom" : ""}`}
247
+ >
248
+ {options.showZoom !== false && (
249
+ <SpagbolCanvasControlsZoom extra={options.zoomExtra} />
250
+ )}
251
+ <SpagbolCanvasActions {...options} />
252
+ </div>
253
+ </>
254
+ );
255
+ }
@@ -0,0 +1,38 @@
1
+ import {
2
+ Background,
3
+ ReactFlow,
4
+ type Node,
5
+ type Edge,
6
+ type ReactFlowProps,
7
+ } from "@xyflow/react";
8
+
9
+ /** Controlled integration boundary for hosts with an existing graph controller.
10
+ * Node/edge payloads and all edit callbacks belong to the host. Use inside a
11
+ * ReactFlowProvider so specialised host nodes retain access to the flow store.
12
+ */
13
+ export type SpagbolCanvasFlowProps<
14
+ N extends Node = Node,
15
+ E extends Edge = Edge,
16
+ > = ReactFlowProps<N, E> & {
17
+ grid?: false | { color?: string; gap?: number; size?: number };
18
+ };
19
+ export function SpagbolCanvasFlow<
20
+ N extends Node = Node,
21
+ E extends Edge = Edge,
22
+ >({ grid, children, className, ...props }: SpagbolCanvasFlowProps<N, E>) {
23
+ return (
24
+ <ReactFlow<N, E>
25
+ {...props}
26
+ className={`spagbol-canvas-flow ${className ?? ""}`}
27
+ >
28
+ {children}
29
+ {grid !== false && (
30
+ <Background
31
+ color={grid?.color ?? "var(--spagbol-grid, rgba(255,255,255,0.08))"}
32
+ gap={grid?.gap ?? 28}
33
+ size={grid?.size ?? 1}
34
+ />
35
+ )}
36
+ </ReactFlow>
37
+ );
38
+ }
@@ -0,0 +1,21 @@
1
+ import type { CSSProperties } from 'react';
2
+ import { standardLinkModes } from '../core/index.js';
3
+ import { SpagbolControlNumberLink } from './SpagbolControlNumberLink.js';
4
+ export interface SpagbolCanvasLinkControlsProps {
5
+ weight: number; mode: string; enabled: boolean; style?: CSSProperties;
6
+ modes?: readonly { id: string; label: string }[];
7
+ onWeightChange(value: number): void;
8
+ onModeChange(mode: string): void;
9
+ onEnabledChange(enabled: boolean): void;
10
+ }
11
+ export function SpagbolCanvasLinkControls({ weight, mode, enabled, style, modes = standardLinkModes, onWeightChange, onModeChange, onEnabledChange }: SpagbolCanvasLinkControlsProps) {
12
+ return <div className="spagbol-canvas-link-controls nodrag nopan" style={style} onPointerDown={event => event.stopPropagation()} onDoubleClick={event => event.stopPropagation()}>
13
+ <div className="spagbol-canvas-link-controls-value-row">
14
+ <SpagbolControlNumberLink value={weight} onChange={onWeightChange} />
15
+ <button className={`spagbol-canvas-link-controls-enabled nodrag nopan${enabled ? ' spagbol-canvas-link-controls-enabled-active' : ''}`} type="button" onClick={event => { event.stopPropagation(); onEnabledChange(!enabled); }} onPointerDown={event => event.stopPropagation()} onMouseDown={event => event.stopPropagation()} onDoubleClick={event => event.stopPropagation()} aria-label={enabled ? 'Disable link' : 'Enable link'} aria-pressed={enabled} title={enabled ? 'Disable link' : 'Enable link'} />
16
+ </div>
17
+ <select className="spagbol-canvas-link-controls-mode nodrag nopan" value={mode} onChange={event => { onModeChange(event.target.value); event.currentTarget.blur(); }} onPointerDown={event => event.stopPropagation()} onMouseDown={event => event.stopPropagation()} onDoubleClick={event => event.stopPropagation()} aria-label="Link mode" title="Link mode">
18
+ {modes.map(item => <option key={item.id} value={item.id}>{item.label}</option>)}
19
+ </select>
20
+ </div>;
21
+ }
@@ -0,0 +1,121 @@
1
+ import { useEffect, useId, useRef, useState, type ReactNode } from "react";
2
+
3
+ export interface SpagbolCanvasMenuProps {
4
+ children: ReactNode;
5
+ label?: ReactNode;
6
+ ariaLabel?: string;
7
+ className?: string;
8
+ menuClassName?: string;
9
+ triggerClassName?: string;
10
+ }
11
+ /** Controller-independent menu mechanics; the host owns actions and styling. */
12
+ export function SpagbolCanvasMenu({
13
+ children,
14
+ label = "MORE ˆ",
15
+ ariaLabel = "More actions",
16
+ className,
17
+ menuClassName,
18
+ triggerClassName,
19
+ }: SpagbolCanvasMenuProps) {
20
+ const [open, setOpen] = useState(false);
21
+ const wrapper = useRef<HTMLDivElement>(null);
22
+ const trigger = useRef<HTMLButtonElement>(null);
23
+ const menu = useRef<HTMLDivElement>(null);
24
+ const id = useId();
25
+ function close(restoreFocus = false) {
26
+ if (restoreFocus) trigger.current?.focus();
27
+ setOpen(false);
28
+ }
29
+ useEffect(() => {
30
+ if (!open) return;
31
+ const outside = (event: PointerEvent) => {
32
+ if (
33
+ event.target instanceof Node &&
34
+ !wrapper.current?.contains(event.target)
35
+ )
36
+ setOpen(false);
37
+ };
38
+ document.addEventListener("pointerdown", outside, true);
39
+ return () => document.removeEventListener("pointerdown", outside, true);
40
+ }, [open]);
41
+ return (
42
+ <div
43
+ ref={wrapper}
44
+ className={`spagbol-canvas-menu ${className ?? ""}`}
45
+ data-open={open}
46
+ onMouseEnter={() => setOpen(true)}
47
+ onMouseLeave={() => setOpen(false)}
48
+ onBlur={(event) => {
49
+ if (!event.currentTarget.contains(event.relatedTarget)) close();
50
+ }}
51
+ onKeyDown={(event) => {
52
+ event.stopPropagation();
53
+ if (event.key === "Escape") {
54
+ event.preventDefault();
55
+ close(true);
56
+ }
57
+ if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
58
+ event.preventDefault();
59
+ setOpen(true);
60
+ const key = event.key;
61
+ requestAnimationFrame(() => {
62
+ const buttons = Array.from(
63
+ menu.current?.querySelectorAll<HTMLButtonElement>(
64
+ "button:not(:disabled)",
65
+ ) ?? [],
66
+ );
67
+ const current = buttons.indexOf(
68
+ document.activeElement as HTMLButtonElement,
69
+ );
70
+ const index =
71
+ key === "Home"
72
+ ? 0
73
+ : key === "End"
74
+ ? buttons.length - 1
75
+ : current < 0
76
+ ? key === "ArrowDown"
77
+ ? 0
78
+ : buttons.length - 1
79
+ : (current +
80
+ (key === "ArrowDown" ? 1 : -1) +
81
+ buttons.length) %
82
+ buttons.length;
83
+ buttons[index]?.focus();
84
+ });
85
+ }
86
+ }}
87
+ >
88
+ <button
89
+ ref={trigger}
90
+ className={`spagbol-canvas-menu-trigger ${triggerClassName ?? ""}`}
91
+ type="button"
92
+ aria-haspopup="menu"
93
+ aria-controls={id}
94
+ aria-expanded={open}
95
+ aria-label={ariaLabel}
96
+ title={ariaLabel}
97
+ onFocus={() => setOpen(true)}
98
+ onClick={() => setOpen(true)}
99
+ >
100
+ {label}
101
+ </button>
102
+ <div
103
+ ref={menu}
104
+ id={id}
105
+ className={`spagbol-canvas-menu-items ${menuClassName ?? ""}`}
106
+ role="menu"
107
+ aria-label={ariaLabel}
108
+ hidden={!open}
109
+ onClick={(event) => {
110
+ const button =
111
+ event.target instanceof Element
112
+ ? event.target.closest("button")
113
+ : null;
114
+ if (button && !button.disabled) close(true);
115
+ }}
116
+ >
117
+ {children}
118
+ </div>
119
+ </div>
120
+ );
121
+ }