@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,6 @@
|
|
|
1
|
+
import { Frame, Toggle } from './FullscreenParts.js';
|
|
2
|
+
|
|
3
|
+
// The parts live in FullscreenParts.js (a client module). The namespace object is built here, outside it, so a server
|
|
4
|
+
// component can write `Fullscreen.Frame`: a property of an object exported from a client module is not reachable there.
|
|
5
|
+
export * from './FullscreenParts.js';
|
|
6
|
+
export const Fullscreen = { Frame, Toggle };
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type RefObject } from 'react';
|
|
2
|
+
import { buttonFace, type NameProps } from './accessible-name.js';
|
|
3
|
+
import { FULLSCREEN_LABEL, FULLSCREEN_MESSAGE, resolveFullscreenEscape } from './core/fullscreen.js';
|
|
4
|
+
import { useNameCheck } from './ToolbarParts.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Full screen as parts: Frame and Toggle. Headless: parts add `data-part` and `data-mt`; the host gives them looks.
|
|
8
|
+
*
|
|
9
|
+
* Frame is a wrapper div: put the chart, its controls and the Toggle inside it. Toggle is a button that expands the Frame to
|
|
10
|
+
* the whole screen. Where the browser has the Fullscreen API (including the `webkit` prefix on iPad Safari) the Frame goes
|
|
11
|
+
* full screen natively. Where it does not (iPhone Safari) or the browser refuses, the Frame fills the page instead:
|
|
12
|
+
* `position: fixed` over everything, the page behind it inert and not scrolling, both put back on exit. So the button
|
|
13
|
+
* always works.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** What a part inside a Frame can read and do. */
|
|
17
|
+
export interface FullscreenState {
|
|
18
|
+
/** True while the Frame is expanded. */
|
|
19
|
+
fullscreen: boolean;
|
|
20
|
+
/** How: the browser's own full screen, or the in-page fallback. null when not expanded. */
|
|
21
|
+
mode: 'native' | 'fallback' | null;
|
|
22
|
+
enter: () => void;
|
|
23
|
+
exit: () => void;
|
|
24
|
+
toggle: () => void;
|
|
25
|
+
}
|
|
26
|
+
const Context = createContext<(FullscreenState & { frame: RefObject<HTMLDivElement | null>; toggleRef: RefObject<HTMLButtonElement | null> }) | null>(null);
|
|
27
|
+
|
|
28
|
+
/** The Frame's state, for a host that changes its layout when expanded (a taller chart). Throws outside a Frame. */
|
|
29
|
+
export function useFullscreen(): FullscreenState {
|
|
30
|
+
const context = useContext(Context);
|
|
31
|
+
if (!context) throw new Error('useFullscreen must be used inside Fullscreen.Frame.');
|
|
32
|
+
const { fullscreen, mode, enter, exit, toggle } = context;
|
|
33
|
+
return { fullscreen, mode, enter, exit, toggle };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type DataAttributes = { [attribute: `data-${string}`]: string | number | boolean | undefined };
|
|
37
|
+
|
|
38
|
+
export interface FullscreenFrameProps extends DataAttributes {
|
|
39
|
+
className?: string;
|
|
40
|
+
style?: CSSProperties;
|
|
41
|
+
/** Controlled: whether the Frame is expanded. Leave it out and the Frame keeps its own state. */
|
|
42
|
+
fullscreen?: boolean;
|
|
43
|
+
/** Called with the new state when the user, the Escape key or the browser's own UI changes it. */
|
|
44
|
+
onFullscreenChange?: (fullscreen: boolean) => void;
|
|
45
|
+
children?: ReactNode;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const NATIVE_EVENTS = ['fullscreenchange', 'webkitfullscreenchange'] as const;
|
|
49
|
+
|
|
50
|
+
type FullscreenElement = HTMLElement & { webkitRequestFullscreen?: () => Promise<void> | void };
|
|
51
|
+
type FullscreenDocument = Document & { webkitFullscreenElement?: Element | null; webkitExitFullscreen?: () => Promise<void> | void };
|
|
52
|
+
|
|
53
|
+
function nativeElement(doc: Document): Element | null {
|
|
54
|
+
const d = doc as FullscreenDocument;
|
|
55
|
+
return d.fullscreenElement ?? d.webkitFullscreenElement ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Ask the browser to take the element full screen. null when it has no such API; else a promise that rejects if refused. */
|
|
59
|
+
function requestNative(element: HTMLElement): Promise<void> | null {
|
|
60
|
+
const el = element as FullscreenElement;
|
|
61
|
+
try {
|
|
62
|
+
if (typeof el.requestFullscreen === 'function') return Promise.resolve(el.requestFullscreen());
|
|
63
|
+
if (typeof el.webkitRequestFullscreen === 'function') return Promise.resolve(el.webkitRequestFullscreen());
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return Promise.reject(error);
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function exitNative(doc: Document): void {
|
|
71
|
+
const d = doc as FullscreenDocument;
|
|
72
|
+
try {
|
|
73
|
+
const done = typeof d.exitFullscreen === 'function' ? d.exitFullscreen() : d.webkitExitFullscreen?.();
|
|
74
|
+
Promise.resolve(done).catch(() => {});
|
|
75
|
+
} catch { /* not in full screen any more */ }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Put `inert` on everything beside the frame and its ancestors, so focus and taps stay in the frame. Returns the undo. */
|
|
79
|
+
function inertPageBehind(frame: HTMLElement): () => void {
|
|
80
|
+
const marked: Element[] = [];
|
|
81
|
+
for (let node: HTMLElement | null = frame; node && node !== document.body; node = node.parentElement) {
|
|
82
|
+
for (const sibling of Array.from(node.parentElement?.children ?? [])) {
|
|
83
|
+
if (sibling === node || sibling.hasAttribute('inert')) continue;
|
|
84
|
+
sibling.setAttribute('inert', '');
|
|
85
|
+
marked.push(sibling);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return () => { for (const el of marked) el.removeAttribute('inert'); };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Stop the page scrolling under the frame; the undo puts the styles and the scroll position back. */
|
|
92
|
+
function lockScroll(): () => void {
|
|
93
|
+
const html = document.documentElement;
|
|
94
|
+
const body = document.body;
|
|
95
|
+
const saved = { html: html.style.overflow, body: body.style.overflow, x: window.scrollX, y: window.scrollY };
|
|
96
|
+
html.style.overflow = 'hidden';
|
|
97
|
+
body.style.overflow = 'hidden';
|
|
98
|
+
return () => {
|
|
99
|
+
html.style.overflow = saved.html;
|
|
100
|
+
body.style.overflow = saved.body;
|
|
101
|
+
window.scrollTo(saved.x, saved.y);
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const HIDDEN: CSSProperties = { position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap', border: 0 };
|
|
106
|
+
|
|
107
|
+
/** The in-page look: over everything, inside the safe areas, painted from tokens (override on the Frame or a parent). */
|
|
108
|
+
const FALLBACK_STYLE: CSSProperties = {
|
|
109
|
+
position: 'fixed',
|
|
110
|
+
inset: 0,
|
|
111
|
+
zIndex: 'var(--mt-fullscreen-z, 2147483000)' as unknown as number,
|
|
112
|
+
boxSizing: 'border-box',
|
|
113
|
+
overflow: 'auto',
|
|
114
|
+
background: 'var(--mt-fullscreen-bg, var(--mt-surface, #12151c))',
|
|
115
|
+
color: 'var(--mt-fullscreen-fg, #e8eaef)',
|
|
116
|
+
paddingTop: 'env(safe-area-inset-top)',
|
|
117
|
+
paddingRight: 'env(safe-area-inset-right)',
|
|
118
|
+
paddingBottom: 'env(safe-area-inset-bottom)',
|
|
119
|
+
paddingLeft: 'env(safe-area-inset-left)',
|
|
120
|
+
};
|
|
121
|
+
/** The browser's own full screen paints black unless the Frame fills it and brings its own background. */
|
|
122
|
+
const NATIVE_STYLE: CSSProperties = {
|
|
123
|
+
boxSizing: 'border-box',
|
|
124
|
+
width: '100%',
|
|
125
|
+
height: '100%',
|
|
126
|
+
overflow: 'auto',
|
|
127
|
+
background: 'var(--mt-fullscreen-bg, var(--mt-surface, #12151c))',
|
|
128
|
+
color: 'var(--mt-fullscreen-fg, #e8eaef)',
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The wrapper. `data-fullscreen` is present while expanded and `data-mode` says how. Escape leaves full screen: see
|
|
133
|
+
* `resolveFullscreenEscape` for the order when the chart has something to clear first.
|
|
134
|
+
*/
|
|
135
|
+
function Frame({ className, style, fullscreen, onFullscreenChange, children, ...rest }: FullscreenFrameProps) {
|
|
136
|
+
const frame = useRef<HTMLDivElement | null>(null);
|
|
137
|
+
const toggleRef = useRef<HTMLButtonElement | null>(null);
|
|
138
|
+
const [inner, setInner] = useState(false);
|
|
139
|
+
const [mode, setMode] = useState<'native' | 'fallback' | null>(null);
|
|
140
|
+
const [message, setMessage] = useState('');
|
|
141
|
+
const expanded = fullscreen ?? inner;
|
|
142
|
+
|
|
143
|
+
// The latest values for handlers that outlive a render (browser events, a promise settling).
|
|
144
|
+
const live = useRef({ expanded, mode, onFullscreenChange, controlled: fullscreen !== undefined });
|
|
145
|
+
live.current = { expanded, mode, onFullscreenChange, controlled: fullscreen !== undefined };
|
|
146
|
+
|
|
147
|
+
const commit = useCallback((next: boolean) => {
|
|
148
|
+
if (live.current.expanded === next) return;
|
|
149
|
+
live.current.expanded = next; // a second call in the same tick sees this one
|
|
150
|
+
if (!live.current.controlled) setInner(next);
|
|
151
|
+
live.current.onFullscreenChange?.(next);
|
|
152
|
+
}, []);
|
|
153
|
+
|
|
154
|
+
const setModeNow = useCallback((next: 'native' | 'fallback' | null) => { live.current.mode = next; setMode(next); }, []);
|
|
155
|
+
|
|
156
|
+
/** Take native full screen if the browser can; else, or if it refuses, fall back to the in-page state. */
|
|
157
|
+
const begin = useCallback(() => {
|
|
158
|
+
const element = frame.current;
|
|
159
|
+
if (!element) return;
|
|
160
|
+
const request = requestNative(element);
|
|
161
|
+
if (!request) { setModeNow('fallback'); return; }
|
|
162
|
+
setModeNow('native');
|
|
163
|
+
request.then(
|
|
164
|
+
() => { if (!live.current.expanded || live.current.mode !== 'native') exitNative(document); }, // collapsed while the request was in flight
|
|
165
|
+
() => { if (live.current.expanded && live.current.mode === 'native') setModeNow('fallback'); },
|
|
166
|
+
);
|
|
167
|
+
}, [setModeNow]);
|
|
168
|
+
|
|
169
|
+
const enter = useCallback(() => {
|
|
170
|
+
if (live.current.expanded) return;
|
|
171
|
+
commit(true);
|
|
172
|
+
begin(); // synchronous, so the browser still sees the click that asked for it
|
|
173
|
+
}, [commit, begin]);
|
|
174
|
+
|
|
175
|
+
const exit = useCallback(() => {
|
|
176
|
+
if (!live.current.expanded) return;
|
|
177
|
+
commit(false);
|
|
178
|
+
if (nativeElement(document) === frame.current) exitNative(document);
|
|
179
|
+
setModeNow(null);
|
|
180
|
+
}, [commit, setModeNow]);
|
|
181
|
+
|
|
182
|
+
const toggle = useCallback(() => { if (live.current.expanded) exit(); else enter(); }, [enter, exit]);
|
|
183
|
+
|
|
184
|
+
// A controlled `fullscreen` that changed from outside: bring the DOM in step. (A user gesture is not available here, so a
|
|
185
|
+
// browser may refuse native full screen; the fallback covers that.)
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (expanded && mode === null) begin();
|
|
188
|
+
if (!expanded && mode !== null) {
|
|
189
|
+
if (nativeElement(document) === frame.current) exitNative(document);
|
|
190
|
+
setModeNow(null);
|
|
191
|
+
}
|
|
192
|
+
}, [expanded, mode, begin, setModeNow]);
|
|
193
|
+
|
|
194
|
+
// The browser's own UI left full screen (swipe, its Esc, a system gesture): follow it.
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
const onChange = () => {
|
|
197
|
+
const active = nativeElement(document) === frame.current;
|
|
198
|
+
if (!active && live.current.mode === 'native' && live.current.expanded) { commit(false); setModeNow(null); }
|
|
199
|
+
if (active && !live.current.expanded) exitNative(document); // entered after the state had already moved on
|
|
200
|
+
};
|
|
201
|
+
for (const name of NATIVE_EVENTS) document.addEventListener(name, onChange);
|
|
202
|
+
return () => { for (const name of NATIVE_EVENTS) document.removeEventListener(name, onChange); };
|
|
203
|
+
}, [commit, setModeNow]);
|
|
204
|
+
|
|
205
|
+
// In-page mode: lock scroll and make the page behind inert. Both undone on exit and on unmount.
|
|
206
|
+
const fallback = expanded && mode === 'fallback';
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
const element = frame.current;
|
|
209
|
+
if (!fallback || !element) return;
|
|
210
|
+
const unlock = lockScroll();
|
|
211
|
+
const uninert = inertPageBehind(element);
|
|
212
|
+
return () => { uninert(); unlock(); };
|
|
213
|
+
}, [fallback]);
|
|
214
|
+
|
|
215
|
+
// Escape, in both modes (a browser's own full screen usually takes the key first, and then this never sees it). A part
|
|
216
|
+
// that handles Escape itself (menu, Edit form, the toolbar, the chart with a selection) stops the key before it reaches here.
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
if (!expanded) return;
|
|
219
|
+
const onKey = (event: KeyboardEvent) => {
|
|
220
|
+
if (event.key !== 'Escape' || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
221
|
+
event.preventDefault();
|
|
222
|
+
exit();
|
|
223
|
+
};
|
|
224
|
+
document.addEventListener('keydown', onKey);
|
|
225
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
226
|
+
}, [expanded, exit]);
|
|
227
|
+
|
|
228
|
+
// Say it, and send focus home on the way out.
|
|
229
|
+
const was = useRef(false);
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
if (was.current === expanded) return;
|
|
232
|
+
was.current = expanded;
|
|
233
|
+
setMessage(expanded ? FULLSCREEN_MESSAGE.entered : FULLSCREEN_MESSAGE.exited);
|
|
234
|
+
if (!expanded) toggleRef.current?.focus({ preventScroll: true });
|
|
235
|
+
}, [expanded]);
|
|
236
|
+
|
|
237
|
+
// Unmounted while expanded: leave the browser's full screen. (The effects above undo the in-page mode.)
|
|
238
|
+
useEffect(() => () => {
|
|
239
|
+
if (live.current.mode === 'native' && nativeElement(document) === frame.current) exitNative(document);
|
|
240
|
+
}, []);
|
|
241
|
+
|
|
242
|
+
// The chart swallows Escape when it has something to clear, and also when it has not. So decide here, before it does.
|
|
243
|
+
// The signals are the parts' own documented attributes: the crosshair, `data-selected` (a drawing), `data-state='selected'`
|
|
244
|
+
// (the series, a study), `data-draft` (a half-placed drawing) and the armed tool's `aria-pressed`.
|
|
245
|
+
const onKeyDownCapture = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
246
|
+
if (!expanded || event.key !== 'Escape' || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
247
|
+
const target = event.target as Element;
|
|
248
|
+
const plot = target.closest?.("[data-part='plot']") ?? null;
|
|
249
|
+
const drawingBusy = event.currentTarget.querySelector("[data-part='drawing-tool'][aria-pressed='true'], [data-part='drawing'][data-draft]") !== null;
|
|
250
|
+
const plotHasMark = plot?.querySelector("[data-part='crosshair'], [data-selected], [data-state='selected']") != null;
|
|
251
|
+
if (resolveFullscreenEscape({ inPlot: plot !== null, plotHasMark, drawingBusy }) !== 'exit') return;
|
|
252
|
+
if (!plot) return; // outside the chart the document listener (or the part itself) handles it
|
|
253
|
+
event.preventDefault();
|
|
254
|
+
event.stopPropagation();
|
|
255
|
+
exit();
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const context = useMemo(() => ({ fullscreen: expanded, mode: expanded ? mode : null, enter, exit, toggle, frame, toggleRef }), [expanded, mode, enter, exit, toggle]);
|
|
259
|
+
const look = expanded ? (mode === 'fallback' ? FALLBACK_STYLE : NATIVE_STYLE) : undefined;
|
|
260
|
+
return (
|
|
261
|
+
<Context.Provider value={context}>
|
|
262
|
+
<div
|
|
263
|
+
ref={frame}
|
|
264
|
+
data-mt=""
|
|
265
|
+
data-part="fullscreen-frame"
|
|
266
|
+
{...rest}
|
|
267
|
+
className={className}
|
|
268
|
+
style={look ? { ...look, ...style } : style}
|
|
269
|
+
data-fullscreen={expanded ? '' : undefined}
|
|
270
|
+
data-mode={expanded ? (mode ?? undefined) : undefined}
|
|
271
|
+
onKeyDownCapture={onKeyDownCapture}
|
|
272
|
+
>
|
|
273
|
+
{children}
|
|
274
|
+
<span role="status" aria-live="polite" data-part="fullscreen-status" style={HIDDEN}>{message}</span>
|
|
275
|
+
</div>
|
|
276
|
+
</Context.Provider>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export interface FullscreenToggleProps extends NameProps, DataAttributes {
|
|
281
|
+
className?: string;
|
|
282
|
+
/** The name while collapsed, the action it does. Default "Full screen". */
|
|
283
|
+
label?: string;
|
|
284
|
+
/** The name while expanded. Default "Exit full screen". */
|
|
285
|
+
exitLabel?: string;
|
|
286
|
+
/** Visible text, in place of the label. */
|
|
287
|
+
children?: ReactNode;
|
|
288
|
+
disabled?: boolean;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** A button with `aria-pressed` that enters and leaves full screen. Put it inside Fullscreen.Frame. */
|
|
292
|
+
function Toggle({ icon, label, exitLabel, hideLabel, children, className, disabled, ...rest }: FullscreenToggleProps) {
|
|
293
|
+
const context = useContext(Context);
|
|
294
|
+
if (!context) throw new Error('Fullscreen.Toggle must be used inside Fullscreen.Frame.');
|
|
295
|
+
const { fullscreen, toggle, toggleRef } = context;
|
|
296
|
+
const name = fullscreen ? (exitLabel ?? FULLSCREEN_LABEL.exit) : (label ?? FULLSCREEN_LABEL.enter);
|
|
297
|
+
const face = buttonFace(name, { icon, label: name, hideLabel, children });
|
|
298
|
+
useNameCheck(toggleRef, 'Fullscreen.Toggle');
|
|
299
|
+
return (
|
|
300
|
+
<button
|
|
301
|
+
data-mt=""
|
|
302
|
+
data-part="fullscreen-toggle"
|
|
303
|
+
data-touch-target=""
|
|
304
|
+
{...rest}
|
|
305
|
+
ref={toggleRef}
|
|
306
|
+
type="button"
|
|
307
|
+
className={className}
|
|
308
|
+
disabled={disabled}
|
|
309
|
+
aria-label={face['aria-label']}
|
|
310
|
+
aria-pressed={fullscreen}
|
|
311
|
+
onClick={toggle}
|
|
312
|
+
>
|
|
313
|
+
{face.content}
|
|
314
|
+
</button>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export { Frame, Toggle };
|
package/src/Ladder.tsx
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Root, Table, Row, Last, Price, Size, Traded, Status } from './LadderParts.js';
|
|
2
|
+
|
|
3
|
+
// The parts live in LadderParts.js (a client module). The namespace object is built here, outside it, so a server
|
|
4
|
+
// component can write `Ladder.Root`: a property of an object exported from a client module is not reachable there.
|
|
5
|
+
export * from './LadderParts.js';
|
|
6
|
+
export const Ladder = { Root, Table, Row, Last, Price, Size, Traded, Status };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo, type CSSProperties, type ReactNode } from 'react';
|
|
2
|
+
import { primitiveAttributes } from './attributes.js';
|
|
3
|
+
import { useLadderKeys } from './ladder-keys.js';
|
|
4
|
+
import { LADDER_ROW_ATTRIBUTE } from './core/ladder-keys.js';
|
|
5
|
+
import { buildLadder, type LadderBuilt, type LadderInput, type LadderIssue, type LadderRow } from './core/ladder.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Ladder as parts: Root, Table, Row, Last, Price, Size, Traded, Status. Headless: the parts are real table elements and
|
|
9
|
+
* add `data-ladder-side`, `data-best` and a `--mt-bar` width for the size bars; the host chooses the columns.
|
|
10
|
+
*
|
|
11
|
+
* Asks are drawn highest first, then the divider row (the last price and the spread), then bids highest first. Prices
|
|
12
|
+
* group by `step` (bids round down, asks round up) and sizes are summed exactly. The keys are the ladder keys: arrows
|
|
13
|
+
* move between rows, Home is the best bid and End the best ask; the table has one tab stop. It is read-only: no key
|
|
14
|
+
* places or changes an order. A level that breaks a rule is listed by Status and is not drawn.
|
|
15
|
+
*/
|
|
16
|
+
interface LadderContext {
|
|
17
|
+
stop: string | undefined;
|
|
18
|
+
issues: LadderIssue[];
|
|
19
|
+
}
|
|
20
|
+
const Context = createContext<LadderContext | null>(null);
|
|
21
|
+
|
|
22
|
+
function useLadder(part: string): LadderContext {
|
|
23
|
+
const context = useContext(Context);
|
|
24
|
+
if (!context) throw new Error(`Ladder.${part} needs a Ladder.Root above it.`);
|
|
25
|
+
return context;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LadderRootProps extends LadderInput {
|
|
29
|
+
className?: string;
|
|
30
|
+
/** The parts, given the grouped rows. */
|
|
31
|
+
children: (built: LadderBuilt) => ReactNode;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function Root({ bids, asks, step, levels, last, traded, className, children }: LadderRootProps) {
|
|
35
|
+
const keys = useLadderKeys();
|
|
36
|
+
const built = useMemo(() => buildLadder({ bids, asks, step, levels, last, traded }), [bids, asks, step, levels, last, traded]);
|
|
37
|
+
const best = built.bids.find((r) => r.best) ?? built.asks.find((r) => r.best);
|
|
38
|
+
const stop = keys.tabStop(built.rows.map((r) => r.id), best?.id);
|
|
39
|
+
const context = useMemo<LadderContext>(() => ({ stop, issues: built.issues }), [stop, built.issues]);
|
|
40
|
+
return (
|
|
41
|
+
<Context.Provider value={context}>
|
|
42
|
+
<div className={className} data-mt="" {...keys.containerProps}>
|
|
43
|
+
{children(built)}
|
|
44
|
+
</div>
|
|
45
|
+
</Context.Provider>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The table. */
|
|
50
|
+
function Table({ caption, className, captionClassName, children }: { caption: string; className?: string; captionClassName?: string; children: ReactNode }) {
|
|
51
|
+
return (
|
|
52
|
+
<table className={className}>
|
|
53
|
+
<caption className={captionClassName}>{caption}</caption>
|
|
54
|
+
{children}
|
|
55
|
+
</table>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One price level. A single tab stop for the ladder: the focused row, or the best bid. */
|
|
60
|
+
function Row({ row, className, children }: { row: LadderRow; className?: string; children: ReactNode }) {
|
|
61
|
+
const { stop } = useLadder('Row');
|
|
62
|
+
return (
|
|
63
|
+
<tr
|
|
64
|
+
className={className}
|
|
65
|
+
data-ladder-side={row.side}
|
|
66
|
+
data-best={row.best ? row.side : undefined}
|
|
67
|
+
{...{ [LADDER_ROW_ATTRIBUTE]: row.id }}
|
|
68
|
+
tabIndex={row.id === stop ? 0 : -1}
|
|
69
|
+
>
|
|
70
|
+
{children}
|
|
71
|
+
</tr>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The divider between asks and bids: the last price and the spread. Not a stop for the keys. */
|
|
76
|
+
function Last({ last, colSpan = 3, className, children }: { last: LadderBuilt['last']; colSpan?: number; className?: string; children?: ReactNode }) {
|
|
77
|
+
return (
|
|
78
|
+
<tr className={className} data-ladder-last="">
|
|
79
|
+
<td colSpan={colSpan} data-mt-numeric="">
|
|
80
|
+
{children ?? (
|
|
81
|
+
<>
|
|
82
|
+
<span>{last.price ? `Last ${last.price}` : 'No trade yet'}</span>
|
|
83
|
+
{last.spread && <span> · Spread {last.spread}</span>}
|
|
84
|
+
</>
|
|
85
|
+
)}
|
|
86
|
+
</td>
|
|
87
|
+
</tr>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The price, as the bucket's exact string; the B or A mark and colour come from tokens.css. */
|
|
92
|
+
function Price({ row, className }: { row: LadderRow; className?: string }) {
|
|
93
|
+
return <th scope="row" className={className} data-mt-numeric="" {...primitiveAttributes({ side: row.side })}>{row.price}</th>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The size, with a bar behind it as wide as the size is large next to the largest row shown. */
|
|
97
|
+
function Size({ row, className }: { row: LadderRow; className?: string }) {
|
|
98
|
+
return <td className={className} data-mt-numeric="" data-mt="" data-ladder-bar={row.side} style={{ ['--mt-bar' as string]: row.fraction } as CSSProperties}>{row.size}</td>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Size traded at this price, empty when none. */
|
|
102
|
+
function Traded({ row, className }: { row: LadderRow; className?: string }) {
|
|
103
|
+
return <td className={className} data-mt-numeric="">{row.traded ?? ''}</td>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Levels left out for breaking a rule, and a crossed book. */
|
|
107
|
+
function Status({ className }: { className?: string }) {
|
|
108
|
+
const { issues } = useLadder('Status');
|
|
109
|
+
if (!issues.length) return null;
|
|
110
|
+
const text = issues.slice(0, 3).map((i) => `${i.where}${i.index !== undefined ? ` ${i.index + 1}` : ''}: ${i.message}`).join('; ');
|
|
111
|
+
return <p className={className} role="status">{issues.length} {issues.length === 1 ? 'problem' : 'problems'} in the book. {text}{issues.length > 3 ? '; …' : ''}.</p>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export { Root, Table, Row, Last, Price, Size, Traded, Status };
|
package/src/Menu.tsx
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Root, Trigger, List, Item, RadioItem, Separator } from './MenuParts.js';
|
|
2
|
+
|
|
3
|
+
// The parts live in MenuParts.js (a client module). The namespace object is built here, outside it, so a server
|
|
4
|
+
// component can write `Menu.Root`: a property of an object exported from a client module is not reachable there.
|
|
5
|
+
export * from './MenuParts.js';
|
|
6
|
+
export const Menu = { Root, Trigger, List, Item, RadioItem, Separator };
|