From ad86464a76c51dcae9f5f7ea4c8e4e689b8b53b4 Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Wed, 30 Sep 2026 07:40:36 -0700 Subject: [PATCH] feat(ui): createControlRail, the header's controls as a vertical rail The left control rail in the playground was ~120 lines of app code, so hosts such as bloxwap.pro could only get the top header bar. - createControlRail (@bloxwap/chart/ui): timeframe and chart-type menus, Indicators, Auto / % / Log toggles, and top / bottom host slots in a 52px rail beside the drawing toolbar. Same options, datafeed rules and handle as createChartHeader; menus open right with hover intent - The header's keyboard dropdown and radio entries move to a shared menu-controls module, so both menus behave identically - Rail styles (slots, scale toggles, footer) ship in TOOLBAR_CSS - Playground builds its rail with createControlRail - Docs: control rail section in the header guide, exports, READMEs, llms.txt Co-Authored-By: Claude Opus 5.5 --- apps/docs/content/docs/api/exports.mdx | 3 +- apps/docs/content/docs/guides/header.mdx | 42 ++- apps/docs/public/llms.txt | 2 +- apps/playground/README.md | 9 +- apps/playground/index.html | 91 ++----- packages/chart/README.md | 6 +- packages/chart/src/ui/control-rail.ts | 280 ++++++++++++++++++++ packages/chart/src/ui/header.ts | 66 +---- packages/chart/src/ui/index.ts | 2 + packages/chart/src/ui/menu-controls.ts | 83 ++++++ packages/chart/src/ui/styles.ts | 8 + packages/chart/test/control-rail.test.ts | 319 +++++++++++++++++++++++ 12 files changed, 766 insertions(+), 145 deletions(-) create mode 100644 packages/chart/src/ui/control-rail.ts create mode 100644 packages/chart/src/ui/menu-controls.ts create mode 100644 packages/chart/test/control-rail.test.ts diff --git a/apps/docs/content/docs/api/exports.mdx b/apps/docs/content/docs/api/exports.mdx index d6617ef..c5892a4 100644 --- a/apps/docs/content/docs/api/exports.mdx +++ b/apps/docs/content/docs/api/exports.mdx @@ -14,7 +14,7 @@ The package ships ESM and TypeScript declarations. There is no CommonJS export. | `@bloxwap/chart/indicators` | Indicator registry, built-ins, and calculation helpers | | `@bloxwap/chart/drawings` | Drawing registry, catalog, built-ins, and geometry types | | `@bloxwap/chart/wasm` | WASM initialization and kernel interface | -| `@bloxwap/chart/ui` | Drawing toolbar, settings, header, scale buttons, indicator dialog, context menu, touch gestures, countdown ticker, controller, history, UI primitives, and frame scheduler | +| `@bloxwap/chart/ui` | Drawing toolbar, settings, header, control rail, scale buttons, indicator dialog, context menu, touch gestures, countdown ticker, controller, history, UI primitives, and frame scheduler | | `@bloxwap/chart/icons` | `ICONS` and `icon(name, size?)` | | `@bloxwap/chart/react` | `` React component (`'use client'`), `ChartProps`, `ChartInstance` | | `@bloxwap/chart/datafeed` | `createDatafeedChart`, `attachDatafeed`, `loadHistory`, `createLiveBarFolder`, paging and bucket helpers | @@ -40,6 +40,7 @@ Beyond `createChart`, the root entry exports: | Area | Exports | | --- | --- | | Header | `createChartHeader`, `DEFAULT_TIMEFRAMES`, `DEFAULT_HEADER_INTERVAL_MS`, `DEFAULT_CHART_TYPES`, `CHART_TYPE_LABELS`, `HEADER_CSS`, `HEADER_STYLE_MARKER`, `injectHeaderStyles`, `BLOXWAP_HEADER_TOKENS`, `BLOXWAP_HEADER_THEME`; types `HeaderTokens`, `ThemedHeaderTokens` | +| Control rail | `createControlRail`; types `ControlRail`, `ControlRailOptions` | | Scale buttons | `createScaleButtons`, `toggleScale`, `scaleToggleState`, `syncScaleToggles`, `SCALE_TOGGLES` | | Indicator dialog | `createIndicatorsDialog`, `indicatorInputs`, `indicatorStyleGroups`, `INDICATORS_DIALOG_CSS`, `INDICATORS_DIALOG_STYLE_MARKER` | | Context menu | `createChartContextMenu`; types `ChartContextMenuHooks`, `ContextMenuTarget`, `ContextMenuAction`, `ContextMenuEntry` | diff --git a/apps/docs/content/docs/guides/header.mdx b/apps/docs/content/docs/guides/header.mdx index 76fd567..5904517 100644 --- a/apps/docs/content/docs/guides/header.mdx +++ b/apps/docs/content/docs/guides/header.mdx @@ -1,6 +1,6 @@ --- -title: Header and scale buttons -description: Add a TradingView-style top bar with timeframes, chart types, indicators, and price-scale toggles. +title: Header, control rail and scale buttons +description: Add a TradingView-style top bar or a vertical control rail with timeframes, chart types, indicators, and price-scale toggles. --- The chart header is an optional top bar from `@bloxwap/chart/ui`. It holds the symbol, a timeframe switcher that drives your datafeed, a chart-type menu, an Indicators button, Auto / % / Log price-scale toggles, and slots for your own controls. It is a normal-flow element: put its container above the chart. Nothing else depends on it, so skip it if you keep your own header. @@ -89,6 +89,44 @@ The chart-type button and scale toggles follow `series` and `priceAxis` changes Every control is its own tab stop, and dropdowns work from the keyboard: arrows, Home, and End move through entries, Escape closes the menu (from anywhere on the page, so a menu opened with a click or tap closes too) and returns focus to its button, and Tab closes it and continues from its button. Menus stack above the settings card and the indicators dialog. +## Control rail + +`createControlRail` puts the same controls in a vertical rail beside the drawing toolbar, so the plot starts at the top of the card. This is the layout bloxwap.pro and the playground use on wide screens. It holds a timeframe menu, the chart-type menu, the Indicators button, and the Auto / % / Log toggles, then your own controls. + +```html +
+
+
+
+
+``` + +```ts +import { createControlRail } from '@bloxwap/chart/ui'; + +const rail = createControlRail({ + chart, + document, + container: document.querySelector('#controls')!, + datafeed, // picking 1h reloads the loaded symbol at 3_600_000 + flyouts: toolbar.flyouts, // one menu open at a time with the drawing rail + onIndicators: () => indicators.openPicker(), + slots: { top: [undoButton, redoButton], bottom: [settingsButton] }, +}); + +rail.slots.bottom.append(snapshotButton); // slots are live containers +rail.setTheme('light'); +rail.destroy(); +``` + +The layout, top to bottom, is `[timeframe] [chart type] [Indicators] — [Auto % Log] — [top slot] … [bottom slot]`. Dividers only appear between groups that are shown. The top slot scrolls with the built-in controls, and the rail adds paging arrows when they overflow. The bottom slot stays pinned at the foot. + +It takes the header's `symbol`, `timeframes`, `intervalMs`, `datafeed`, `onTimeframeChange`, `chartTypes`, `onChartTypeChange`, `onIndicators`, `scaleButtons`, `flyouts`, and `theme` options, with the same defaults and the same [datafeed rules](#working-with-a-datafeed). `slots` is `{ top?, bottom? }`. The handle has `element`, `slots` (the `top` and `bottom` containers), `symbol`, `intervalMs`, `setSymbol`, `setInterval`, `setChartType`, `setTheme`, `refresh`, and `destroy`, which behave like the header's. + +Menus open to the right of their button, on click or mouse hover like the drawing rail's, and work from the keyboard like the header's dropdowns. The rail uses the shared toolbar tokens (`--cts-accent`, `--cts-accent-soft`, and so on) rather than `--cts-header-*`: set them on `.cts-theme` to restyle it along with the drawing rail. A pressed scale toggle takes the accent color on the soft accent fill. + +To switch layouts by width, as the playground does, create both. Hide the rail's container below a breakpoint and the header's above it, then move your own buttons between `rail.slots` and the header's `slots.right` container. Both follow the datafeed, so they stay in step. + ## Scale buttons on the chart TradingView shows `A`, `%`, and `L` toggles at the foot of the price axis. `createScaleButtons` adds the same strip above the time axis: diff --git a/apps/docs/public/llms.txt b/apps/docs/public/llms.txt index b31cf79..89fe086 100644 --- a/apps/docs/public/llms.txt +++ b/apps/docs/public/llms.txt @@ -68,7 +68,7 @@ Add the toolbar with `createDrawingToolbar({ chart, document, canvas, rail, over - [Indicators](https://bloxwap.github.io/chart/docs/guides/indicators/): 20 built-in studies, per-plot styling, the indicator dialog, and custom calculations. - [Drawings](https://bloxwap.github.io/chart/docs/guides/drawings/): Place, edit, and manage annotations in data coordinates. - [Toolbar and settings](https://bloxwap.github.io/chart/docs/guides/toolbar-settings/): Drawing rail, navigation, the settings card, and context menus. -- [Header and scale buttons](https://bloxwap.github.io/chart/docs/guides/header/): Top bar with timeframes, chart types, indicators, and Auto / % / Log toggles. +- [Header, control rail and scale buttons](https://bloxwap.github.io/chart/docs/guides/header/): Top bar or vertical control rail with timeframes, chart types, indicators, and Auto / % / Log toggles. - [Touch gestures](https://bloxwap.github.io/chart/docs/guides/touch/): Pan, pinch, fling, and long-press crosshair. - [Time scale](https://bloxwap.github.io/chart/docs/guides/time-scale/): Bar-indexed or time-continuous axis, and what stays bar-indexed. - [Chart events](https://bloxwap.github.io/chart/docs/guides/events/): Visible range, crosshair, data load, and layout events and their delivery rules. diff --git a/apps/playground/README.md b/apps/playground/README.md index 0d27b7c..8e00128 100644 --- a/apps/playground/README.md +++ b/apps/playground/README.md @@ -44,6 +44,11 @@ bloxwap.pro uses the chart: section of the settings card switches between BTC, the random walk (1m or 1d) and **Gaps (1h)**. Gaps (1h) turns on `timeScale.continuous`, so missing buckets show as gaps. +- **Control rail:** on wide screens, `createControlRail` stacks the timeframe + menu, chart-type menu, **Indicators** button and Auto / % / Log toggles in a + rail left of the drawing toolbar. The page adds the right-edge panel picker, + undo/redo and the theme control to its top slot, and the snapshot, settings + and drawing-rail toggle buttons to its bottom slot. - **Header:** `createChartHeader` shows the symbol and the 1m–1M timeframes. Picking a timeframe calls `datafeed.setSymbol`. The header also has the chart-type menu (including Heikin Ashi and hollow candles), an **Indicators** @@ -71,8 +76,8 @@ bloxwap.pro uses the chart: `#00ff3f` accent. The header and the on-chart scale toggles get it from `BLOXWAP_HEADER_THEME`, which their `setTheme` clears on light. With no saved choice, the page starts on dark. -- **Narrow screens (under 600px):** the control rail hides and its buttons move - into the header. When the header is too wide, it collapses the timeframes into +- **Narrow screens (under 600px):** the control rail hides, the header shows, + and the page's own buttons move from the rail's slots into the header. When the header is too wide, it collapses the timeframes into a dropdown and wraps onto a second row. The drawing rail starts collapsed. The status line wraps onto more rows instead of running under the price axis. On touch devices, a one-finger drag pans even across the demo drawings: tap one diff --git a/apps/playground/index.html b/apps/playground/index.html index 30f7e05..de152b1 100644 --- a/apps/playground/index.html +++ b/apps/playground/index.html @@ -19,9 +19,7 @@ #header { flex: none; min-width: 0; } /* Wide: the control rail carries the header's controls, so the plot starts at the top of the card. */ @media (min-width: 600px) { #header { display: none; } } - /* Rail text toggles (timeframe, Auto / % / Log) fit the 36px buttons; a pressed toggle takes the accent. */ - #ctrl-rail .cts-btn-label { font-size: 11px; } - #ctrl-rail .rail-scale.cts-on { color: var(--cts-accent); background: var(--cts-accent-soft); } + #ctrl-rail { flex: none; display: flex; } #stage { position: relative; flex: 1; min-height: 0; } #tools { flex: none; width: 52px; overflow: hidden; transition: width 240ms cubic-bezier(0.2, 0.8, 0.2, 1); } #tools.collapsed { width: 0; } @@ -63,10 +61,7 @@
- +
@@ -81,9 +76,8 @@ import { createDatafeedChart, datafeedFootprintSource, bucketStartMs } from '../dist/datafeed/index.js'; import { createDrawingToolbar, createChartSettings, createIndicatorsDialog, createThemeControl, createFrameScheduler, - createChartHeader, DEFAULT_TIMEFRAMES, attachScrollableRail, el, iconButton, setButtonIcon, - menuItem, menuLabel, menuSeparator, startCountdownTicker, labelButton, setButtonLabel, ICONS, - DEFAULT_CHART_TYPES, CHART_TYPE_LABELS, SCALE_TOGGLES, toggleScale, scaleToggleState, + createChartHeader, createControlRail, DEFAULT_TIMEFRAMES, el, iconButton, setButtonIcon, + menuItem, menuLabel, menuSeparator, startCountdownTicker, } from '../dist/ui/index.js'; import { BLOXWAP_HEADER_THEME } from '../dist/ui/index.js'; @@ -588,8 +582,8 @@ onChange: () => { toolbar.cancelNavigation(); toolbar.refreshViewport(); }, }); - // ---------------- rail: timeframe, chart type, indicators, price scale (the wide layout's header) ---------------- - // Flyouts open to the right of the rail; every control follows the datafeed and config like the header does. + // ---------------- rail: the right-edge panel disclosure (createControlRail below holds the header's controls) ---------------- + // Flyouts open to the right of the rail. const railMenu = (label) => { const menu = flyouts.create(); menu.append(menuLabel(document, label)); @@ -606,39 +600,6 @@ item.setAttribute('aria-checked', String(on)); }; - const tfBtn = labelButton(document, '15m', 'Timeframe'); - const tfMenu = railMenu('Timeframe'); - const tfItems = DEFAULT_TIMEFRAMES.map((tf) => radio(tfMenu, { label: tf.label, - onClick: () => { if (tf.intervalMs !== datafeed.intervalMs) void load(datafeed.symbol ?? COIN, tf.intervalMs); } })); - flyouts.attach(tfBtn, tfMenu); - function syncRailTimeframe() { - const intervalMs = datafeed.intervalMs; - const label = DEFAULT_TIMEFRAMES.find((tf) => tf.intervalMs === intervalMs)?.label ?? ''; - setButtonLabel(tfBtn, label); - tfBtn.title = `Timeframe: ${label}`; - tfBtn.setAttribute('aria-label', tfBtn.title); - DEFAULT_TIMEFRAMES.forEach((tf, i) => check(tfItems[i], tf.intervalMs === intervalMs)); - } - datafeed.subscribeState(syncRailTimeframe); - - const typeBtn = iconButton(document, 'chart-candlestick', 'Chart type', 20); - const typeMenu = railMenu('Chart type'); - const typeItems = DEFAULT_CHART_TYPES.map((type) => radio(typeMenu, { icon: `chart-${type}`, label: CHART_TYPE_LABELS[type], - onClick: () => chart.updateConfig({ series: { type } }) })); - flyouts.attach(typeBtn, typeMenu); - function syncRailType() { - const type = chart.getConfig().series.type; - // A custom series (the footprint) keeps the candlestick glyph. - setButtonIcon(typeBtn, ICONS[`chart-${type}`] !== undefined ? `chart-${type}` : 'chart-candlestick', 20); - typeBtn.title = `Chart type: ${CHART_TYPE_LABELS[type] ?? type}`; - typeBtn.setAttribute('aria-label', typeBtn.title); - DEFAULT_CHART_TYPES.forEach((t, i) => check(typeItems[i], t === type)); - } - - const indicatorsBtn = iconButton(document, 'function-square', 'Indicators', 20); - indicatorsBtn.setAttribute('aria-haspopup', 'dialog'); - indicatorsBtn.addEventListener('click', () => indicators.openPicker()); - // Right-edge panel disclosure: the volume profile (both ways from a center line), the order book (out of the // right edge), or none. The button shows the chosen panel's glyph and takes the accent while one shows. const SIDE_MODES = [ @@ -663,28 +624,6 @@ } syncSideMode(); - const scaleBtns = SCALE_TOGGLES.map(({ key, label, title }) => { - const b = labelButton(document, label, title); - b.classList.add('rail-scale'); - b.setAttribute('aria-label', title); - b.addEventListener('click', () => toggleScale(chart, key)); - return b; - }); - function syncRailScale() { - const state = scaleToggleState(chart.getConfig().priceAxis); - SCALE_TOGGLES.forEach(({ key }, i) => { - scaleBtns[i].classList.toggle('cts-on', state[key]); - scaleBtns[i].setAttribute('aria-pressed', String(state[key])); - }); - } - chart.subscribeConfigChange(({ keys }) => { - if (keys.includes('series')) syncRailType(); - if (keys.includes('priceAxis')) syncRailScale(); - }); - syncRailTimeframe(); - syncRailType(); - syncRailScale(); - // ---------------- header (narrow screens): symbol, timeframes → datafeed, chart type, indicators, scale toggles ---------------- const shotBtn = iconButton(document, 'image', 'Download snapshot', 18); shotBtn.innerHTML = CAMERA; @@ -703,6 +642,11 @@ onIndicators: () => indicators.openPicker(), slots: { right: [headerControls] }, }); + // Wide: the control rail replaces the header, driving the same source. Host controls join its slots below. + const controlRail = createControlRail({ chart, document, container: $('ctrl-rail'), datafeed: source, flyouts, + onIndicators: () => indicators.openPicker(), + slots: { top: [sideBtn, el(document, 'div', 'cts-rail-divider')] }, + }); // Embeds can pin a host theme with ?lockedTheme=dark (or light). Without a saved choice, start on dark (bloxwapDark). const themeKey = 'chart-ts:theme'; @@ -714,7 +658,7 @@ onSelect: mode => safeSet(themeKey, mode), onChange: theme => { document.body.classList.toggle('light', theme === 'light'); - $('ctrl-rail').classList.toggle('cts-light', theme === 'light'); + controlRail.setTheme(theme); toolbar.setTheme(theme); settings.setTheme(theme); header.setTheme(theme); @@ -742,21 +686,18 @@ // Wide: the control rail replaces the header. Narrow (< 600px): the rail hides, the header shows, and the // shared buttons join it, compacting and wrapping. The rail-only controls stay put in the hidden rail. - const divider = () => el(document, 'div', 'cts-rail-divider'); const narrow = matchMedia('(max-width: 599px)'); - $('ctrl-scroll').append(tfBtn, typeBtn, indicatorsBtn, sideBtn, divider(), ...scaleBtns, divider()); - const themeDivider = divider(); + const themeDivider = el(document, 'div', 'cts-rail-divider'); const placeControls = () => { const theme = themeControl.element ? [themeControl.element] : []; flyouts.close(); if (narrow.matches) headerControls.append(shotBtn, undoBtn, redoBtn, ...theme, settingsBtn, railToggle); else { - $('ctrl-scroll').append(undoBtn, redoBtn, ...(theme.length > 0 ? [themeDivider, ...theme] : [])); - $('ctrl-bottom').append(shotBtn, settingsBtn, railToggle); + controlRail.slots.top.append(undoBtn, redoBtn, ...(theme.length > 0 ? [themeDivider, ...theme] : [])); + controlRail.slots.bottom.append(shotBtn, settingsBtn, railToggle); } }; placeControls(); - attachScrollableRail(document, $('ctrl-scroll')); narrow.addEventListener('change', placeControls); // Keep the chart usable in a narrow embed; drawing tools are one click away. if (narrow.matches) railToggle.click(); @@ -968,7 +909,7 @@ chart.addDrawing({ name: 'long-position', points: [{ index: n - 30, price: data[n - 30].close }, { index: n + 10, price: data[n - 30].close * 1.012 }] }); } } - Object.assign(window, { chart, datafeed, toolbar, header, indicators, settings }); + Object.assign(window, { chart, datafeed, toolbar, header, controlRail, indicators, settings }); diff --git a/packages/chart/README.md b/packages/chart/README.md index b8828ef..aa3d28b 100644 --- a/packages/chart/README.md +++ b/packages/chart/README.md @@ -31,7 +31,7 @@ to open the playground at http://localhost:8641/demo/ (requires Bun). - **Events** — visible range, crosshair, data load and layout subscriptions with re-entrancy-safe delivery - **Time-continuous axis** — `timeScale.continuous` shows weekends and outages as gaps (bar-indexed by default) - **Snapshots and countdown** — `takeScreenshot`/`toBlob`/`toDataURL`, and a bar-close countdown in the status line and on the price label -- **TradingView-style UI** — header bar with timeframes, chart types and Auto/%/Log toggles, indicator picker and settings dialog, right-click context menus, and touch gestures (pan, pinch, fling, long-press crosshair) +- **TradingView-style UI** — header bar or vertical control rail with timeframes, chart types and Auto/%/Log toggles, indicator picker and settings dialog, right-click context menus, and touch gestures (pan, pinch, fling, long-press crosshair) - **89 drawing tools** in TradingView's toolbar groups — lines, channels, pitchforks, Fibonacci, Gann, harmonic/chart patterns, Elliott waves, cycles, long/short positions, forecasting, anchored VWAP, fixed range volume profile, measurers, brushes, shapes, arrows, text/notes/callouts/tables, images, emoji — plus a registry for custom models - **Interactive editing API** — live draft preview, hit-testing, selection handles, point dragging, translation, magnet snapping (weak/strong), hide/clear, per-drawing color/width/line style/text/lock - **Cursor modes** — cross, dot, arrow and presenter halo (`crosshair.mode`) @@ -49,7 +49,7 @@ to open the playground at http://localhost:8641/demo/ (requires Bun). | `@bloxwap/chart/indicators` | Indicator registry, built-ins and calculation helpers | | `@bloxwap/chart/drawings` | Drawing registry, catalog and built-ins | | `@bloxwap/chart/wasm` | WASM initialization | -| `@bloxwap/chart/ui` | Drawing toolbar, settings card, header, scale buttons, indicator dialog, context menu, touch gestures, countdown ticker | +| `@bloxwap/chart/ui` | Drawing toolbar, settings card, header, control rail, scale buttons, indicator dialog, context menu, touch gestures, countdown ticker | | `@bloxwap/chart/icons` | `ICONS`, `icon(name, size?)` | | `@bloxwap/chart/react` | `` component | | `@bloxwap/chart/datafeed` | `createDatafeedChart`, `attachDatafeed`, `loadHistory`, `createLiveBarFolder` and paging helpers | @@ -156,6 +156,8 @@ const header = createChartHeader({ flyouts: toolbar.flyouts, tokens: BLOXWAP_HEADER_THEME, // bloxwap.pro look on dark; setTheme swaps it onIndicators: () => indicators.openPicker(), }); +// Or stack the same controls in a rail beside the drawing toolbar: +// createControlRail({ chart, document, container: controlsEl, datafeed, flyouts: toolbar.flyouts, onIndicators: … }); ``` Custom indicators and drawings: diff --git a/packages/chart/src/ui/control-rail.ts b/packages/chart/src/ui/control-rail.ts new file mode 100644 index 0000000..d9552b4 --- /dev/null +++ b/packages/chart/src/ui/control-rail.ts @@ -0,0 +1,280 @@ +/** + * An optional vertical control rail: the header's controls stacked in a 52px + * column beside the drawing toolbar, so the plot starts at the top of the + * card. It holds a timeframe menu (drives a datafeed), a chart-type menu, an + * Indicators button, the Auto / % / Log price-scale toggles, and slots for + * host controls: `top` follows the built-in controls and scrolls with them, + * `bottom` stays pinned at the rail's foot. + * + * ```ts + * import { createControlRail } from '@bloxwap/chart/ui'; + * + * const rail = createControlRail({ + * chart, document, + * container: document.querySelector('#controls')!, + * datafeed, // picking 1h reloads the loaded symbol at 3_600_000 + * flyouts: toolbar.flyouts, // one menu open at a time with the drawing rail + * onIndicators: () => indicators.openPicker(), + * slots: { bottom: [settingsButton] }, + * }); + * ``` + * + * @module + */ + +import type { Chart } from '../core/chart.js'; +import type { SeriesType } from '../config.js'; +import type { ThemeName } from '../themes.js'; +import type { UIDocument, UIElement } from './host.js'; +import { ICONS } from './icons.js'; +import { Flyouts, el, iconButton, labelButton, menuLabel, setButtonIcon, setButtonLabel } from './menu.js'; +import { menuControls } from './menu-controls.js'; +import { injectStyles } from './styles.js'; +import { attachScrollableRail } from './rail-scroll.js'; +import { createScaleToggleGroup } from './scale-buttons.js'; +import { + CHART_TYPE_LABELS, DEFAULT_CHART_TYPES, DEFAULT_HEADER_INTERVAL_MS, DEFAULT_TIMEFRAMES, intervalLabel, + type HeaderDatafeed, type HeaderTimeframe, +} from './header.js'; + +/** Options for {@link createControlRail}. */ +export interface ControlRailOptions { + chart: Chart; + /** Injected document (pass the browser `document`). */ + document: UIDocument; + /** Element the rail is appended to; give it the rail's height, beside the drawing toolbar's rail. */ + container: UIElement; + /** Symbol passed to the datafeed on a timeframe pick. A datafeed reporting its own `symbol` wins. */ + symbol?: string; + /** Timeframe menu entries, in order. Default {@link DEFAULT_TIMEFRAMES}; `[]` hides the menu. */ + timeframes?: readonly HeaderTimeframe[]; + /** Initially selected interval in ms. Default {@link DEFAULT_HEADER_INTERVAL_MS}; a datafeed reporting its own `intervalMs` wins. */ + intervalMs?: number; + /** Picking a timeframe calls `datafeed.setSymbol(symbol, intervalMs)` (when a symbol is known). */ + datafeed?: HeaderDatafeed; + /** Called after the user picks a different timeframe. */ + onTimeframeChange?(timeframe: HeaderTimeframe): void; + /** Chart-type menu entries. Default {@link DEFAULT_CHART_TYPES}; `[]` hides the menu. */ + chartTypes?: readonly SeriesType[]; + /** Called after the user picks a different chart type (already applied to `series.type`). */ + onChartTypeChange?(type: SeriesType): void; + /** Shows the Indicators button, which calls this. */ + onIndicators?(): void; + /** Auto / % / Log price-scale toggles. Default true. */ + scaleButtons?: boolean; + /** Host controls: `top` follows the built-in controls (after a divider), `bottom` is pinned at the foot. */ + slots?: { top?: readonly UIElement[]; bottom?: readonly UIElement[] }; + /** Share the drawing toolbar's flyouts; by default the rail owns a set. */ + flyouts?: Flyouts; + /** Default `'dark'`. */ + theme?: ThemeName; +} + +/** Handle returned by {@link createControlRail}. */ +export interface ControlRail { + /** The rail. */ + readonly element: UIElement; + /** The slot containers; append host controls to them, or move controls in and out (e.g. into a header on narrow screens). */ + readonly slots: { readonly top: UIElement; readonly bottom: UIElement }; + /** Symbol passed to the datafeed. */ + readonly symbol: string | undefined; + /** Selected interval in ms. */ + readonly intervalMs: number; + /** Sets the symbol for later timeframe picks, unless the datafeed reports its own. Does not call the datafeed. */ + setSymbol(symbol: string): void; + /** Marks `intervalMs` as selected. Does not call the datafeed or `onTimeframeChange`. */ + setInterval(intervalMs: number): void; + /** Applies `type` to `series.type` and updates the menu. Does not call `onChartTypeChange`. */ + setChartType(type: SeriesType): void; + /** Switches between the dark and light UI tokens. */ + setTheme(theme: ThemeName): void; + /** + * Re-reads the chart config and the datafeed's symbol / interval. Config + * changes already show once they render; this is for a datafeed without `subscribeState`. + */ + refresh(): void; + /** Removes the rail, its menus and every listener. */ + destroy(): void; +} + +/** Icon size for rail buttons, matching the drawing rail's flyout buttons. */ +const ICON_PX = 20; + +/** Builds the control rail into `options.container`. */ +export function createControlRail(options: ControlRailOptions): ControlRail { + const { chart, document: doc } = options; + injectStyles(doc); + const cleanups: (() => void)[] = []; + + const root = el(doc, 'nav', 'cts-theme cts-rail cts-control-rail'); + root.setAttribute('aria-label', 'Chart controls'); + const scroll = el(doc, 'div', 'cts-rail-scroll'); + const footer = el(doc, 'div', 'cts-rail-footer'); + const portal = options.flyouts === undefined ? el(doc, 'div', 'cts-theme') : null; + if (portal !== null) doc.body.append(portal); + const flyouts = options.flyouts ?? new Flyouts(doc, portal!); + const themed = portal === null ? [root] : [root, portal]; + const { dropdown, radioItem } = menuControls(doc, flyouts, cleanups); + const menus: UIElement[] = []; + /** A menu opening right of its button, headed by `label`, with hover intent like the drawing rail's. */ + const railMenu = (btn: UIElement, label: string): UIElement => { + const menu = flyouts.create('cts-control-rail-menu'); + menu.append(menuLabel(doc, label)); + menus.push(menu); + cleanups.push(flyouts.hover(btn, menu)); + return menu; + }; + const divider = (): UIElement => el(doc, 'div', 'cts-rail-divider'); + + // ------------------------------------------------------------ timeframe + + const datafeed = options.datafeed; + let symbol = options.symbol ?? datafeed?.symbol ?? undefined; + const timeframes = options.timeframes ?? DEFAULT_TIMEFRAMES; + let interval = options.intervalMs ?? datafeed?.intervalMs ?? DEFAULT_HEADER_INTERVAL_MS; + const tfBtn = labelButton(doc, '', 'Timeframe'); + const tfMenu = railMenu(tfBtn, 'Timeframe'); + const tfItems = timeframes.map((tf) => radioItem(tfMenu, tfBtn, tf.label, undefined, () => pickTimeframe(tf))); + dropdown(tfBtn, tfMenu, tfItems); + + function syncTimeframes(): void { + timeframes.forEach((tf, i) => { + const on = tf.intervalMs === interval; + tfItems[i].classList.toggle('cts-active', on); + tfItems[i].setAttribute('aria-checked', String(on)); + }); + const label = timeframes.find((tf) => tf.intervalMs === interval)?.label ?? intervalLabel(interval); + setButtonLabel(tfBtn, label); + tfBtn.title = `Timeframe: ${label}`; + tfBtn.setAttribute('aria-label', tfBtn.title); + } + function pickTimeframe(tf: HeaderTimeframe): void { + // The datafeed's own symbol and interval win: they are what the chart shows. + if (tf.intervalMs === interval && tf.intervalMs === (datafeed?.intervalMs ?? interval)) return; + interval = tf.intervalMs; + syncTimeframes(); + const loaded = datafeed?.symbol ?? symbol; + if (loaded !== undefined) datafeed?.setSymbol(loaded, interval); + options.onTimeframeChange?.(tf); + } + /** Adopts the symbol and interval a datafeed reports. */ + function follow(state: Pick): void { + if (typeof state.symbol === 'string') symbol = state.symbol; + if (typeof state.intervalMs === 'number' && state.intervalMs !== interval) { + interval = state.intervalMs; + syncTimeframes(); + } + } + + // ------------------------------------------------------------ chart type + + const chartTypes = options.chartTypes ?? DEFAULT_CHART_TYPES; + const typeBtn = iconButton(doc, 'chart-candlestick', 'Chart type', ICON_PX); + const typeMenu = railMenu(typeBtn, 'Chart type'); + const typeItems = chartTypes.map((type) => radioItem(typeMenu, typeBtn, CHART_TYPE_LABELS[type], `chart-${type}`, () => pickType(type))); + dropdown(typeBtn, typeMenu, typeItems); + let shownType: string | null = null; + + function syncType(): void { + const type = chart.getConfig().series.type; + if (type === shownType) return; + shownType = type; + // A custom series (e.g. a footprint) has no glyph of its own: keep the candlestick. + setButtonIcon(typeBtn, ICONS[`chart-${type}`] !== undefined ? `chart-${type}` : 'chart-candlestick', ICON_PX); + typeBtn.title = `Chart type: ${CHART_TYPE_LABELS[type as SeriesType] ?? type}`; + typeBtn.setAttribute('aria-label', typeBtn.title); + chartTypes.forEach((t, i) => { + typeItems[i].classList.toggle('cts-active', t === type); + typeItems[i].setAttribute('aria-checked', String(t === type)); + }); + } + function applyType(type: SeriesType): boolean { + const changed = chart.getConfig().series.type !== type; + if (changed) chart.updateConfig({ series: { type } }); + syncType(); + return changed; + } + function pickType(type: SeriesType): void { + if (applyType(type)) options.onChartTypeChange?.(type); + } + + // ------------------------------------------------------------ indicators, scale, slots + + const controls: UIElement[] = []; + if (timeframes.length > 0) controls.push(tfBtn); + if (chartTypes.length > 0) controls.push(typeBtn); + const onIndicators = options.onIndicators; + if (onIndicators !== undefined) { + const b = iconButton(doc, 'function-square', 'Indicators', ICON_PX); + b.setAttribute('aria-label', 'Indicators'); + b.setAttribute('aria-haspopup', 'dialog'); + b.addEventListener('click', onIndicators); + cleanups.push(() => b.removeEventListener('click', onIndicators)); + controls.push(b); + } + const scale = options.scaleButtons === false ? null : createScaleToggleGroup(doc, chart, 'cts-btn cts-rail-scale', false); + if (scale !== null) { + const group = el(doc, 'div', 'cts-control-rail-group'); + group.setAttribute('role', 'group'); + group.setAttribute('aria-label', 'Price scale'); + group.append(...scale.buttons); + if (controls.length > 0) controls.push(divider()); + controls.push(group); + cleanups.push(scale.destroy); + } + const top = el(doc, 'div', 'cts-control-rail-slot'); + top.append(...(options.slots?.top ?? [])); + if (controls.length > 0) controls.push(divider()); + scroll.append(...controls, top); + footer.append(...(options.slots?.bottom ?? [])); + root.append(scroll, footer); + cleanups.push(chart.subscribeConfigChange(({ keys }) => { if (keys.includes('series')) syncType(); })); + + // ------------------------------------------------------------ boot + + const setTheme = (theme: ThemeName): void => { + for (const node of themed) node.classList.toggle('cts-light', theme === 'light'); + }; + function refresh(): void { + if (datafeed !== undefined) follow(datafeed); + syncType(); + if (scale !== null) scale.sync(); + syncTimeframes(); + } + setTheme(options.theme ?? 'dark'); + options.container.append(root); + // Paging arrows and edge fades once the controls outgrow the rail; needs the scroll column in the DOM. + cleanups.push(attachScrollableRail(doc, scroll)); + refresh(); + const unfollow = datafeed?.subscribeState?.(follow); + if (unfollow !== undefined) cleanups.push(unfollow); + + return { + element: root, + slots: { top, bottom: footer }, + get symbol() { return symbol; }, + get intervalMs() { return interval; }, + setSymbol(next): void { + symbol = next; + }, + setInterval(intervalMs): void { + interval = intervalMs; + syncTimeframes(); + }, + setChartType(type): void { + applyType(type); + }, + setTheme, + refresh, + destroy(): void { + for (const cleanup of cleanups) cleanup(); + if (menus.includes(flyouts.open as UIElement)) flyouts.close(); + for (const menu of menus) menu.remove(); + root.remove(); + if (portal !== null) { + flyouts.destroy(); + portal.remove(); + } + }, + }; +} diff --git a/packages/chart/src/ui/header.ts b/packages/chart/src/ui/header.ts index fa0c637..9a2457e 100644 --- a/packages/chart/src/ui/header.ts +++ b/packages/chart/src/ui/header.ts @@ -25,7 +25,8 @@ import type { SeriesType } from '../config.js'; import type { ThemeName } from '../themes.js'; import type { UIDocument, UIElement, UIEvent } from './host.js'; import { icon } from './icons.js'; -import { Flyouts, addCaret, el, menuItem, requireWindow, setButtonIcon } from './menu.js'; +import { Flyouts, el, requireWindow, setButtonIcon } from './menu.js'; +import { menuControls } from './menu-controls.js'; import { headerTokensFor, injectHeaderStyles, swapTokens, type HeaderTokens, type ThemedHeaderTokens } from './header-styles.js'; import { createScaleToggleGroup } from './scale-buttons.js'; @@ -169,14 +170,11 @@ const UNITS: readonly (readonly [number, string])[] = [ ]; /** Short label for an interval outside the timeframe list, e.g. `2h`. */ -function intervalLabel(ms: number): string { +export function intervalLabel(ms: number): string { for (const [size, unit] of UNITS) if (ms >= size && ms % size === 0) return `${ms / size}${unit}`; return `${ms}ms`; } -/** No-op `menuItem` handler; the header wires clicks itself so destroy can remove them. */ -const ignore = (): void => undefined; - /** Builds the header bar into `options.container`. */ export function createChartHeader(options: ChartHeaderOptions): ChartHeader { const { chart, document: doc } = options; @@ -210,63 +208,7 @@ export function createChartHeader(options: ChartHeaderOptions): ChartHeader { b.setAttribute('type', 'button'); return b; }; - /** Click toggles `menu` below `btn`; arrows, Home/End, Escape and Tab drive it from the keyboard. */ - const dropdown = (btn: UIElement, menu: UIElement, items: readonly UIElement[]): void => { - btn.classList.add('cts-flyout-btn'); - btn.setAttribute('aria-haspopup', 'menu'); - flyouts.trackExpanded(btn); - addCaret(doc, btn); - listen(btn, 'click', (e) => { - e.stopPropagation(); - flyouts.toggle(menu, btn); - // Keyboard activation (detail 0) moves focus into the menu, onto the checked entry. - if (flyouts.open === menu && e.detail === 0) (items.find((item) => item.classList.contains('cts-active')) ?? items[0]).focus(); - }); - const keys = (e: UIEvent): void => { - if (flyouts.open !== menu) return; - if (e.key === 'Tab') { - // Close, and let Tab carry on from the button rather than from the detached menu. - flyouts.close(); - if (menu.contains(e.target)) btn.focus(); - return; - } - if (e.key === 'Escape') { - flyouts.close(); - btn.focus(); - } else { - const at = items.indexOf(e.target as UIElement); - const next = e.key === 'ArrowDown' ? at + 1 : e.key === 'ArrowUp' ? (at < 0 ? items.length : at) - 1 - : e.key === 'Home' ? 0 : e.key === 'End' ? items.length - 1 : null; - if (next === null) return; - items[(next + items.length) % items.length].focus(); - } - e.preventDefault(); - e.stopPropagation(); - }; - listen(btn, 'keydown', keys); - listen(menu, 'keydown', keys); - // Escape anywhere closes it too: a menu opened by a pointer may hold no focus (Safari never focuses a clicked button). - const escape = (e: UIEvent): void => { - if (e.key !== 'Escape' || flyouts.open !== menu) return; - flyouts.close(); - btn.focus(); - }; - doc.addEventListener('keydown', escape); - cleanups.push(() => doc.removeEventListener('keydown', escape)); - }; - /** A checkable entry; picking it closes `menu` and, when focus was inside, returns it to `owner`. */ - const radioItem = (menu: UIElement, owner: UIElement, label: string, iconName: string | undefined, onPick: () => void): UIElement => { - const item = menuItem(doc, { label, tick: true, onClick: ignore, ...(iconName !== undefined ? { icon: iconName } : {}) }); - item.setAttribute('role', 'menuitemradio'); - listen(item, 'click', () => { - const refocus = menu.contains(doc.activeElement); - flyouts.close(); - onPick(); - if (refocus) owner.focus(); - }); - menu.append(item); - return item; - }; + const { dropdown, radioItem } = menuControls(doc, flyouts, cleanups); // ------------------------------------------------------------ symbol diff --git a/packages/chart/src/ui/index.ts b/packages/chart/src/ui/index.ts index 686bfdd..c5f6236 100644 --- a/packages/chart/src/ui/index.ts +++ b/packages/chart/src/ui/index.ts @@ -69,6 +69,8 @@ export type { GestureHandlers, GestureRecognizerOptions, TouchGestures, TouchGes export type { ThemeName } from '../themes.js'; export { createChartHeader, DEFAULT_TIMEFRAMES, DEFAULT_HEADER_INTERVAL_MS, DEFAULT_CHART_TYPES, CHART_TYPE_LABELS } from './header.js'; export type { ChartHeader, ChartHeaderOptions, HeaderTimeframe, HeaderDatafeed } from './header.js'; +export { createControlRail } from './control-rail.js'; +export type { ControlRail, ControlRailOptions } from './control-rail.js'; export { createScaleButtons, toggleScale, scaleToggleState, syncScaleToggles, SCALE_TOGGLES } from './scale-buttons.js'; export type { ScaleButtons, ScaleButtonsOptions, ScaleToggle, ScaleToggleState } from './scale-buttons.js'; export { HEADER_CSS, HEADER_STYLE_MARKER, BLOXWAP_HEADER_TOKENS, BLOXWAP_HEADER_THEME, injectHeaderStyles } from './header-styles.js'; diff --git a/packages/chart/src/ui/menu-controls.ts b/packages/chart/src/ui/menu-controls.ts new file mode 100644 index 0000000..1d7138c --- /dev/null +++ b/packages/chart/src/ui/menu-controls.ts @@ -0,0 +1,83 @@ +/** + * Keyboard-driven dropdown buttons and radio entries, shared by the chart + * header and the control rail so their menus behave identically. + * + * @module + */ + +import type { UIDocument, UIElement, UIEvent } from './host.js'; +import { Flyouts, addCaret, menuItem } from './menu.js'; + +/** Builders returned by {@link menuControls}. */ +export interface MenuControls { + /** Click toggles `menu` beside `btn`; arrows, Home/End, Escape and Tab drive it from the keyboard. */ + dropdown(btn: UIElement, menu: UIElement, items: readonly UIElement[]): void; + /** A checkable entry; picking it closes `menu` and, when focus was inside, returns it to `owner`. */ + radioItem(menu: UIElement, owner: UIElement, label: string, iconName: string | undefined, onPick: () => void): UIElement; +} + +/** No-op `menuItem` handler; clicks are wired here so `cleanups` can remove them. */ +const ignore = (): void => undefined; + +/** Dropdown builders on `flyouts`; every listener they add pushes its removal onto `cleanups`. */ +export function menuControls(doc: UIDocument, flyouts: Flyouts, cleanups: (() => void)[]): MenuControls { + const listen = (target: UIElement | UIDocument, type: string, fn: (e: UIEvent) => void): void => { + target.addEventListener(type, fn); + cleanups.push(() => target.removeEventListener(type, fn)); + }; + return { + dropdown(btn, menu, items): void { + btn.classList.add('cts-flyout-btn'); + btn.setAttribute('aria-haspopup', 'menu'); + flyouts.trackExpanded(btn); + addCaret(doc, btn); + listen(btn, 'click', (e) => { + e.stopPropagation(); + flyouts.toggle(menu, btn); + // Keyboard activation (detail 0) moves focus into the menu, onto the checked entry. + if (flyouts.open === menu && e.detail === 0) (items.find((item) => item.classList.contains('cts-active')) ?? items[0]).focus(); + }); + const keys = (e: UIEvent): void => { + if (flyouts.open !== menu) return; + if (e.key === 'Tab') { + // Close, and let Tab carry on from the button rather than from the detached menu. + flyouts.close(); + if (menu.contains(e.target)) btn.focus(); + return; + } + if (e.key === 'Escape') { + flyouts.close(); + btn.focus(); + } else { + const at = items.indexOf(e.target as UIElement); + const next = e.key === 'ArrowDown' ? at + 1 : e.key === 'ArrowUp' ? (at < 0 ? items.length : at) - 1 + : e.key === 'Home' ? 0 : e.key === 'End' ? items.length - 1 : null; + if (next === null) return; + items[(next + items.length) % items.length].focus(); + } + e.preventDefault(); + e.stopPropagation(); + }; + listen(btn, 'keydown', keys); + listen(menu, 'keydown', keys); + // Escape anywhere closes it too: a menu opened by a pointer may hold no focus (Safari never focuses a clicked button). + listen(doc, 'keydown', (e) => { + if (e.key !== 'Escape' || flyouts.open !== menu) return; + flyouts.close(); + btn.focus(); + }); + }, + radioItem(menu, owner, label, iconName, onPick): UIElement { + const item = menuItem(doc, { label, tick: true, onClick: ignore, ...(iconName !== undefined ? { icon: iconName } : {}) }); + item.setAttribute('role', 'menuitemradio'); + listen(item, 'click', () => { + const refocus = menu.contains(doc.activeElement); + flyouts.close(); + onPick(); + if (refocus) owner.focus(); + }); + menu.append(item); + return item; + }, + }; +} diff --git a/packages/chart/src/ui/styles.ts b/packages/chart/src/ui/styles.ts index 085bc7c..d25bb48 100644 --- a/packages/chart/src/ui/styles.ts +++ b/packages/chart/src/ui/styles.ts @@ -131,6 +131,14 @@ export const TOOLBAR_CSS = ` @media (hover: none) { .cts-rail-scroll-button { opacity: 1; pointer-events: auto; } } .cts-rail-divider { width: 24px; height: 1px; flex: none; background: var(--cts-edge); margin: 4px 0; } +.cts-rail-footer { flex: none; width: 100%; display: flex; flex-direction: column; align-items: center; gap: 4px; } + +/* Control rail: the header's controls stacked; text toggles fit the 36px buttons, a pressed toggle takes the accent. */ +.cts-control-rail-group { flex: none; display: flex; flex-direction: column; align-items: center; gap: 4px; } +.cts-control-rail-slot { display: contents; } +.cts-control-rail-group > .cts-btn, .cts-control-rail-slot > .cts-btn { left: -4px; } +.cts-control-rail .cts-btn-label, .cts-control-rail .cts-rail-scale { font: 500 11px/1 var(--cts-mono); } +.cts-rail-scale.cts-on { color: var(--cts-accent); background: var(--cts-accent-soft); } /* ---- buttons ---- */ .cts-btn { diff --git a/packages/chart/test/control-rail.test.ts b/packages/chart/test/control-rail.test.ts new file mode 100644 index 0000000..da830ba --- /dev/null +++ b/packages/chart/test/control-rail.test.ts @@ -0,0 +1,319 @@ +import { after, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { Window, type Element, type HTMLElement } from 'happy-dom'; +import { MockContext2D, createChart, type Candle, type ChartCanvas } from '../dist/index.js'; +import { + CHART_TYPE_LABELS, + DEFAULT_CHART_TYPES, + DEFAULT_TIMEFRAMES, + HOVER_OPEN_MS, + STYLE_MARKER, + TOOLBAR_CSS, + createChartHeader, + createControlRail, + createDrawingToolbar, + type ControlRailOptions, + type UIDocument, + type UIElement, +} from '../dist/ui/index.js'; + +const data: Candle[] = Array.from({ length: 40 }, (_, i) => ({ time: 1_700_000_000 + i * 900, open: 100 + i, high: 104 + i, low: 98 + i, close: 102 + i, volume: 10 })); +const windows: Window[] = []; +after(() => { for (const win of windows) void win.happyDOM.close(); }); + +function mount(options: Partial> = {}) { + const win = new Window({ url: 'http://localhost/', width: 1200, height: 800 }); + windows.push(win); + const doc = win.document; + const canvas: ChartCanvas = { width: 640, height: 400, getContext: () => new MockContext2D() }; + const chart = createChart({ container: canvas, config: { wasm: false, data } }); + const host = doc.createElement('div'); + doc.body.append(host); + const rail = createControlRail({ chart, document: doc as unknown as UIDocument, container: host as unknown as UIElement, ...options }); + const root = rail.element as unknown as HTMLElement; + const q = (selector: string) => doc.querySelector(selector) as unknown as T; + const qa = (selector: string) => [...doc.querySelectorAll(selector)] as unknown as HTMLElement[]; + const click = (node: Element, detail = 1) => node.dispatchEvent(new win.MouseEvent('click', { bubbles: true, detail })); + const key = (node: Element, k: string) => { + const event = new win.KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }); + node.dispatchEvent(event); + return event; + }; + const menus = () => qa('.cts-control-rail-menu'); + const item = (menu: HTMLElement, label: string) => [...menu.querySelectorAll('.cts-item')].find((b) => b.textContent === label) as unknown as HTMLElement; + const tfBtn = () => q('.cts-control-rail .cts-rail-scroll > .cts-btn'); + return { win, doc, chart, host, rail, root, q, qa, click, key, menus, item, tfBtn }; +} + +type State = { symbol: string | null; intervalMs: number | null }; +function feed(initial: State, subscribe = true) { + const calls: [string, number][] = []; + const listeners = new Set<(state: State) => void>(); + const df = { + ...initial, + setSymbol(symbol: string, intervalMs: number) { calls.push([symbol, intervalMs]); df.emit({ symbol, intervalMs }); }, + emit(state: State) { df.symbol = state.symbol; df.intervalMs = state.intervalMs; for (const l of listeners) l(state); }, + ...(subscribe ? { subscribeState(l: (state: State) => void) { listeners.add(l); return () => listeners.delete(l); } } : {}), + }; + return { df, calls, listeners }; +} + +describe('control rail: layout', () => { + it('stacks timeframe, chart type, indicators, scale toggles and slots in a nav rail', () => { + const win = new Window(); + windows.push(win); + const top = [win.document.createElement('button')] as unknown as UIElement[]; + const bottom = [win.document.createElement('button'), win.document.createElement('button')] as unknown as UIElement[]; + let indicators = 0; + const m = mount({ onIndicators: () => { indicators++; }, slots: { top, bottom } }); + assert.equal(m.root.parentElement, m.host as unknown as HTMLElement, 'appended to the container'); + assert.equal(m.root.tagName, 'NAV'); + assert.equal(m.root.getAttribute('aria-label'), 'Chart controls'); + assert.ok(['cts-theme', 'cts-rail', 'cts-control-rail'].every((c) => m.root.classList.contains(c))); + assert.ok(m.doc.head.querySelector(`style[${STYLE_MARKER}]`), 'toolbar styles injected'); + + const scroll = m.q('.cts-control-rail .cts-rail-scroll'); + assert.ok(scroll.parentElement!.classList.contains('cts-rail-scroll-wrap'), 'scrolls with paging arrows'); + const kinds = [...scroll.children].map((c) => c.getAttribute('aria-label') ?? c.className); + assert.deepEqual(kinds, ['Timeframe: 15m', 'Chart type: Candles', 'Indicators', 'cts-rail-divider', 'Price scale', 'cts-rail-divider', 'cts-control-rail-slot']); + assert.deepEqual([...m.q('.cts-control-rail-group').children].map((b) => b.textContent), ['Auto', '%', 'Log']); + assert.ok(m.q('.cts-control-rail-group').querySelector('.cts-rail-scale.cts-btn')); + assert.equal(m.rail.slots.top as unknown, (top[0] as unknown as HTMLElement).parentElement as unknown, 'top slot follows the controls'); + assert.deepEqual([...(m.rail.slots.bottom as unknown as HTMLElement).children], bottom as unknown as HTMLElement[]); + assert.ok((m.rail.slots.bottom as unknown as HTMLElement).classList.contains('cts-rail-footer')); + assert.equal((m.rail.slots.bottom as unknown as HTMLElement).parentElement, m.root, 'footer pinned outside the scroll'); + + const indicatorsBtn = m.q('[aria-label="Indicators"]'); + assert.equal(indicatorsBtn.getAttribute('aria-haspopup'), 'dialog'); + m.click(indicatorsBtn); + assert.equal(indicators, 1); + assert.equal(m.menus().length, 2, 'timeframe and chart-type menus'); + assert.equal(m.menus()[0]!.querySelector('.cts-menu-label')!.textContent, 'Timeframe'); + assert.deepEqual([...m.menus()[0]!.querySelectorAll('.cts-item')].map((i) => i.textContent), DEFAULT_TIMEFRAMES.map((tf) => tf.label)); + assert.deepEqual([...m.menus()[1]!.querySelectorAll('.cts-item')].map((i) => i.textContent), DEFAULT_CHART_TYPES.map((t) => CHART_TYPE_LABELS[t])); + assert.ok(m.tfBtn().querySelector('.cts-caret'), 'menu buttons carry the disclosure chevron'); + assert.equal(m.tfBtn().getAttribute('aria-haspopup'), 'menu'); + + m.rail.destroy(); + m.click(indicatorsBtn); + assert.equal(indicators, 1, 'destroy removes the listener'); + assert.equal(m.root.parentElement, null); + assert.equal(m.menus().length, 0, 'menus removed'); + }); + + it('hides what is switched off and skips dividers with nothing to separate', () => { + const bare = mount({ timeframes: [], chartTypes: [], scaleButtons: false }); + assert.deepEqual([...bare.q('.cts-rail-scroll').children].map((c) => c.className), ['cts-control-rail-slot']); + assert.equal((bare.rail.slots.top as unknown as HTMLElement).children.length, 0); + assert.equal((bare.rail.slots.bottom as unknown as HTMLElement).children.length, 0); + + const scaleOnly = mount({ timeframes: [], chartTypes: [] }); + assert.deepEqual([...scaleOnly.q('.cts-rail-scroll').children].map((c) => c.className), + ['cts-control-rail-group', 'cts-rail-divider', 'cts-control-rail-slot'], 'no leading divider'); + + const noScale = mount({ scaleButtons: false }); + assert.deepEqual([...noScale.q('.cts-rail-scroll').children].map((c) => c.className.split(' ')[0]), + ['cts-btn', 'cts-btn', 'cts-rail-divider', 'cts-control-rail-slot']); + }); + + it('ships its layout in the toolbar stylesheet', () => { + assert.match(TOOLBAR_CSS, /\.cts-control-rail-slot \{ display: contents; \}/); + assert.match(TOOLBAR_CSS, /\.cts-rail-scale\.cts-on \{ color: var\(--cts-accent\); background: var\(--cts-accent-soft\); \}/); + assert.match(TOOLBAR_CSS, /\.cts-rail-footer \{[^}]*flex-direction: column/); + }); +}); + +describe('control rail: timeframes', () => { + it('picks from the menu, calling the datafeed and onTimeframeChange once per change', () => { + const picks: string[] = []; + const calls: [string, number][] = []; + const m = mount({ symbol: 'BTC', datafeed: { setSymbol: (s, ms) => { calls.push([s, ms]); } }, onTimeframeChange: (tf) => picks.push(tf.label) }); + const menu = m.menus()[0]!; + assert.equal(m.item(menu, '15m').getAttribute('aria-checked'), 'true'); + m.click(m.tfBtn()); + assert.ok(menu.classList.contains('cts-open')); + m.click(m.item(menu, '1h')); + assert.ok(!menu.classList.contains('cts-open'), 'a pick closes the menu'); + assert.equal(m.rail.intervalMs, 3_600_000); + assert.equal(m.tfBtn().querySelector('.cts-btn-label')!.textContent, '1h'); + assert.equal(m.tfBtn().title, 'Timeframe: 1h'); + assert.equal(m.item(menu, '1h').getAttribute('aria-checked'), 'true'); + assert.equal(m.item(menu, '15m').getAttribute('aria-checked'), 'false'); + m.click(m.item(menu, '1h')); + assert.deepEqual(calls, [['BTC', 3_600_000]], 'the selected interval is a no-op'); + assert.deepEqual(picks, ['1h']); + }); + + it('labels an interval outside the list, and without a symbol calls no datafeed', () => { + const calls: [string, number][] = []; + const m = mount({ intervalMs: 7_200_000, datafeed: { setSymbol: (s, ms) => { calls.push([s, ms]); } } }); + assert.equal(m.tfBtn().querySelector('.cts-btn-label')!.textContent, '2h'); + assert.ok(m.menus()[0]!.querySelectorAll('[aria-checked="true"]').length === 0); + m.click(m.item(m.menus()[0]!, '4h')); + assert.deepEqual(calls, [], 'no symbol known yet'); + m.rail.setSymbol('SOL'); + assert.equal(m.rail.symbol, 'SOL'); + m.click(m.item(m.menus()[0]!, '1d')); + assert.deepEqual(calls, [['SOL', 86_400_000]]); + m.rail.setInterval(60_000); + assert.equal(m.tfBtn().title, 'Timeframe: 1m'); + assert.deepEqual(calls.length, 1, 'setInterval does not call the datafeed'); + const bare = mount({ timeframes: [{ label: 'Tick', intervalMs: 1000 }] }); + bare.click(bare.item(bare.menus()[0]!, 'Tick')); + assert.equal(bare.rail.intervalMs, 1000, 'no datafeed: the pick still shows'); + }); + + it('drives the menu from the keyboard like the header', () => { + const m = mount(); + const btn = m.tfBtn(); + btn.focus(); + m.click(btn, 0); + assert.equal(m.doc.activeElement?.textContent, '15m', 'keyboard open focuses the checked entry'); + m.key(m.doc.activeElement!, 'ArrowDown'); + assert.equal(m.doc.activeElement?.textContent, '1h'); + m.key(m.doc.activeElement!, 'Escape'); + assert.equal(m.doc.activeElement, btn); + assert.ok(!m.menus()[0]!.classList.contains('cts-open')); + }); +}); + +describe('control rail: datafeed as the source of truth', () => { + it('starts from the datafeed, reloads its symbol and follows its switches', () => { + const f = feed({ symbol: 'ETH', intervalMs: 3_600_000 }); + const m = mount({ symbol: 'BTC', datafeed: f.df }); + assert.equal(m.rail.symbol, 'ETH', 'the loaded symbol wins over an explicit one'); + assert.equal(m.rail.intervalMs, 3_600_000, 'the loaded interval wins over the 15m default'); + m.click(m.item(m.menus()[0]!, '4h')); + assert.deepEqual(f.calls, [['ETH', 14_400_000]], 'reloads what the datafeed has loaded'); + assert.equal(m.rail.symbol, 'ETH', 'follows the reported symbol'); + f.df.emit({ symbol: 'SOL', intervalMs: 60_000 }); + assert.equal(m.rail.intervalMs, 60_000); + assert.equal(m.tfBtn().title, 'Timeframe: 1m'); + f.df.emit({ symbol: null, intervalMs: null }); + assert.equal(m.rail.symbol, 'SOL', 'a cleared state keeps the last symbol'); + assert.equal(m.rail.intervalMs, 60_000); + m.rail.destroy(); + assert.equal(f.listeners.size, 0, 'destroy unsubscribes'); + }); + + it('refresh() catches up with a datafeed that cannot be subscribed to', () => { + const f = feed({ symbol: null, intervalMs: null }, false); + const m = mount({ datafeed: f.df }); + assert.equal(m.rail.symbol, undefined); + assert.equal(m.rail.intervalMs, 900_000); + f.df.symbol = 'BTC'; + f.df.intervalMs = 300_000; + assert.equal(m.rail.intervalMs, 900_000, 'stale until refreshed'); + m.rail.refresh(); + assert.equal(m.rail.symbol, 'BTC'); + assert.equal(m.rail.intervalMs, 300_000); + m.rail.refresh(); + assert.equal(m.rail.intervalMs, 300_000, 'an unchanged interval is kept'); + }); +}); + +describe('control rail: chart type', () => { + it('applies picks, follows config changes and keeps the candle glyph for a custom series', () => { + const picks: string[] = []; + const m = mount({ onChartTypeChange: (t) => picks.push(t) }); + const btn = () => m.q('.cts-control-rail .cts-rail-scroll > .cts-btn:nth-child(2)'); + const menu = m.menus()[1]!; + m.click(m.item(menu, 'Line')); + assert.equal(m.chart.getConfig().series.type, 'line'); + assert.equal(btn().title, 'Chart type: Line'); + assert.equal(m.item(menu, 'Line').getAttribute('aria-checked'), 'true'); + m.click(m.item(menu, 'Line')); + assert.deepEqual(picks, ['line'], 'the current type is a no-op'); + m.rail.setChartType('bar'); + assert.equal(m.chart.getConfig().series.type, 'bar'); + assert.deepEqual(picks, ['line'], 'setChartType does not call onChartTypeChange'); + m.chart.updateConfig({ series: { type: 'area' } }); + m.chart.render(); + assert.equal(btn().title, 'Chart type: Area', 'follows rendered config changes'); + m.chart.registerSeries('footprint', () => undefined); + m.chart.updateConfig({ series: { type: 'footprint' as never } }); + m.rail.refresh(); + assert.equal(btn().title, 'Chart type: footprint'); + assert.ok(btn().querySelector('svg'), 'a type without a glyph keeps the candlestick'); + assert.ok(btn().querySelector('.cts-caret'), 'the chevron survives icon swaps'); + assert.ok(menu.querySelectorAll('[aria-checked="true"]').length === 0); + }); + + it('offers only the given types', () => { + const m = mount({ chartTypes: ['candlestick', 'line'] }); + assert.deepEqual([...m.menus()[1]!.querySelectorAll('.cts-item')].map((i) => i.textContent), ['Candles', 'Line']); + }); +}); + +describe('control rail: scale toggles', () => { + it('toggles the price axis and follows it', () => { + const m = mount(); + const [auto, percent, log] = [...m.q('.cts-control-rail-group').children] as HTMLElement[]; + assert.ok(auto!.classList.contains('cts-on'), 'autoScale is on by default'); + m.click(log!); + assert.equal(m.chart.getConfig().priceAxis.mode, 'logarithmic'); + assert.equal(log!.getAttribute('aria-pressed'), 'true'); + m.click(percent!); + assert.equal(log!.getAttribute('aria-pressed'), 'false', '% and Log exclude each other'); + m.rail.destroy(); + m.click(auto!); + assert.equal(m.chart.getConfig().priceAxis.autoScale, true, 'destroy removes the toggles\' listeners'); + }); +}); + +describe('control rail: flyouts and theme', () => { + it('owns a themed portal by default and removes it on destroy', () => { + const m = mount({ theme: 'light' }); + const portal = m.menus()[0]!.parentElement!; + assert.equal(portal.parentElement, m.doc.body as unknown as HTMLElement); + assert.ok(portal.classList.contains('cts-light') && m.root.classList.contains('cts-light')); + m.rail.setTheme('dark'); + assert.ok(!portal.classList.contains('cts-light') && !m.root.classList.contains('cts-light')); + m.click(m.tfBtn()); + m.rail.destroy(); + assert.equal(portal.parentElement, null); + }); + + it('shares the toolbar flyouts, one menu open at a time, and leaves them usable after destroy', () => { + const win = new Window({ url: 'http://localhost/' }); + windows.push(win); + const doc = win.document; + const canvas: ChartCanvas = { width: 640, height: 400, getContext: () => new MockContext2D() }; + const chart = createChart({ container: canvas, config: { wasm: false, data } }); + const stage = doc.createElement('div'); + const tools = doc.createElement('div'); + const host = doc.createElement('div'); + stage.append(doc.createElement('div')); + doc.body.append(host, tools, stage); + const toolbar = createDrawingToolbar({ chart, document: doc as unknown as UIDocument, canvas: stage.firstElementChild as unknown as UIElement, + rail: tools as unknown as UIElement, overlay: stage as unknown as UIElement, navigation: false, applyChartTheme: false }); + const before = doc.body.children.length; + const rail = createControlRail({ chart, document: doc as unknown as UIDocument, container: host as unknown as UIElement, flyouts: toolbar.flyouts, theme: 'light' }); + assert.equal(doc.body.children.length, before, 'no extra portal'); + const header = createChartHeader({ chart, document: doc as unknown as UIDocument, container: host as unknown as UIElement, flyouts: toolbar.flyouts }); + const menus = [...doc.querySelectorAll('.cts-control-rail-menu')] as unknown as HTMLElement[]; + assert.ok(menus.every((menu) => menu.parentElement === toolbar.flyouts.portal as unknown as HTMLElement)); + const railBtn = doc.querySelector('.cts-control-rail .cts-rail-scroll > .cts-btn') as unknown as HTMLElement; + railBtn.dispatchEvent(new win.MouseEvent('click', { bubbles: true })); + assert.equal(toolbar.flyouts.open, menus[0] as unknown as UIElement); + (doc.querySelector('.cts-header-type') as unknown as HTMLElement).dispatchEvent(new win.MouseEvent('click', { bubbles: true })); + assert.notEqual(toolbar.flyouts.open, menus[0] as unknown as UIElement, 'the header menu replaces the rail menu'); + header.destroy(); + railBtn.dispatchEvent(new win.MouseEvent('click', { bubbles: true })); + rail.destroy(); + assert.equal(toolbar.flyouts.open, null, 'destroy closes its open menu'); + assert.ok(!(toolbar.flyouts.portal as unknown as HTMLElement).classList.contains('cts-light'), 'the shared portal belongs to the toolbar'); + const menu = toolbar.flyouts.create(); + toolbar.flyouts.show(menu, tools as unknown as UIElement); + assert.equal(toolbar.flyouts.open, menu, 'the shared flyouts still work'); + toolbar.destroy(); + }); + + it('opens a menu on mouse hover, like the drawing rail', async () => { + const m = mount(); + m.tfBtn().dispatchEvent(new m.win.PointerEvent('pointerenter', { pointerType: 'mouse' })); + await new Promise((resolve) => setTimeout(resolve, HOVER_OPEN_MS + 40)); + assert.ok(m.menus()[0]!.classList.contains('cts-open')); + m.rail.destroy(); + }); +});