@matchstick-trading/primitives 0.0.10
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/AGENTS.md +44 -0
- package/CHANGELOG.md +19 -0
- package/LICENSE +201 -0
- package/README.md +636 -0
- package/dist/Chart.d.ts +18 -0
- package/dist/Chart.js +6 -0
- package/dist/ChartParts.d.ts +151 -0
- package/dist/ChartParts.js +389 -0
- package/dist/CsvDrop.d.ts +8 -0
- package/dist/CsvDrop.js +5 -0
- package/dist/CsvDropParts.d.ts +33 -0
- package/dist/CsvDropParts.js +77 -0
- package/dist/Cursor.d.ts +6 -0
- package/dist/Cursor.js +5 -0
- package/dist/CursorParts.d.ts +51 -0
- package/dist/CursorParts.js +40 -0
- package/dist/DepthProfile.d.ts +13 -0
- package/dist/DepthProfile.js +5 -0
- package/dist/DepthProfileParts.d.ts +45 -0
- package/dist/DepthProfileParts.js +96 -0
- package/dist/DiffTable.d.ts +13 -0
- package/dist/DiffTable.js +5 -0
- package/dist/DiffTableParts.d.ts +57 -0
- package/dist/DiffTableParts.js +103 -0
- package/dist/Drawings.d.ts +13 -0
- package/dist/Drawings.js +5 -0
- package/dist/DrawingsParts.d.ts +194 -0
- package/dist/DrawingsParts.js +775 -0
- package/dist/Fullscreen.d.ts +7 -0
- package/dist/Fullscreen.js +5 -0
- package/dist/FullscreenParts.d.ts +54 -0
- package/dist/FullscreenParts.js +250 -0
- package/dist/Ladder.d.ts +13 -0
- package/dist/Ladder.js +5 -0
- package/dist/LadderParts.d.ts +49 -0
- package/dist/LadderParts.js +56 -0
- package/dist/Menu.d.ts +11 -0
- package/dist/Menu.js +5 -0
- package/dist/MenuParts.d.ts +52 -0
- package/dist/MenuParts.js +202 -0
- package/dist/Notice.d.ts +18 -0
- package/dist/Notice.js +9 -0
- package/dist/Panels.d.ts +9 -0
- package/dist/Panels.js +5 -0
- package/dist/PanelsParts.d.ts +62 -0
- package/dist/PanelsParts.js +387 -0
- package/dist/Receipt.d.ts +11 -0
- package/dist/Receipt.js +5 -0
- package/dist/ReceiptParts.d.ts +45 -0
- package/dist/ReceiptParts.js +71 -0
- package/dist/Scrubber.d.ts +9 -0
- package/dist/Scrubber.js +5 -0
- package/dist/ScrubberParts.d.ts +74 -0
- package/dist/ScrubberParts.js +126 -0
- package/dist/Selection.d.ts +14 -0
- package/dist/Selection.js +7 -0
- package/dist/SelectionParts.d.ts +63 -0
- package/dist/SelectionParts.js +208 -0
- package/dist/Studies.d.ts +9 -0
- package/dist/Studies.js +5 -0
- package/dist/StudiesParts.d.ts +45 -0
- package/dist/StudiesParts.js +153 -0
- package/dist/Submenu.d.ts +7 -0
- package/dist/Submenu.js +4 -0
- package/dist/SymbolSearch.d.ts +10 -0
- package/dist/SymbolSearch.js +5 -0
- package/dist/SymbolSearchParts.d.ts +39 -0
- package/dist/SymbolSearchParts.js +86 -0
- package/dist/Tape.d.ts +15 -0
- package/dist/Tape.js +5 -0
- package/dist/TapeParts.d.ts +69 -0
- package/dist/TapeParts.js +139 -0
- package/dist/Toolbar.d.ts +8 -0
- package/dist/Toolbar.js +5 -0
- package/dist/ToolbarParts.d.ts +61 -0
- package/dist/ToolbarParts.js +122 -0
- package/dist/Transport.d.ts +15 -0
- package/dist/Transport.js +5 -0
- package/dist/TransportParts.d.ts +52 -0
- package/dist/TransportParts.js +47 -0
- package/dist/VolumeProfile.d.ts +7 -0
- package/dist/VolumeProfile.js +5 -0
- package/dist/VolumeProfileParts.d.ts +37 -0
- package/dist/VolumeProfileParts.js +57 -0
- package/dist/accessible-name.d.ts +26 -0
- package/dist/accessible-name.js +14 -0
- package/dist/attributes.d.ts +8 -0
- package/dist/attributes.js +9 -0
- package/dist/core/adapter.d.ts +38 -0
- package/dist/core/adapter.js +64 -0
- package/dist/core/attributes.d.ts +36 -0
- package/dist/core/attributes.js +22 -0
- package/dist/core/candles.d.ts +6 -0
- package/dist/core/candles.js +19 -0
- package/dist/core/chart-gesture.d.ts +109 -0
- package/dist/core/chart-gesture.js +161 -0
- package/dist/core/chart-scale.d.ts +141 -0
- package/dist/core/chart-scale.js +302 -0
- package/dist/core/csv-bars.d.ts +54 -0
- package/dist/core/csv-bars.js +325 -0
- package/dist/core/depth-profile.d.ts +43 -0
- package/dist/core/depth-profile.js +66 -0
- package/dist/core/diff-table.d.ts +57 -0
- package/dist/core/diff-table.js +77 -0
- package/dist/core/drawing-fields.d.ts +11 -0
- package/dist/core/drawing-fields.js +15 -0
- package/dist/core/drawings.d.ts +194 -0
- package/dist/core/drawings.js +403 -0
- package/dist/core/fullscreen.d.ts +32 -0
- package/dist/core/fullscreen.js +16 -0
- package/dist/core/index.d.ts +41 -0
- package/dist/core/index.js +23 -0
- package/dist/core/ladder-keys.d.ts +35 -0
- package/dist/core/ladder-keys.js +43 -0
- package/dist/core/ladder.d.ts +65 -0
- package/dist/core/ladder.js +93 -0
- package/dist/core/panels.d.ts +72 -0
- package/dist/core/panels.js +151 -0
- package/dist/core/receipt-rules.d.ts +42 -0
- package/dist/core/receipt-rules.js +20 -0
- package/dist/core/scrubber-gesture.d.ts +66 -0
- package/dist/core/scrubber-gesture.js +69 -0
- package/dist/core/selection.d.ts +24 -0
- package/dist/core/selection.js +33 -0
- package/dist/core/series.d.ts +161 -0
- package/dist/core/series.js +331 -0
- package/dist/core/studies.d.ts +80 -0
- package/dist/core/studies.js +237 -0
- package/dist/core/symbol-search.d.ts +89 -0
- package/dist/core/symbol-search.js +223 -0
- package/dist/core/tape.d.ts +77 -0
- package/dist/core/tape.js +136 -0
- package/dist/core/volume-profile.d.ts +47 -0
- package/dist/core/volume-profile.js +86 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.js +42 -0
- package/dist/ladder-keys.d.ts +16 -0
- package/dist/ladder-keys.js +86 -0
- package/dist/panels-context.d.ts +47 -0
- package/dist/panels-context.js +3 -0
- package/dist/selection-context.d.ts +41 -0
- package/dist/selection-context.js +4 -0
- package/examples/csv-to-chart-frame.mjs +17 -0
- package/examples/studies.mjs +12 -0
- package/llms.txt +16 -0
- package/package.json +67 -0
- package/src/Chart.tsx +7 -0
- package/src/ChartParts.tsx +529 -0
- package/src/CsvDrop.tsx +6 -0
- package/src/CsvDropParts.tsx +128 -0
- package/src/Cursor.tsx +6 -0
- package/src/CursorParts.tsx +77 -0
- package/src/DepthProfile.tsx +6 -0
- package/src/DepthProfileParts.tsx +154 -0
- package/src/DiffTable.tsx +6 -0
- package/src/DiffTableParts.tsx +160 -0
- package/src/Drawings.tsx +6 -0
- package/src/DrawingsParts.tsx +1080 -0
- package/src/Fullscreen.tsx +6 -0
- package/src/FullscreenParts.tsx +318 -0
- package/src/Ladder.tsx +6 -0
- package/src/LadderParts.tsx +114 -0
- package/src/Menu.tsx +6 -0
- package/src/MenuParts.tsx +364 -0
- package/src/Notice.tsx +27 -0
- package/src/Panels.tsx +6 -0
- package/src/PanelsParts.tsx +454 -0
- package/src/Receipt.tsx +6 -0
- package/src/ReceiptParts.tsx +118 -0
- package/src/Scrubber.tsx +6 -0
- package/src/ScrubberParts.tsx +207 -0
- package/src/Selection.tsx +12 -0
- package/src/SelectionParts.tsx +251 -0
- package/src/Studies.tsx +6 -0
- package/src/StudiesParts.tsx +226 -0
- package/src/Submenu.tsx +5 -0
- package/src/SymbolSearch.tsx +6 -0
- package/src/SymbolSearchParts.tsx +166 -0
- package/src/Tape.tsx +6 -0
- package/src/TapeParts.tsx +208 -0
- package/src/Toolbar.tsx +6 -0
- package/src/ToolbarParts.tsx +179 -0
- package/src/Transport.tsx +6 -0
- package/src/TransportParts.tsx +128 -0
- package/src/VolumeProfile.tsx +6 -0
- package/src/VolumeProfileParts.tsx +106 -0
- package/src/accessible-name.tsx +34 -0
- package/src/attributes.ts +12 -0
- package/src/core/adapter.ts +91 -0
- package/src/core/attributes.ts +51 -0
- package/src/core/candles.ts +22 -0
- package/src/core/chart-gesture.ts +228 -0
- package/src/core/chart-scale.ts +368 -0
- package/src/core/csv-bars.ts +328 -0
- package/src/core/depth-profile.ts +84 -0
- package/src/core/diff-table.ts +105 -0
- package/src/core/drawing-fields.ts +17 -0
- package/src/core/drawings.ts +478 -0
- package/src/core/fullscreen.ts +32 -0
- package/src/core/index.ts +42 -0
- package/src/core/ladder-keys.ts +61 -0
- package/src/core/ladder.ts +156 -0
- package/src/core/panels.ts +177 -0
- package/src/core/receipt-rules.ts +45 -0
- package/src/core/scrubber-gesture.ts +102 -0
- package/src/core/selection.ts +41 -0
- package/src/core/series.ts +374 -0
- package/src/core/studies.ts +260 -0
- package/src/core/symbol-search.ts +256 -0
- package/src/core/tape.ts +159 -0
- package/src/core/volume-profile.ts +116 -0
- package/src/index.ts +83 -0
- package/src/ladder-keys.ts +83 -0
- package/src/panels-context.ts +46 -0
- package/src/selection-context.ts +38 -0
- package/src/tokens.css +235 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MutableRefObject, type ReactNode } from 'react';
|
|
2
|
+
import { buttonFace, type NameProps } from './accessible-name.js';
|
|
3
|
+
import { useNameCheck, useToolbarContext, useToolbarItem } from './ToolbarParts.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Menu and Submenu as parts. A Trigger button opens a List (role="menu") of Items; a Submenu is an item that opens
|
|
7
|
+
* another List. Headless: parts add `data-part`, `data-mt` and `data-state` (open or closed); the host gives them looks.
|
|
8
|
+
*
|
|
9
|
+
* Everything works by tap or click: nothing opens on hover. From the keyboard: Enter, Space or ArrowDown on the trigger
|
|
10
|
+
* opens it (ArrowUp opens on the last item); inside, ArrowUp and ArrowDown move (wrapping), Home and End jump, typing
|
|
11
|
+
* letters jumps to the item that starts with them, Enter or Space picks, Escape closes and puts focus back on the trigger,
|
|
12
|
+
* Tab closes and moves on. ArrowRight on a submenu trigger opens it and ArrowLeft inside it closes it. A click or tap
|
|
13
|
+
* outside closes. Picking an item closes the whole menu and returns focus to its trigger.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface MenuContext {
|
|
17
|
+
open: boolean;
|
|
18
|
+
/** Open and focus the first or last item, or close. */
|
|
19
|
+
show: (focus: 'first' | 'last') => void;
|
|
20
|
+
hide: (returnFocus: boolean) => void;
|
|
21
|
+
/** Close this menu and every menu above it. */
|
|
22
|
+
hideAll: (returnFocus: boolean) => void;
|
|
23
|
+
triggerId: string;
|
|
24
|
+
listId: string;
|
|
25
|
+
triggerRef: MutableRefObject<HTMLButtonElement | null>;
|
|
26
|
+
focusOnOpen: MutableRefObject<'first' | 'last'>;
|
|
27
|
+
submenu: boolean;
|
|
28
|
+
}
|
|
29
|
+
const Context = createContext<MenuContext | null>(null);
|
|
30
|
+
|
|
31
|
+
function useMenu(part: string): MenuContext {
|
|
32
|
+
const context = useContext(Context);
|
|
33
|
+
if (!context) throw new Error(`${part} needs a Menu.Root (or Submenu.Root) above it.`);
|
|
34
|
+
return context;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MenuRootProps {
|
|
38
|
+
className?: string;
|
|
39
|
+
/** Controlled open state. Without it the menu keeps its own. */
|
|
40
|
+
open?: boolean;
|
|
41
|
+
onOpenChange?: (open: boolean) => void;
|
|
42
|
+
children?: ReactNode;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function useMenuState(parent: MenuContext | null, { open, onOpenChange }: Pick<MenuRootProps, 'open' | 'onOpenChange'>) {
|
|
46
|
+
const [inner, setInner] = useState(false);
|
|
47
|
+
const isOpen = open !== undefined ? open : inner;
|
|
48
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
49
|
+
const focusOnOpen = useRef<'first' | 'last'>('first');
|
|
50
|
+
const triggerId = useId();
|
|
51
|
+
const listId = useId();
|
|
52
|
+
const set = useCallback((next: boolean) => {
|
|
53
|
+
if (open === undefined) setInner(next);
|
|
54
|
+
onOpenChange?.(next);
|
|
55
|
+
}, [open, onOpenChange]);
|
|
56
|
+
const show = useCallback((focus: 'first' | 'last') => { focusOnOpen.current = focus; set(true); }, [set]);
|
|
57
|
+
const hide = useCallback((returnFocus: boolean) => {
|
|
58
|
+
set(false);
|
|
59
|
+
if (returnFocus) triggerRef.current?.focus();
|
|
60
|
+
}, [set]);
|
|
61
|
+
const hideAll = useCallback((returnFocus: boolean) => {
|
|
62
|
+
if (parent) parent.hideAll(returnFocus);
|
|
63
|
+
else hide(returnFocus);
|
|
64
|
+
}, [parent, hide]);
|
|
65
|
+
return useMemo<MenuContext>(() => ({ open: isOpen, show, hide, hideAll, triggerId, listId, triggerRef, focusOnOpen, submenu: parent !== null }), [isOpen, show, hide, hideAll, triggerId, listId, parent]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Holds the open state and the wrapper element the List is placed against. */
|
|
69
|
+
function Root({ className, open, onOpenChange, children }: MenuRootProps) {
|
|
70
|
+
const context = useMenuState(null, { open, onOpenChange });
|
|
71
|
+
const element = useRef<HTMLDivElement | null>(null);
|
|
72
|
+
// A press outside the menu, anywhere in the page, closes it.
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!context.open) return;
|
|
75
|
+
const onDown = (event: PointerEvent) => { if (!element.current?.contains(event.target as Node)) context.hide(false); };
|
|
76
|
+
document.addEventListener('pointerdown', onDown);
|
|
77
|
+
return () => document.removeEventListener('pointerdown', onDown);
|
|
78
|
+
}, [context]);
|
|
79
|
+
return (
|
|
80
|
+
<Context.Provider value={context}>
|
|
81
|
+
<div
|
|
82
|
+
ref={element}
|
|
83
|
+
className={className}
|
|
84
|
+
data-mt=""
|
|
85
|
+
data-part="menu"
|
|
86
|
+
data-state={context.open ? 'open' : 'closed'}
|
|
87
|
+
// Focus leaving for another control (Tab, or a script) closes the menu; a null target is the window losing focus.
|
|
88
|
+
onBlur={(event) => { if (context.open && event.relatedTarget && !element.current?.contains(event.relatedTarget as Node)) context.hide(false); }}
|
|
89
|
+
>
|
|
90
|
+
{children}
|
|
91
|
+
</div>
|
|
92
|
+
</Context.Provider>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface MenuTriggerProps extends NameProps {
|
|
97
|
+
className?: string;
|
|
98
|
+
children?: ReactNode;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The button that opens the menu: `aria-haspopup="menu"` and `aria-expanded`. Inside a Toolbar it is one of its items. */
|
|
102
|
+
function Trigger({ className, icon, label, hideLabel, children }: MenuTriggerProps) {
|
|
103
|
+
const menu = useMenu('Menu.Trigger');
|
|
104
|
+
const toolbar = useToolbarContext();
|
|
105
|
+
const item = useToolbarItem(menu.triggerRef);
|
|
106
|
+
const face = buttonFace(undefined, { icon, label, hideLabel, children });
|
|
107
|
+
useNameCheck(menu.triggerRef, 'Menu.Trigger');
|
|
108
|
+
const onKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
|
|
109
|
+
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
|
110
|
+
// In a vertical toolbar Up and Down move between items, so the menu opens to the side instead.
|
|
111
|
+
const vertical = toolbar?.orientation === 'vertical';
|
|
112
|
+
const open = vertical ? event.key === 'ArrowRight' : event.key === 'ArrowDown';
|
|
113
|
+
if (!open && !(!vertical && event.key === 'ArrowUp')) return;
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
event.stopPropagation();
|
|
116
|
+
menu.show(event.key === 'ArrowUp' ? 'last' : 'first');
|
|
117
|
+
};
|
|
118
|
+
return (
|
|
119
|
+
<button
|
|
120
|
+
ref={menu.triggerRef}
|
|
121
|
+
id={menu.triggerId}
|
|
122
|
+
type="button"
|
|
123
|
+
className={className}
|
|
124
|
+
data-mt=""
|
|
125
|
+
data-part="menu-trigger"
|
|
126
|
+
data-touch-target=""
|
|
127
|
+
data-state={menu.open ? 'open' : 'closed'}
|
|
128
|
+
aria-haspopup="menu"
|
|
129
|
+
aria-expanded={menu.open}
|
|
130
|
+
aria-controls={menu.open ? menu.listId : undefined}
|
|
131
|
+
aria-label={face['aria-label']}
|
|
132
|
+
tabIndex={item.tabIndex}
|
|
133
|
+
onFocus={item.onFocus}
|
|
134
|
+
onKeyDown={onKeyDown}
|
|
135
|
+
onClick={() => (menu.open ? menu.hide(false) : menu.show('first'))}
|
|
136
|
+
>
|
|
137
|
+
{face.content}
|
|
138
|
+
</button>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The items of a list: its own menuitems, not those of a submenu inside it. Disabled ones are skipped. */
|
|
143
|
+
function itemsOf(list: HTMLElement): HTMLButtonElement[] {
|
|
144
|
+
return [...list.querySelectorAll<HTMLButtonElement>("[role='menuitem'], [role='menuitemradio']")].filter((el) => el.closest("[role='menu']") === list && !el.disabled);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface MenuListProps {
|
|
148
|
+
className?: string;
|
|
149
|
+
/** Names the list. Default: the trigger's name. */
|
|
150
|
+
'aria-label'?: string;
|
|
151
|
+
children?: ReactNode;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The open list, role="menu". It is not in the page while closed. */
|
|
155
|
+
function List({ className, 'aria-label': label, children }: MenuListProps) {
|
|
156
|
+
const menu = useMenu('Menu.List');
|
|
157
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
158
|
+
const typed = useRef({ text: '', timer: 0 });
|
|
159
|
+
// Opening puts focus on an item. Arrow keys, a tap and Enter all arrive here.
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (!menu.open) return;
|
|
162
|
+
const items = itemsOf(ref.current!);
|
|
163
|
+
(menu.focusOnOpen.current === 'last' ? items[items.length - 1] : items[0])?.focus();
|
|
164
|
+
}, [menu.open, menu.focusOnOpen]);
|
|
165
|
+
useEffect(() => () => window.clearTimeout(typed.current.timer), []);
|
|
166
|
+
if (!menu.open) return null;
|
|
167
|
+
const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
168
|
+
const list = ref.current;
|
|
169
|
+
// Keys from inside a submenu are handled by that submenu's list.
|
|
170
|
+
if (!list || (event.target as HTMLElement).closest("[role='menu']") !== list) return;
|
|
171
|
+
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
|
172
|
+
const items = itemsOf(list);
|
|
173
|
+
const at = items.indexOf(document.activeElement as HTMLButtonElement);
|
|
174
|
+
const go = (index: number) => { event.preventDefault(); event.stopPropagation(); items[index]?.focus(); };
|
|
175
|
+
switch (event.key) {
|
|
176
|
+
case 'ArrowDown': return go(items.length === 0 ? 0 : (at + 1) % items.length);
|
|
177
|
+
case 'ArrowUp': return go(items.length === 0 ? 0 : (at - 1 + items.length) % items.length);
|
|
178
|
+
case 'Home': return go(0);
|
|
179
|
+
case 'End': return go(items.length - 1);
|
|
180
|
+
case 'Escape':
|
|
181
|
+
event.preventDefault();
|
|
182
|
+
event.stopPropagation(); // the toolbar or page behind must not also react
|
|
183
|
+
return menu.hide(true);
|
|
184
|
+
case 'ArrowLeft': case 'ArrowRight': {
|
|
185
|
+
// A submenu closes with the arrow that points back toward its parent: Left in left-to-right text, Right in right-to-left.
|
|
186
|
+
const closing = getComputedStyle(list).direction === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
|
|
187
|
+
if (!menu.submenu || event.key !== closing) return;
|
|
188
|
+
event.preventDefault();
|
|
189
|
+
event.stopPropagation();
|
|
190
|
+
return menu.hide(true);
|
|
191
|
+
}
|
|
192
|
+
case 'Tab':
|
|
193
|
+
return menu.hideAll(true); // focus goes back to the trigger first, so Tab then moves on from there
|
|
194
|
+
default:
|
|
195
|
+
}
|
|
196
|
+
// Typeahead: letters build a prefix (reset after a short pause); one letter repeated steps through items that share it.
|
|
197
|
+
if (event.key.length === 1 && event.key !== ' ') {
|
|
198
|
+
event.preventDefault();
|
|
199
|
+
window.clearTimeout(typed.current.timer);
|
|
200
|
+
typed.current.text += event.key.toLowerCase();
|
|
201
|
+
typed.current.timer = window.setTimeout(() => { typed.current.text = ''; }, 600);
|
|
202
|
+
const word = typed.current.text;
|
|
203
|
+
const name = (el: HTMLElement) => {
|
|
204
|
+
const label = el.getAttribute('aria-label');
|
|
205
|
+
if (label !== null) return label.trim().toLowerCase();
|
|
206
|
+
const copy = el.cloneNode(true) as HTMLElement; // an icon is aria-hidden: its text is not the item's name
|
|
207
|
+
copy.querySelectorAll('[aria-hidden="true"]').forEach((hidden) => hidden.remove());
|
|
208
|
+
return (copy.textContent ?? '').trim().toLowerCase();
|
|
209
|
+
};
|
|
210
|
+
const repeated = word.length > 1 && [...word].every((c) => c === word[0]);
|
|
211
|
+
const prefix = repeated ? word[0] : word;
|
|
212
|
+
const order = [...items.slice(at + 1), ...items.slice(0, at + 1)];
|
|
213
|
+
(order.find((el) => name(el).startsWith(prefix)) ?? order.find((el) => name(el).startsWith(word)))?.focus();
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
return (
|
|
217
|
+
<div
|
|
218
|
+
ref={ref}
|
|
219
|
+
id={menu.listId}
|
|
220
|
+
className={className}
|
|
221
|
+
data-mt=""
|
|
222
|
+
data-part={menu.submenu ? 'submenu-list' : 'menu-list'}
|
|
223
|
+
role="menu"
|
|
224
|
+
aria-labelledby={label ? undefined : menu.triggerId}
|
|
225
|
+
aria-label={label}
|
|
226
|
+
aria-orientation="vertical"
|
|
227
|
+
onKeyDown={onKeyDown}
|
|
228
|
+
>
|
|
229
|
+
{children}
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface MenuItemProps extends NameProps {
|
|
235
|
+
className?: string;
|
|
236
|
+
disabled?: boolean;
|
|
237
|
+
/** Runs when the item is picked, by click, tap, Enter or Space. */
|
|
238
|
+
onSelect?: () => void;
|
|
239
|
+
/** Keep the menu open after picking. Default: picking closes the whole menu. */
|
|
240
|
+
keepOpen?: boolean;
|
|
241
|
+
children?: ReactNode;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** One choice. A menuitem button; the roving focus is handled by the list, so it is not a tab stop. */
|
|
245
|
+
function Item({ className, disabled, onSelect, keepOpen, icon, label, hideLabel, children }: MenuItemProps) {
|
|
246
|
+
const menu = useMenu('Menu.Item');
|
|
247
|
+
const ref = useRef<HTMLButtonElement | null>(null);
|
|
248
|
+
const face = buttonFace(undefined, { icon, label, hideLabel, children });
|
|
249
|
+
useNameCheck(ref, 'Menu.Item');
|
|
250
|
+
return (
|
|
251
|
+
<button
|
|
252
|
+
ref={ref}
|
|
253
|
+
type="button"
|
|
254
|
+
className={className}
|
|
255
|
+
data-mt=""
|
|
256
|
+
data-part="menu-item"
|
|
257
|
+
data-touch-target=""
|
|
258
|
+
role="menuitem"
|
|
259
|
+
tabIndex={-1}
|
|
260
|
+
disabled={disabled}
|
|
261
|
+
aria-label={face['aria-label']}
|
|
262
|
+
onClick={() => { onSelect?.(); if (!keepOpen) menu.hideAll(true); }}
|
|
263
|
+
>
|
|
264
|
+
{face.content}
|
|
265
|
+
</button>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface MenuRadioItemProps extends MenuItemProps {
|
|
270
|
+
/** Whether this is the chosen one of its group. */
|
|
271
|
+
checked: boolean;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** A choice among several: `aria-checked` says which is on. Picking it runs onSelect and closes the menu. */
|
|
275
|
+
function RadioItem({ className, checked, disabled, onSelect, keepOpen, icon, label, hideLabel, children }: MenuRadioItemProps) {
|
|
276
|
+
const menu = useMenu('Menu.RadioItem');
|
|
277
|
+
const ref = useRef<HTMLButtonElement | null>(null);
|
|
278
|
+
const face = buttonFace(undefined, { icon, label, hideLabel, children });
|
|
279
|
+
useNameCheck(ref, 'Menu.RadioItem');
|
|
280
|
+
return (
|
|
281
|
+
<button
|
|
282
|
+
ref={ref}
|
|
283
|
+
type="button"
|
|
284
|
+
className={className}
|
|
285
|
+
data-mt=""
|
|
286
|
+
data-part="menu-radio-item"
|
|
287
|
+
data-touch-target=""
|
|
288
|
+
role="menuitemradio"
|
|
289
|
+
aria-checked={checked}
|
|
290
|
+
tabIndex={-1}
|
|
291
|
+
disabled={disabled}
|
|
292
|
+
aria-label={face['aria-label']}
|
|
293
|
+
onClick={() => { onSelect?.(); if (!keepOpen) menu.hideAll(true); }}
|
|
294
|
+
>
|
|
295
|
+
{face.content}
|
|
296
|
+
</button>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** A thin divider between groups of items. */
|
|
301
|
+
function Separator({ className }: { className?: string }) {
|
|
302
|
+
return <div className={className} data-mt="" data-part="menu-separator" role="separator" />;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** A menu inside a menu: put Submenu.Trigger and Submenu.List inside it, among the items of the parent List. */
|
|
306
|
+
function SubmenuRoot({ className, open, onOpenChange, children }: MenuRootProps) {
|
|
307
|
+
const parent = useMenu('Submenu.Root');
|
|
308
|
+
const context = useMenuState(parent, { open, onOpenChange });
|
|
309
|
+
const element = useRef<HTMLDivElement | null>(null);
|
|
310
|
+
return (
|
|
311
|
+
<Context.Provider value={context}>
|
|
312
|
+
<div
|
|
313
|
+
ref={element}
|
|
314
|
+
className={className}
|
|
315
|
+
data-mt=""
|
|
316
|
+
data-part="submenu"
|
|
317
|
+
data-state={context.open ? 'open' : 'closed'}
|
|
318
|
+
// Focus moving to a sibling item of the parent closes the submenu.
|
|
319
|
+
onBlur={(event) => { if (context.open && event.relatedTarget && !element.current?.contains(event.relatedTarget as Node)) context.hide(false); }}
|
|
320
|
+
>
|
|
321
|
+
{children}
|
|
322
|
+
</div>
|
|
323
|
+
</Context.Provider>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The item that opens a submenu: a menuitem with `aria-haspopup` and `aria-expanded`. ArrowRight (ArrowLeft in right-to-left text) opens it. */
|
|
328
|
+
function SubmenuTrigger({ className, icon, label, hideLabel, children }: MenuTriggerProps) {
|
|
329
|
+
const menu = useMenu('Submenu.Trigger');
|
|
330
|
+
const face = buttonFace(undefined, { icon, label, hideLabel, children });
|
|
331
|
+
useNameCheck(menu.triggerRef, 'Submenu.Trigger');
|
|
332
|
+
const onKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
|
|
333
|
+
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
|
334
|
+
const rtl = getComputedStyle(event.currentTarget).direction === 'rtl';
|
|
335
|
+
if (event.key !== (rtl ? 'ArrowLeft' : 'ArrowRight')) return;
|
|
336
|
+
event.preventDefault();
|
|
337
|
+
event.stopPropagation();
|
|
338
|
+
menu.show('first');
|
|
339
|
+
};
|
|
340
|
+
return (
|
|
341
|
+
<button
|
|
342
|
+
ref={menu.triggerRef}
|
|
343
|
+
id={menu.triggerId}
|
|
344
|
+
type="button"
|
|
345
|
+
className={className}
|
|
346
|
+
data-mt=""
|
|
347
|
+
data-part="submenu-trigger"
|
|
348
|
+
data-touch-target=""
|
|
349
|
+
data-state={menu.open ? 'open' : 'closed'}
|
|
350
|
+
role="menuitem"
|
|
351
|
+
aria-haspopup="menu"
|
|
352
|
+
aria-expanded={menu.open}
|
|
353
|
+
aria-controls={menu.open ? menu.listId : undefined}
|
|
354
|
+
aria-label={face['aria-label']}
|
|
355
|
+
tabIndex={-1}
|
|
356
|
+
onKeyDown={onKeyDown}
|
|
357
|
+
onClick={() => (menu.open ? menu.hide(false) : menu.show('first'))}
|
|
358
|
+
>
|
|
359
|
+
{face.content}
|
|
360
|
+
</button>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export { Root, Trigger, List, Item, RadioItem, Separator, SubmenuRoot, SubmenuTrigger };
|
package/src/Notice.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export type NoticeKind = 'gap' | 'stale' | 'truncated' | 'unsupported' | 'rights' | 'info' | 'ok' | 'error';
|
|
4
|
+
|
|
5
|
+
export interface NoticeProps {
|
|
6
|
+
/** `status` is polite (a normal condition); `alert` is for refusals and errors. */
|
|
7
|
+
role?: 'status' | 'alert';
|
|
8
|
+
kind?: NoticeKind;
|
|
9
|
+
/** Element to render: a paragraph by default, a span for inline text. */
|
|
10
|
+
as?: 'p' | 'span';
|
|
11
|
+
/** Host class, kept so existing styles keep matching. */
|
|
12
|
+
className?: string;
|
|
13
|
+
children?: ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One integrity message. Replaces the repeated `<p role="status">` / `<p role="alert">` paragraphs: the same
|
|
18
|
+
* element, role and class as before, plus `data-mt` (the scope the token styles hang on) and `data-kind` so a host can style by kind.
|
|
19
|
+
*/
|
|
20
|
+
export function Notice({ role = 'status', kind, as = 'p', className, children }: NoticeProps) {
|
|
21
|
+
const Tag = as;
|
|
22
|
+
return (
|
|
23
|
+
<Tag role={role} className={className} data-mt data-kind={kind}>
|
|
24
|
+
{children}
|
|
25
|
+
</Tag>
|
|
26
|
+
);
|
|
27
|
+
}
|
package/src/Panels.tsx
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Root, Layer, List, Move } from './PanelsParts.js';
|
|
2
|
+
|
|
3
|
+
// The parts live in PanelsParts.js (a client module). The namespace object is built here, outside it, so a server
|
|
4
|
+
// component can write `Panels.Root`: a property of an object exported from a client module is not reachable there.
|
|
5
|
+
export * from './PanelsParts.js';
|
|
6
|
+
export const Panels = { Root, Layer, List, Move };
|