From 577746f747b600f22c991108d7d82f2e436fadf5 Mon Sep 17 00:00:00 2001 From: adrianspdev Date: Thu, 22 Oct 2026 12:18:22 +0200 Subject: [PATCH 01/35] Color named expressions in the idle formula bar the same way as in the editor The idle formula bar tokenized without resolving named expressions, so a named expression did not take a reference color slot there while it did in the cell editor, and every later reference was shown in a different color. The idle view now runs the same named-expression annotation as the editor and re-renders when a named expression is added or removed. --- hyperformula-ui/dev-docs/ARCHITECTURE.md | 6 +- .../packages/core/src/engine/engineBridge.ts | 4 + .../core/src/engine/namedExpressions.ts | 14 ++- .../core/src/formulaBar/formulaBar.ts | 7 ++ .../core/src/formulaBar/formulaBarCell.ts | 22 +++- .../core/tests/formulaBar/formula-bar.test.ts | 119 ++++++++++++++++++ 6 files changed, 168 insertions(+), 4 deletions(-) diff --git a/hyperformula-ui/dev-docs/ARCHITECTURE.md b/hyperformula-ui/dev-docs/ARCHITECTURE.md index e7cd6337f..46ef79aa4 100644 --- a/hyperformula-ui/dev-docs/ARCHITECTURE.md +++ b/hyperformula-ui/dev-docs/ARCHITECTURE.md @@ -78,7 +78,11 @@ calls `adapter.setActiveCell` + `scrollCellIntoView` for a single cell or `adapt for an `A1:B5` range, and mirrors the current `GridSelection` via `setSelection()` — and `FormulaBarCell` (`formula-cell` scope) — the fx-icon + formula area, owning both the idle read-only colored-formula view (its own `VisualOverlay` seeded with `FormulaEditor.knownFunctionNames`, rendered via `renderValue()` through -`nToComments`) and the `overlayMount` the shared editor `attachTo`'s while bar-hosted. +`nToComments`) and the `overlayMount` the shared editor `attachTo`'s while bar-hosted. The idle +view's tokens go through the same `annotateNamedExprs` step as the editor's kernel +(`FormulaBarCellOptions.tokenPostProcess`), so a named expression takes a reference color slot +in both and every later reference keeps the same color; `FormulaBar` re-renders the idle view +through `FormulaBarCell.refresh()` when `EngineBridge.onNamedExpressionsChange` fires. While the editor is inline, `FormulaBarCell`'s idle view shows a live colored mirror of the current text. Because the wrap styles are shared, a bar-hosted editor soft-wraps at the bar diff --git a/hyperformula-ui/packages/core/src/engine/engineBridge.ts b/hyperformula-ui/packages/core/src/engine/engineBridge.ts index f1cd3407a..d2127ceee 100644 --- a/hyperformula-ui/packages/core/src/engine/engineBridge.ts +++ b/hyperformula-ui/packages/core/src/engine/engineBridge.ts @@ -171,6 +171,10 @@ export class EngineBridge { this.namedExprs.invalidate(); } + onNamedExpressionsChange(listener: () => void): () => void { + return this.namedExprs.onChange(listener); + } + destroy(): void { this.namedExprs.destroy(); } diff --git a/hyperformula-ui/packages/core/src/engine/namedExpressions.ts b/hyperformula-ui/packages/core/src/engine/namedExpressions.ts index 9aa9303cc..6cf2914fe 100644 --- a/hyperformula-ui/packages/core/src/engine/namedExpressions.ts +++ b/hyperformula-ui/packages/core/src/engine/namedExpressions.ts @@ -1,3 +1,4 @@ +import { ListenerSet } from "../events/listenerSet"; import { indexToColLetters, quoteSheetName } from "../lexer/tokens"; import { expandWholeSpan } from "./namedExpr"; import type { HyperFormulaLike, NamedExprResolution } from "./types"; @@ -18,7 +19,8 @@ export class NamedExpressions { private readonly sheetId: number; private scopeByUpperName: Map | null = null; private resolveCache = new Map(); - private readonly onNamedExpressionChanged = (): void => this.invalidate(); + private readonly changeListeners = new ListenerSet(); + private readonly onNamedExpressionChanged = (): void => this.handleNamedExpressionChanged(); constructor(engine: HyperFormulaLike | undefined, sheetId: number) { this.engine = engine; @@ -56,6 +58,11 @@ export class NamedExpressions { return this.engine?.getSheetName?.(sheetId) ?? ""; } + private handleNamedExpressionChanged(): void { + this.invalidate(); + this.changeListeners.emit(); + } + private resolveUncached(name: string): NamedExprResolution | null { if (!this.engine?.getNamedExpressionFormula) { return null; @@ -162,7 +169,12 @@ export class NamedExpressions { this.resolveCache.clear(); } + onChange(listener: () => void): () => void { + return this.changeListeners.add(listener); + } + destroy(): void { + this.changeListeners.clear(); this.engine?.off?.("namedExpressionAdded", this.onNamedExpressionChanged); this.engine?.off?.("namedExpressionRemoved", this.onNamedExpressionChanged); } diff --git a/hyperformula-ui/packages/core/src/formulaBar/formulaBar.ts b/hyperformula-ui/packages/core/src/formulaBar/formulaBar.ts index 9813e7886..6da971b7a 100644 --- a/hyperformula-ui/packages/core/src/formulaBar/formulaBar.ts +++ b/hyperformula-ui/packages/core/src/formulaBar/formulaBar.ts @@ -1,5 +1,6 @@ import { singleCellSelection } from "../adapter/range"; import type { GridSelection, IGridAdapter } from "../adapter/types"; +import { annotateNamedExprs } from "../engine/namedExpr"; import { EventManager } from "../events/eventManager"; import type { Disposable } from "../events/types"; import type { FormulaEditor } from "../formulaEditor/formulaEditor"; @@ -98,6 +99,8 @@ export class FormulaBar { this.formulaCell = new FormulaBarCell({ knownFunctions: options.knownFunctions, separators: options.editor.engine.getSeparators(), + tokenPostProcess: (tokens) => + annotateNamedExprs(tokens, (name) => options.editor.engine.resolveNamedExpression(name)), onActivate: () => this.activateFromIdle(), ...(options.icons !== undefined && { icons: options.icons }), }); @@ -139,6 +142,10 @@ export class FormulaBar { ), ); + subsScope.add( + asDisposable(this.editor.engine.onNamedExpressionsChange(() => this.formulaCell.refresh())), + ); + subsScope.add( asDisposable( this.editor.on("focus", (payload) => { diff --git a/hyperformula-ui/packages/core/src/formulaBar/formulaBarCell.ts b/hyperformula-ui/packages/core/src/formulaBar/formulaBarCell.ts index 4c537ec6a..ba825686f 100644 --- a/hyperformula-ui/packages/core/src/formulaBar/formulaBarCell.ts +++ b/hyperformula-ui/packages/core/src/formulaBar/formulaBarCell.ts @@ -1,7 +1,7 @@ import { EventManager } from "../events"; import { nToComments } from "../kernel/commentTransform"; import { Lexer } from "../lexer"; -import type { LexerOptions } from "../lexer/types"; +import type { LexerOptions, Token } from "../lexer/types"; import { CN } from "../theme/classNames"; import { createEl, createIcon } from "../ui/dom"; import { VisualOverlay } from "../ui/text/visualOverlay"; @@ -28,6 +28,12 @@ export interface FormulaBarCellOptions { /** Host factories replacing the built-in icons; the cell renders the `function` icon. */ icons?: IconsOptions; + + /** + * Post-processes the lexed tokens before they render, the same step the editor's kernel + * applies, so the idle view and the editor color references alike. + */ + tokenPostProcess?: (tokens: Token[], text: string) => Token[]; } /** @@ -42,6 +48,8 @@ export class FormulaBarCell { private readonly events = new EventManager(); private readonly lexer: Lexer; private readonly separators: Partial | undefined; + private readonly tokenPostProcess: ((tokens: Token[], text: string) => Token[]) | undefined; + private lastValue = ""; private readonly idleOverlay: VisualOverlay; private readonly fxIcon: Element; private readonly bodyEl: HTMLElement; @@ -50,6 +58,7 @@ export class FormulaBarCell { constructor(options: FormulaBarCellOptions) { this.separators = options.separators; + this.tokenPostProcess = options.tokenPostProcess; this.lexer = new Lexer(options.separators); this.root = createEl("div", { class: `${CN.formulaBarCell} ${CN.formulaBarCellFormula}` }); this.fxIcon = createIcon("function", options.icons); @@ -92,14 +101,23 @@ export class FormulaBarCell { * be invisible work. */ renderValue(text: string): void { + this.lastValue = text; + if (!this.overlayEl.hidden) { return; } const display = nToComments(text, this.separators); + const lexed = this.lexer.tokenize(display); + const tokens = this.tokenPostProcess ? this.tokenPostProcess(lexed, display) : lexed; this.idleEl.classList.toggle(CN.editorFormula, text.startsWith("=")); - this.idleOverlay.render(this.lexer.tokenize(display), display); + this.idleOverlay.render(tokens, display); + } + + /** Re-renders the last value passed to {@link FormulaBarCell.renderValue}. */ + refresh(): void { + this.renderValue(this.lastValue); } /** Toggles between the editor overlay (`true`) and the idle view (`false`). */ diff --git a/hyperformula-ui/packages/core/tests/formulaBar/formula-bar.test.ts b/hyperformula-ui/packages/core/tests/formulaBar/formula-bar.test.ts index 2fc7b7910..f600e3477 100644 --- a/hyperformula-ui/packages/core/tests/formulaBar/formula-bar.test.ts +++ b/hyperformula-ui/packages/core/tests/formulaBar/formula-bar.test.ts @@ -1210,3 +1210,122 @@ describe("FormulaBar deferred inline-edit start", () => { editor.destroy(); }); }); + +class TaxRateHF { + private names = ["TaxRate"]; + private readonly listeners = new Map void>>(); + + private emit(event: string, name: string) { + for (const listener of this.listeners.get(event) ?? []) { + listener(name); + } + } + + listNamedExpressions(scope?: number) { + return scope === undefined ? this.names : []; + } + + getNamedExpressionFormula() { + return "=0.23"; + } + + simpleCellRangeFromString() { + return undefined; + } + + simpleCellAddressFromString() { + return undefined; + } + + getSheetName() { + return ""; + } + + on(event: string, listener: (name: string) => void) { + const set = this.listeners.get(event) ?? new Set(); + + set.add(listener); + this.listeners.set(event, set); + } + + off(event: string, listener: (name: string) => void) { + this.listeners.get(event)?.delete(listener); + } + + addNamedExpression(name: string) { + this.names = [...this.names, name]; + this.emit("namedExpressionAdded", name); + } + + removeNamedExpression(name: string) { + this.names = this.names.filter((item) => item !== name); + this.emit("namedExpressionRemoved", name); + } +} + +function refIndexByText(root: Element | null): Record { + const marks = root?.querySelectorAll("[data-ref-index]") ?? []; + + return Object.fromEntries( + Array.from(marks, (mark) => [mark.textContent ?? "", mark.getAttribute("data-ref-index")]), + ); +} + +describe("FormulaBar named expressions", () => { + function makeNamedHarness(engine: TaxRateHF, formula: string) { + const container = document.createElement("div"); + const { adapter } = makeBarAdapter({ + getActiveCell: activeCellAtOrigin, + getCellFormula: () => formula, + }); + const editor = new FormulaEditorImpl(document.createElement("div"), { + popupCtx: sharedPopupCtx, + adapter, + hyperformula: engine as never, + sheetId: 0, + onCommit: (value) => adapter.commit(value), + onCancel: () => adapter.cancel(), + }); + const controller = makeFormulaBar(container, { + adapter, + editor, + knownFunctions: new Set(["SUM"]), + }); + + return { container, editor, controller }; + } + + it("assigns the idle view the same reference colors as the editor", () => { + const { container, editor, controller } = makeNamedHarness( + new TaxRateHF(), + "=B3*(1+TaxRate)+23+SUM(B2:B3)", + ); + + const idle = refIndexByText(placeholderEl(container)); + const inEditor = refIndexByText(container.querySelector(".hfe-editor")); + + expect(idle.TaxRate).not.toBeUndefined(); + expect(idle).toEqual(inEditor); + + controller.destroy(); + editor.destroy(); + }); + + it("recolors the idle view when a named expression is added or removed", () => { + const engine = new TaxRateHF(); + const { container, editor, controller } = makeNamedHarness(engine, "=Rate+B2"); + + expect(refIndexByText(placeholderEl(container)).Rate).toBeUndefined(); + + engine.addNamedExpression("Rate"); + expect(refIndexByText(placeholderEl(container)).Rate).toBe("0"); + expect(refIndexByText(placeholderEl(container)).B2).toBe("1"); + + engine.removeNamedExpression("Rate"); + expect(refIndexByText(placeholderEl(container)).Rate).toBeUndefined(); + expect(refIndexByText(placeholderEl(container)).B2).toBe("0"); + + controller.destroy(); + editor.destroy(); + }); +}); From f0e03a68e9ba6cc645ce550fc9cb6088faa61072 Mon Sep 17 00:00:00 2001 From: adrianspdev Date: Thu, 22 Oct 2026 12:19:51 +0200 Subject: [PATCH 02/35] Let FormulaBuilder.destroy() finish after the host destroyed the engine Unsubscribing from a destroyed HyperFormula instance throws, and the first throw in FormulaBuilder.destroy() skipped the rest of the teardown, leaving the popup portal, the live region and the injected stylesheet in the page. The named-expression unsubscribe now tolerates a destroyed engine, and every teardown step runs even when one throws; the first error is rethrown at the end. --- hyperformula-ui/dev-docs/SPEC.md | 2 +- .../core/src/engine/namedExpressions.ts | 7 ++- .../packages/core/src/formulaBuilder.ts | 38 ++++++++---- .../core/tests/formula-builder.test.ts | 59 +++++++++++++++++++ 4 files changed, 93 insertions(+), 13 deletions(-) diff --git a/hyperformula-ui/dev-docs/SPEC.md b/hyperformula-ui/dev-docs/SPEC.md index 69996589e..c58af3de7 100644 --- a/hyperformula-ui/dev-docs/SPEC.md +++ b/hyperformula-ui/dev-docs/SPEC.md @@ -275,7 +275,7 @@ No adapter ships in this repository; see §1. An adapter implements `IGridAdapte - **Optional** peer dep. `EngineBridge` accepts the HF instance via `hyperformula`. Degrades to a no-op when no instance is supplied. - Calls used (all optional, duck-typed, feature-detected): instance `getAvailableFunctions()` / `getFunctionDetails(canonicalName)` (HF instance-metadata API, 3.4.0+) with `getRegisteredFunctionNames()` / `getFunctionPlugin(functionId)` fallbacks (public instance methods, available in HF 3.3.0+) — no language argument; HF translates names via the instance's configured language (§5); `getConfig()` for language + separators; `simpleCellAddressFromString` / `simpleCellRangeFromString`; `getSheetId(name)` / `getSheetName(id)`; `getCellValue(address)`; `listNamedExpressions(scope?)`, `getNamedExpressionFormula(name, scope?)`, and `getSheetDimensions(sheetId)` for named-expression resolution (§5a); `on` / `off` for `valuesUpdated` and named-expression add/remove events (cache invalidation + commit-error capture). - Against HyperFormula 3.0.0–3.3.0 (no instance-metadata API): the editor degrades to a names-only catalog (`getRegisteredFunctionNames()` fallback) and plugin-shaped signatures (`getFunctionPlugin(functionId)` fallback). -- The caller owns the HF instance; the editor never destroys it. +- The caller owns the HF instance; the editor never destroys it. The caller may destroy it before the builder: unsubscribing from a destroyed instance throws, so the engine bridge swallows that error, and `FormulaBuilder.destroy()` runs every teardown step even when one throws, rethrowing the first error at the end, so no popup portal, live region or injected stylesheet is left behind. - The engine type in the public surface is duck-typed (`HyperFormulaLike` in `engine/types.ts`), not imported from `hyperformula` — keeps the editor decoupled from the engine version. - `EngineBridge` is the only HF access point: raw `HyperFormulaLike` is never touched outside `src/engine/`. diff --git a/hyperformula-ui/packages/core/src/engine/namedExpressions.ts b/hyperformula-ui/packages/core/src/engine/namedExpressions.ts index 6cf2914fe..d395a9621 100644 --- a/hyperformula-ui/packages/core/src/engine/namedExpressions.ts +++ b/hyperformula-ui/packages/core/src/engine/namedExpressions.ts @@ -175,7 +175,10 @@ export class NamedExpressions { destroy(): void { this.changeListeners.clear(); - this.engine?.off?.("namedExpressionAdded", this.onNamedExpressionChanged); - this.engine?.off?.("namedExpressionRemoved", this.onNamedExpressionChanged); + + try { + this.engine?.off?.("namedExpressionAdded", this.onNamedExpressionChanged); + this.engine?.off?.("namedExpressionRemoved", this.onNamedExpressionChanged); + } catch {} } } diff --git a/hyperformula-ui/packages/core/src/formulaBuilder.ts b/hyperformula-ui/packages/core/src/formulaBuilder.ts index c06efe544..2f4be8698 100644 --- a/hyperformula-ui/packages/core/src/formulaBuilder.ts +++ b/hyperformula-ui/packages/core/src/formulaBuilder.ts @@ -378,7 +378,9 @@ export class FormulaBuilder { /** * Tears down everything the builder owns (bar, editor, popups, subscriptions) and clears * the theming class from the adapter overlay. Idempotent. The adapter itself is host-owned - * and is NOT destroyed. + * and is NOT destroyed. Safe to call after the host has destroyed the HyperFormula + * instance. A step that throws does not stop the others; the first error is rethrown once + * every step has run. */ destroy(): void { if (this.destroyed) { @@ -386,17 +388,33 @@ export class FormulaBuilder { } this.destroyed = true; - this.events.disposeAll(); - this.formulaBar?.destroy(); - this.formulaEditor.destroy(); - this.errorPopup.destroy(); - this.liveRegion.destroy(); - this.popupCtx.destroy(); - if (this.sharedClassName !== undefined) { - this.adapter.setOverlayClassName(null); + const steps: Array<() => void> = [ + () => this.events.disposeAll(), + () => this.formulaBar?.destroy(), + () => this.formulaEditor.destroy(), + () => this.errorPopup.destroy(), + () => this.liveRegion.destroy(), + () => this.popupCtx.destroy(), + () => { + if (this.sharedClassName !== undefined) { + this.adapter.setOverlayClassName(null); + } + }, + () => releaseStyles(), + ]; + const errors: unknown[] = []; + + for (const step of steps) { + try { + step(); + } catch (error) { + errors.push(error); + } } - releaseStyles(); + if (errors.length > 0) { + throw errors[0]; + } } } diff --git a/hyperformula-ui/packages/core/tests/formula-builder.test.ts b/hyperformula-ui/packages/core/tests/formula-builder.test.ts index 9db100f3b..c3b4c4d9b 100644 --- a/hyperformula-ui/packages/core/tests/formula-builder.test.ts +++ b/hyperformula-ui/packages/core/tests/formula-builder.test.ts @@ -890,3 +890,62 @@ describe("FormulaBuilder", () => { }); }); }); + +class DestroyedEngine implements HyperFormulaLike { + private destroyed = false; + + on(): void {} + + off(): void { + if (this.destroyed) { + throw new TypeError("Cannot read properties of undefined (reading 'off')"); + } + } + + destroy(): void { + this.destroyed = true; + } +} + +class FailingOverlayAdapter extends NullAdapter { + override setOverlayClassName(className: string | null): void { + if (className === null) { + throw new Error("overlay already gone"); + } + } +} + +describe("FormulaBuilder teardown", () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + function leftovers() { + return { + portal: document.querySelector(sel(CN.popupPortal)), + liveRegion: document.querySelector(sel(CN.liveRegion)), + styles: document.head.querySelector("style[data-hfe-styles]"), + }; + } + + it("does not throw when the host destroyed the engine first", () => { + const engine = new DestroyedEngine(); + const builder = new FormulaBuilder({ rootEl: document.body, hyperformula: engine }); + + engine.destroy(); + + expect(() => builder.destroy()).not.toThrow(); + expect(leftovers()).toEqual({ portal: null, liveRegion: null, styles: null }); + }); + + it("finishes every teardown step when one throws, then rethrows the error", () => { + const builder = new FormulaBuilder({ + rootEl: document.body, + adapter: new FailingOverlayAdapter(), + className: "host-theme", + }); + + expect(() => builder.destroy()).toThrow("overlay already gone"); + expect(leftovers()).toEqual({ portal: null, liveRegion: null, styles: null }); + }); +}); From 673611a17a390e5507368b8d55479962952605fb Mon Sep 17 00:00:00 2001 From: adrianspdev Date: Thu, 22 Oct 2026 12:22:16 +0200 Subject: [PATCH 03/35] Tokenize a long run of apostrophes in linear time The quoted sheet-name pattern was retried at every position of a run of apostrophes that no '! closes, so a long run took quadratic time on every tokenize (about 600 ms for 40,000 characters). The closing quote of every possible quoted sheet name is now found in one right-to-left pass per tokenize, with the same matches as the pattern it replaces. --- .../packages/core/src/lexer/lexer.ts | 4 ++- .../packages/core/src/lexer/matchers.ts | 36 ++++++++++++++++--- .../packages/core/src/lexer/patterns.ts | 1 - .../packages/core/tests/lexer/lexer.test.ts | 18 ++++++++++ .../core/tests/lexer/matchers.test.ts | 9 +++++ 5 files changed, 61 insertions(+), 7 deletions(-) diff --git a/hyperformula-ui/packages/core/src/lexer/lexer.ts b/hyperformula-ui/packages/core/src/lexer/lexer.ts index 1494276ea..8a54d9f55 100644 --- a/hyperformula-ui/packages/core/src/lexer/lexer.ts +++ b/hyperformula-ui/packages/core/src/lexer/lexer.ts @@ -1,6 +1,7 @@ import { isAdditiveCloser, isAdditiveOpener, nonTrivialAt } from "./additiveContext"; import { buildNumberPattern, + buildQuotedSheetEnds, type CellMatch, matchCell, matchSheetPrefix, @@ -156,6 +157,7 @@ export class Lexer { let pos = 0; let unterminatedStringFound = false; const textLength = text.length; + const quotedSheetEnds = text.includes("'") ? buildQuotedSheetEnds(text) : undefined; while (pos < textLength) { const start = pos; @@ -237,7 +239,7 @@ export class Lexer { continue; } - const sheet = matchSheetPrefix(text, pos); + const sheet = matchSheetPrefix(text, pos, quotedSheetEnds); if (sheet) { const afterSheet = pos + sheet.length; diff --git a/hyperformula-ui/packages/core/src/lexer/matchers.ts b/hyperformula-ui/packages/core/src/lexer/matchers.ts index 907706bd6..a629a10a9 100644 --- a/hyperformula-ui/packages/core/src/lexer/matchers.ts +++ b/hyperformula-ui/packages/core/src/lexer/matchers.ts @@ -1,4 +1,4 @@ -import { RE_CELL, RE_SHEET_BARE, RE_SHEET_QUOTED } from "./patterns"; +import { RE_CELL, RE_SHEET_BARE } from "./patterns"; import { colLettersToIndex } from "./tokens"; export interface CellMatch { @@ -44,16 +44,42 @@ export function matchCell(text: string, pos: number): CellMatch | null { }; } +const NO_CLOSING_QUOTE = -1; + +export function buildQuotedSheetEnds(text: string): Int32Array { + const ends = new Int32Array(text.length + 2).fill(NO_CLOSING_QUOTE); + + for (let i = text.length - 1; i >= 0; i--) { + const next = text.charAt(i + 1); + + if (text.charAt(i) !== "'") { + ends[i] = ends[i + 1] ?? NO_CLOSING_QUOTE; + } else if (next === "'") { + ends[i] = ends[i + 2] ?? NO_CLOSING_QUOTE; + } else if (next === "!") { + ends[i] = i; + } + } + + return ends; +} + export function matchSheetPrefix( text: string, pos: number, + quotedSheetEnds?: Int32Array, ): { sheet: string; length: number } | null { - const quotedMatch = matchSticky(RE_SHEET_QUOTED, text, pos); + if (text.charAt(pos) === "'") { + const ends = quotedSheetEnds ?? buildQuotedSheetEnds(text); + const closingQuote = ends[pos + 1] ?? NO_CLOSING_QUOTE; + + if (closingQuote === NO_CLOSING_QUOTE) { + return null; + } - if (quotedMatch && quotedMatch.index === pos) { return { - sheet: (quotedMatch[1] ?? "").replace(/''/g, "'"), - length: quotedMatch[0].length, + sheet: text.slice(pos + 1, closingQuote).replace(/''/g, "'"), + length: closingQuote + 2 - pos, }; } diff --git a/hyperformula-ui/packages/core/src/lexer/patterns.ts b/hyperformula-ui/packages/core/src/lexer/patterns.ts index 6924bc160..c378ff502 100644 --- a/hyperformula-ui/packages/core/src/lexer/patterns.ts +++ b/hyperformula-ui/packages/core/src/lexer/patterns.ts @@ -4,7 +4,6 @@ export const RE_CELL = /(\$?)([A-Za-z]+)(\$?)(\d+)/y; export const RE_WHOLE_COL = /(\$?)([A-Za-z]+):(\$?)([A-Za-z]+)/y; export const RE_WHOLE_ROW = /(\$?)(\d+):(\$?)(\d+)/y; export const RE_IDENT = /[\p{L}_][\p{L}\p{N}_.]*/uy; -export const RE_SHEET_QUOTED = /'((?:[^']|'')*)'!/y; export const RE_SHEET_BARE = /([\p{L}_][\p{L}\p{N}_]*)!/uy; export const RE_OP_MULTI = /<>|<=|>=/y; export const RE_OP_SINGLE = /[+\-*/^%&=<>:]/y; diff --git a/hyperformula-ui/packages/core/tests/lexer/lexer.test.ts b/hyperformula-ui/packages/core/tests/lexer/lexer.test.ts index 3dc8fca75..16fc73bc0 100644 --- a/hyperformula-ui/packages/core/tests/lexer/lexer.test.ts +++ b/hyperformula-ui/packages/core/tests/lexer/lexer.test.ts @@ -268,3 +268,21 @@ describe("Lexer", () => { ]); }); }); + +describe("Lexer quoted sheet names", () => { + const RUN_LENGTH = 40_000; + const BUDGET_MS = 50; + + it("tokenizes a long run of apostrophes in linear time", () => { + const text = `=${"'".repeat(RUN_LENGTH)}`; + const startedAt = performance.now(); + const tokens = lex.tokenize(text); + + expect(performance.now() - startedAt).toBeLessThan(BUDGET_MS); + expect(tokens).toHaveLength(RUN_LENGTH + 1); + }); + + it("keeps tokenizing quotes that do not open a sheet name one by one", () => { + expect(values("='abc'+A1")).toEqual(["=", "'", "abc", "'", "+", "A1"]); + }); +}); diff --git a/hyperformula-ui/packages/core/tests/lexer/matchers.test.ts b/hyperformula-ui/packages/core/tests/lexer/matchers.test.ts index ccdf16297..7ad7ee2ab 100644 --- a/hyperformula-ui/packages/core/tests/lexer/matchers.test.ts +++ b/hyperformula-ui/packages/core/tests/lexer/matchers.test.ts @@ -201,4 +201,13 @@ describe("matchSheetPrefix", () => { it("no longer treats backslash-quote as an escaped quote", () => { expect(matchSheetPrefix("'a\\'b'!A1", 0)).toBeNull(); }); + + it("takes the longest quoted name when doubled quotes precede the closing quote", () => { + expect(matchSheetPrefix("'a'''!A1", 0)).toEqual({ sheet: "a'", length: 6 }); + }); + + it("matches a quoted name that starts inside a run of quotes", () => { + expect(matchSheetPrefix("''a'!A1", 0)).toBeNull(); + expect(matchSheetPrefix("''a'!A1", 1)).toEqual({ sheet: "a", length: 4 }); + }); }); From bf6dd66fcc64858fe1ec18880a597b654ae81402 Mon Sep 17 00:00:00 2001 From: adrianspdev Date: Thu, 22 Oct 2026 12:23:15 +0200 Subject: [PATCH 04/35] Render a cell's error value and the error popup title in the same font The error value in a cell used the monospace formula font while the error popup title used the UI font, so the same #NAME? appeared in two fonts side by side. Both now read the new --hfe-error-font-family custom property, which defaults to --hfe-font-family, the host's font. --- hyperformula-ui/dev-docs/SPEC.md | 1 + .../packages/core/src/styles/error.scss | 4 +-- .../packages/core/src/styles/tokens.scss | 1 + .../packages/core/src/theme/cssText.ts | 2 +- .../core/tests/theme/stylesheet.test.ts | 32 +++++++++++++++++++ 5 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 hyperformula-ui/packages/core/tests/theme/stylesheet.test.ts diff --git a/hyperformula-ui/dev-docs/SPEC.md b/hyperformula-ui/dev-docs/SPEC.md index c58af3de7..975efc7b2 100644 --- a/hyperformula-ui/dev-docs/SPEC.md +++ b/hyperformula-ui/dev-docs/SPEC.md @@ -293,6 +293,7 @@ No adapter ships in this repository; see §1. An adapter implements `IGridAdapte - Every class name string lives in `theme/classNames.ts` (`CN`, `TOKEN_CN`) — no string literals scattered in components. - `color-scheme` is set on the editor root in `tokens.scss`: `light` under `[data-theme="light"]`, `dark` under `[data-theme="dark"]`. All themed colors via `light-dark(lightVal, darkVal)`; auto when no `data-theme` override. - Variable namespace `--hfe-*`. Ref colors from the fixed `REF_PALETTE` (`theme/refPalette.ts`). Formula-bar tokens: the color/border ones (`--hfe-formula-bar-{background,foreground,border,border-width,border-radius}`) alias global tokens — no bar-specific colors; the sizing ones (`--hfe-formula-bar-{padding-vertical,padding-horizontal,gap-address,gap-formula,address-width,scrollbar-width}`, `--hfe-z-index-formula-bar`) carry literal defaults. The vertical padding sits on the bar's overlay and textarea, not on the formula cell, and the cell has no end padding. +- Fonts: `--hfe-font-family` (default `inherit`, the host's font) for UI text, `--hfe-formula-font-family` (monospace) for formula text, and `--hfe-error-font-family` (default `var(--hfe-font-family)`) read by both `hfe-cell--error` and `hfe-error__title`, so an error value in a cell and the error popup title always render in the same font. - Scrollbars: `--hfe-scrollbar-width` (default `thin`) sets `scrollbar-width` on a height-capped editor and the two popup lists; the bar-hosted editor takes `--hfe-formula-bar-scrollbar-width` (default `auto`) instead, because a thin bar in a one-line scroll port is hard to grab. - Editor sizing is host-configurable via `--hfe-editor-min-lines` / `--hfe-editor-max-lines` (line-unit height clamps, read by `EditorSizeController`), `--hfe-editor-resize` (`none`/`vertical`/`horizontal`/`both` for the native resize grip), `--hfe-editor-max-width` (px width cap, applied with or without a clipping ancestor), and `--hfe-editor-min-width` (width floor for the resize grip; defaults to `100%` of the mount). Set them on the editor root or an ancestor. - `FormulaBuilder`'s `className` option is the single theming entry point — it lands on the popup portal, formula bar container, editor root, and (via `adapter.setOverlayClassName`) the adapter's highlight overlays. diff --git a/hyperformula-ui/packages/core/src/styles/error.scss b/hyperformula-ui/packages/core/src/styles/error.scss index 8bf03b561..2c439b3bc 100644 --- a/hyperformula-ui/packages/core/src/styles/error.scss +++ b/hyperformula-ui/packages/core/src/styles/error.scss @@ -1,7 +1,7 @@ .hfe-cell--error { position: relative; color: var(--hfe-token-err) !important; - font-family: var(--hfe-formula-font-family); + font-family: var(--hfe-error-font-family); font-size: var(--hfe-font-size); font-weight: 400; line-height: var(--hfe-line-height); @@ -40,7 +40,7 @@ &__title { font-size: var(--hfe-font-size); color: var(--hfe-color-error); - font-family: var(--hfe-font-family); + font-family: var(--hfe-error-font-family); } &__message { diff --git a/hyperformula-ui/packages/core/src/styles/tokens.scss b/hyperformula-ui/packages/core/src/styles/tokens.scss index a823a1097..f18e30f2f 100644 --- a/hyperformula-ui/packages/core/src/styles/tokens.scss +++ b/hyperformula-ui/packages/core/src/styles/tokens.scss @@ -47,6 +47,7 @@ "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; --hfe-font-family: inherit; + --hfe-error-font-family: var(--hfe-font-family); --hfe-font-size: 14px; --hfe-line-height: 20px; --hfe-selection-opacity: 0.08; diff --git a/hyperformula-ui/packages/core/src/theme/cssText.ts b/hyperformula-ui/packages/core/src/theme/cssText.ts index e82ba37ca..3435a6959 100644 --- a/hyperformula-ui/packages/core/src/theme/cssText.ts +++ b/hyperformula-ui/packages/core/src/theme/cssText.ts @@ -4,4 +4,4 @@ * Regenerate: npm run build:styles --workspace=hyperformula-ui/packages/core */ export const CSS_TEXT = - ':root{--hfe-color-background: light-dark(#ffffff, #1e1f22);--hfe-color-foreground: light-dark(#1c1d20, #e6e6e6);--hfe-color-foreground-secondary: light-dark(#4e4f52, #aaaaaa);--hfe-color-border: light-dark(#eceef2, #28292c);--hfe-color-highlight: light-dark(#f7f7f9, #000000);--hfe-color-caret: light-dark(#1c1d20, #e6e6e6);--hfe-color-accent: light-dark(#1a42e8, #8aa4ff);--hfe-color-focus-ring: light-dark(#1a42e8, #8aa4ff);--hfe-color-error: light-dark(#fa4d56, #fa4d56);--hfe-color-shadow: color-mix(in srgb, #000000 var(--hfe-shadow-opacity), transparent);--hfe-scrollbar-color: color-mix(in srgb, var(--hfe-color-foreground) 20%, transparent);--hfe-scrollbar-color-hover: color-mix(in srgb, var(--hfe-color-foreground) 50%, transparent);--hfe-scrollbar-width: thin;--hfe-token-fn: var(--hfe-color-foreground);--hfe-token-fn-unknown: light-dark(#ea580c, #fdba74);--hfe-token-num: var(--hfe-color-foreground);--hfe-token-str: var(--hfe-color-foreground);--hfe-token-bool: var(--hfe-color-foreground);--hfe-token-op: var(--hfe-color-foreground);--hfe-token-paren: var(--hfe-color-foreground);--hfe-token-paren-err: var(--hfe-color-error);--hfe-token-paren-match: var(--hfe-color-accent);--hfe-token-bracket: var(--hfe-color-foreground);--hfe-token-sep: var(--hfe-color-foreground);--hfe-token-sheet: light-dark(#64748b, #7c8694);--hfe-token-ident: var(--hfe-color-foreground);--hfe-token-err: var(--hfe-color-error);--hfe-token-ref-invalid: var(--hfe-color-error);--hfe-token-comment: light-dark(#8a8f99, #6c7280);--hfe-ref-1: light-dark(#007bff, #0A84FF);--hfe-ref-2: light-dark(#ff9500, #FF9D0A);--hfe-ref-3: light-dark(#af52de, #BF5AF2);--hfe-ref-4: light-dark(#30d158, #34c759);--hfe-ref-5: light-dark(#ff2d54, #ff375f);--hfe-ref-6: light-dark(#30b0c7, #40c8e0);--hfe-ref-7: light-dark(#5856d6, #5E5CE6);--hfe-ref-8: light-dark(#a2855e, #AC8E68);--hfe-formula-font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;--hfe-font-family: inherit;--hfe-font-size: 14px;--hfe-line-height: 20px;--hfe-selection-opacity: 0.08;--hfe-dim-opacity: 0.6;--hfe-gap: 4px;--hfe-shadow-x: 0;--hfe-shadow-y: 8px;--hfe-shadow-blur: 16px;--hfe-shadow-opacity: 8%;--hfe-editor-color-border: var(--hfe-color-accent);--hfe-padding-vertical: 8px;--hfe-padding-horizontal: 12px;--hfe-border-radius: 0;--hfe-border-width: 1px;--hfe-border-width-focus: 2px;--hfe-reference-border-width: 1px;--hfe-menu-item-padding-horizontal: 12px;--hfe-menu-item-padding-vertical: 8px;--hfe-menu-item-border-radius: 8px;--hfe-menu-padding-horizontal: 4px;--hfe-menu-padding-vertical: 4px;--hfe-menu-border-radius: 12px;--hfe-menu-border-width: 0;--hfe-menu-border-color: var(--hfe-color-border);--hfe-menu-background: var(--hfe-color-background);--hfe-menu-max-height: 280px;--hfe-menu-separator-color: var(--hfe-color-border);--hfe-menu-kbd-row-border-width: 1px;--hfe-menu-kbd-row-border-color: var(--hfe-color-border);--hfe-menu-kbd-row-chip-color: light-dark(#f7f7f9, #000000);--hfe-menu-chip-border-radius: 8px;--hfe-menu-chip-background: var(--hfe-color-accent);--hfe-menu-chip-foreground: var(--hfe-color-background);--hfe-menu-shadow: var(--hfe-shadow-x) var(--hfe-shadow-y) var(--hfe-shadow-blur) var(--hfe-color-shadow);--hfe-menu-close-bg: var(--hfe-menu-background);--hfe-menu-close-hover-bg: var(--hfe-color-highlight);--hfe-error-padding-horizontal: 16px;--hfe-error-padding-vertical: 8px;--hfe-error-border-radius: 0;--hfe-formula-bar-background: var(--hfe-color-background);--hfe-formula-bar-foreground: var(--hfe-color-foreground);--hfe-formula-bar-border: var(--hfe-color-border);--hfe-formula-bar-border-width: var(--hfe-border-width);--hfe-formula-bar-border-radius: var(--hfe-border-radius);--hfe-formula-bar-padding-vertical: 4px;--hfe-formula-bar-padding-horizontal: 8px;--hfe-formula-bar-gap-address: 4px;--hfe-formula-bar-gap-formula: 8px;--hfe-formula-bar-address-width: 120px;--hfe-formula-bar-scrollbar-width: auto;--hfe-formula-bar-input-background-hover: transparent;--hfe-formula-bar-input-focus-ring: var(--hfe-color-focus-ring);--hfe-z-index-popup: 210;--hfe-z-index-formula-bar: 200}:where([class*=hfe-]){color-scheme:light dark}.hfe-popup__close{position:absolute;inset-block-start:-10px;inset-inline-end:-10px;width:24px;height:24px;padding:0;border-radius:12px;background:var(--hfe-menu-close-bg);border:var(--hfe-menu-border-width) solid var(--hfe-menu-border-color);cursor:pointer;display:flex;align-items:center;justify-content:center}.hfe-popup__close:hover{background:var(--hfe-menu-close-hover-bg)}.hfe-popup__close:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-color-focus-ring);outline-offset:2px}.hfe-popup.hfe-popup--hosted{position:static;width:100%;max-height:none}.hfe-editor{position:relative;display:inline-block;width:max-content;background:var(--hfe-color-background);color:var(--hfe-color-foreground);border:none;box-shadow:inset 0 0 0 var(--hfe-border-width) var(--hfe-editor-color-border);border-radius:var(--hfe-border-radius);font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);font-variant-ligatures:none;line-height:var(--hfe-line-height);min-width:var(--hfe-editor-min-width, 100%);min-height:100%;box-sizing:border-box !important;overflow:hidden;resize:var(--hfe-editor-resize, none)}.hfe-editor--focused{box-shadow:inset 0 0 0 var(--hfe-border-width-focus) var(--hfe-color-focus-ring)}.hfe-editor--height-capped{overflow:hidden}.hfe-editor--height-capped .hfe-overlay,.hfe-editor--height-capped .hfe-editor__input{overflow-x:hidden;overflow-y:scroll;scrollbar-width:var(--hfe-scrollbar-width);scrollbar-color:var(--hfe-scrollbar-color) rgba(0,0,0,0)}.hfe-editor--height-capped:hover .hfe-overlay,.hfe-editor--height-capped:hover .hfe-editor__input{scrollbar-color:var(--hfe-scrollbar-color-hover) rgba(0,0,0,0)}.hfe-editor__layers{display:inline-block;vertical-align:top;min-width:100%;max-width:100%;min-height:inherit;box-sizing:border-box !important}.hfe-editor__input{position:absolute;inset:0;display:block;width:100%;height:100%;background:rgba(0,0,0,0);color:rgba(0,0,0,0);caret-color:var(--hfe-color-caret);resize:none;outline:none;overflow:hidden;z-index:2}.hfe-editor__input::selection{background:color-mix(in srgb, var(--hfe-color-accent) 15%, transparent);color:rgba(0,0,0,0)}.hfe-editor__input,.hfe-overlay{font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);font-weight:400;font-variant-ligatures:none;letter-spacing:0;line-height:var(--hfe-line-height);padding:var(--hfe-padding-vertical) var(--hfe-padding-horizontal);box-sizing:border-box;white-space:pre-wrap;word-break:break-all;tab-size:4;margin:0;border:0}.hfe-editor[dir=rtl] .hfe-editor__input,.hfe-editor[dir=rtl] .hfe-overlay{text-align:right}.hfe-editor--formula .hfe-editor__input,.hfe-editor--formula .hfe-overlay{font-family:var(--hfe-formula-font-family)}.hfe-formula-bar{display:flex;background:var(--hfe-formula-bar-background);border:var(--hfe-formula-bar-border-width) solid var(--hfe-formula-bar-border);color:var(--hfe-formula-bar-foreground);border-radius:var(--hfe-formula-bar-border-radius)}.hfe-formula-bar__cell{display:flex;align-items:center;box-sizing:border-box;border-inline-end:var(--hfe-formula-bar-border-width) solid var(--hfe-formula-bar-border)}.hfe-formula-bar__cell--address{flex:0 0 var(--hfe-formula-bar-address-width);gap:var(--hfe-formula-bar-gap-address)}.hfe-formula-bar__cell--formula{flex:1;min-width:0;gap:var(--hfe-formula-bar-gap-formula);padding-inline-start:var(--hfe-formula-bar-padding-horizontal);border-inline-end:none}.hfe-formula-bar__address-input{width:100%;min-width:0;box-sizing:border-box;border:none;outline:none;background:rgba(0,0,0,0);color:inherit;font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);padding:var(--hfe-formula-bar-padding-vertical) var(--hfe-formula-bar-padding-horizontal);border-start-start-radius:var(--hfe-formula-bar-border-radius)}.hfe-formula-bar__address-input:hover{background:var(--hfe-formula-bar-input-background-hover)}.hfe-formula-bar__address-input:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-formula-bar-input-focus-ring);outline-offset:calc(-1*var(--hfe-border-width-focus))}.hfe-formula-bar__fx-icon{flex:none;color:var(--hfe-formula-bar-foreground)}.hfe-formula-bar__body{position:relative;flex:1;min-width:0}.hfe-formula-bar__idle{cursor:text;height:calc(var(--hfe-line-height) + 2*var(--hfe-formula-bar-padding-vertical));font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);white-space:pre-wrap;word-break:break-all;overflow-x:hidden;overflow-y:auto}.hfe-formula-bar__overlay{position:absolute;top:0;inset-inline:0;z-index:var(--hfe-z-index-formula-bar)}.hfe-formula-bar .hfe-editor{display:block;max-width:100% !important;background:var(--hfe-formula-bar-background);--hfe-scrollbar-width: var(--hfe-formula-bar-scrollbar-width)}.hfe-formula-bar .hfe-editor,.hfe-formula-bar .hfe-overlay,.hfe-formula-bar .hfe-editor__input{border:none;box-shadow:none;border-radius:0;padding:0;min-height:unset}.hfe-formula-bar .hfe-overlay,.hfe-formula-bar .hfe-editor__input{padding:var(--hfe-formula-bar-padding-vertical) 0}.hfe-formula-bar__overlay .hfe-editor{border-start-end-radius:var(--hfe-formula-bar-border-radius)}.hfe-formula-bar[dir=rtl] .hfe-formula-bar__idle .hfe-overlay{text-align:right}.hfe-overlay{position:static;display:block;pointer-events:none;color:var(--hfe-color-foreground);z-index:1}.hfe-token--fn{color:var(--hfe-token-fn)}.hfe-token--fn-unknown{color:var(--hfe-token-fn-unknown)}.hfe-token--num{color:var(--hfe-token-num)}.hfe-token--str{color:var(--hfe-token-str)}.hfe-token--op{color:var(--hfe-token-op)}.hfe-token--paren{color:var(--hfe-token-paren)}.hfe-token--paren-err{color:var(--hfe-token-paren-err)}.hfe-token--bracket{color:var(--hfe-token-bracket)}.hfe-token--sep{color:var(--hfe-token-sep)}.hfe-token--sheet{color:var(--hfe-token-sheet)}.hfe-token--ident{color:var(--hfe-token-ident)}.hfe-token--err{color:var(--hfe-token-err);text-decoration:underline wavy var(--hfe-token-err)}.hfe-token--errlit{color:var(--hfe-token-err);font-weight:600}.hfe-token--ref-invalid{color:var(--hfe-token-ref-invalid);text-decoration:line-through}.hfe-token--comment{color:var(--hfe-token-comment);font-style:italic}.hfe-token--comment-err{text-decoration:underline wavy var(--hfe-token-err);text-underline-offset:2px}.hfe-token--paren-match{background:color-mix(in srgb, var(--hfe-token-paren-match) calc(var(--hfe-selection-opacity) * 100%), transparent);border-radius:3px}.hfe-token--dim{opacity:var(--hfe-dim-opacity)}.hfe-token--arg-active{text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px}.hfe-ref-mark{display:inline;padding:0;margin:0;border:0;background:rgba(0,0,0,0);color:var(--hfe-ref-1);pointer-events:none;font:inherit;white-space:inherit}.hfe-ref-mark[data-ref-index="0"]{color:var(--hfe-ref-1)}.hfe-ref-mark[data-ref-index="1"]{color:var(--hfe-ref-2)}.hfe-ref-mark[data-ref-index="2"]{color:var(--hfe-ref-3)}.hfe-ref-mark[data-ref-index="3"]{color:var(--hfe-ref-4)}.hfe-ref-mark[data-ref-index="4"]{color:var(--hfe-ref-5)}.hfe-ref-mark[data-ref-index="5"]{color:var(--hfe-ref-6)}.hfe-ref-mark[data-ref-index="6"]{color:var(--hfe-ref-7)}.hfe-ref-mark[data-ref-index="7"]{color:var(--hfe-ref-8)}.hfe-ref-mark--named{text-decoration:underline dotted;text-underline-offset:3px}.hfe-suggestions{position:fixed;z-index:var(--hfe-z-index-popup);width:264px;max-height:var(--hfe-menu-max-height);display:flex;flex-direction:column;background:var(--hfe-menu-background);color:var(--hfe-color-foreground);border:var(--hfe-menu-border-width) solid var(--hfe-menu-border-color);border-radius:var(--hfe-menu-border-radius);box-shadow:var(--hfe-menu-shadow);font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);gap:var(--hfe-gap)}.hfe-suggestions[hidden]{display:none}.hfe-suggestions__list{overflow-y:auto;padding:var(--hfe-menu-padding-vertical) var(--hfe-menu-padding-horizontal) 0;scrollbar-width:var(--hfe-scrollbar-width);scrollbar-color:var(--hfe-scrollbar-color) rgba(0,0,0,0)}.hfe-suggestions__list:hover{scrollbar-color:var(--hfe-scrollbar-color-hover) rgba(0,0,0,0)}.hfe-suggestions__list:only-of-type{padding-bottom:var(--hfe-menu-padding-vertical)}.hfe-suggestions__item{padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);border-radius:var(--hfe-menu-item-border-radius);cursor:pointer;white-space:nowrap}.hfe-suggestions__item--active{background:var(--hfe-color-highlight)}.hfe-suggestions__item-label{display:block;font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);font-weight:400;line-height:var(--hfe-line-height)}.hfe-suggestions__item-description{display:none;font-size:12px;line-height:16px;color:var(--hfe-color-foreground-secondary);font-family:var(--hfe-font-family);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.hfe-suggestions__item--active .hfe-suggestions__item-description{display:block}.hfe-suggestions__item--named .hfe-suggestions__item-label{display:flex;align-items:center;gap:var(--hfe-gap);color:var(--hfe-token-sheet)}.hfe-suggestions__match{color:var(--hfe-color-accent);font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);font-weight:400;line-height:var(--hfe-line-height)}.hfe-suggestions__kbd-row{flex-shrink:0;display:flex;align-items:center;gap:var(--hfe-gap);padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);border:var(--hfe-menu-kbd-row-border-width) solid var(--hfe-menu-kbd-row-border-color);border-radius:var(--hfe-menu-item-border-radius);font-family:var(--hfe-font-family);font-size:12px;line-height:16px;color:var(--hfe-color-foreground);margin:0 var(--hfe-menu-padding-horizontal) var(--hfe-menu-padding-vertical)}.hfe-suggestions__kbd-chip{background:var(--hfe-menu-kbd-row-chip-color);border-radius:4px;padding:2px 4px}.hfe-fn-help{position:fixed;z-index:var(--hfe-z-index-popup);width:320px;border-radius:var(--hfe-menu-border-radius);background:var(--hfe-menu-background);color:var(--hfe-color-foreground);border:var(--hfe-menu-border-width) solid var(--hfe-menu-border-color);box-shadow:var(--hfe-menu-shadow);font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);padding:var(--hfe-menu-padding-vertical) var(--hfe-menu-padding-horizontal);max-height:var(--hfe-menu-max-height);display:flex;flex-direction:column}.hfe-fn-help[hidden]{display:none}.hfe-fn-help__body{overflow-y:auto;scrollbar-width:var(--hfe-scrollbar-width);scrollbar-color:var(--hfe-scrollbar-color) rgba(0,0,0,0)}.hfe-fn-help__body:hover{scrollbar-color:var(--hfe-scrollbar-color-hover) rgba(0,0,0,0)}.hfe-fn-help__body:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-color-focus-ring);outline-offset:calc(-1*var(--hfe-border-width-focus))}.hfe-fn-help__signature{display:flex;flex-wrap:wrap;align-items:center;padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);background:var(--hfe-color-highlight);border-radius:var(--hfe-menu-item-border-radius);font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height)}.hfe-fn-help__name{color:var(--hfe-color-foreground)}.hfe-fn-help__arg{color:var(--hfe-color-foreground)}.hfe-fn-help__arg--active{background:var(--hfe-menu-chip-background);color:var(--hfe-menu-chip-foreground);border-radius:var(--hfe-menu-chip-border-radius);padding:0 4px}.hfe-fn-help__section{display:flex;flex-direction:column;gap:var(--hfe-gap);padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);border-radius:var(--hfe-menu-item-border-radius)}.hfe-fn-help__section-label{font-size:12px;line-height:16px;color:var(--hfe-color-foreground-secondary);font-family:var(--hfe-font-family)}.hfe-fn-help__example{font-family:var(--hfe-formula-font-family);color:var(--hfe-color-accent);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height)}.hfe-fn-help__about{font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);color:var(--hfe-color-foreground)}.hfe-fn-help__param-name{font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);color:var(--hfe-color-foreground)}.hfe-fn-help__param-name--active{color:var(--hfe-color-accent)}.hfe-fn-help__param-desc{font-family:var(--hfe-font-family);font-size:12px;line-height:16px;color:var(--hfe-color-foreground)}.hfe-fn-help__separator{height:1px;border:0;background:var(--hfe-menu-separator-color);margin:var(--hfe-gap) var(--hfe-menu-item-padding-horizontal)}.hfe-fn-help__learn-more{display:flex;align-items:center;gap:var(--hfe-gap);padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal) calc(var(--hfe-menu-item-padding-vertical) + var(--hfe-gap));border-radius:var(--hfe-menu-item-border-radius);color:var(--hfe-color-foreground);font-size:var(--hfe-font-size);font-family:var(--hfe-font-family);text-decoration:none;cursor:pointer}.hfe-fn-help__learn-more:hover{text-decoration:underline}.hfe-fn-help__learn-more:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-color-focus-ring);outline-offset:2px}.hfe-cell--error{position:relative;color:var(--hfe-token-err) !important;font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);font-weight:400;line-height:var(--hfe-line-height)}.hfe-cell--error::after{content:"";position:absolute;inset-block-end:0;inset-inline-end:0;width:0;height:0;border-style:solid;border-width:0;border-block-end-width:6px;border-inline-start-width:6px;border-color:rgba(0,0,0,0);border-block-end-color:var(--hfe-color-error);pointer-events:none}.hfe-error{position:fixed;z-index:1000;max-width:280px;padding:var(--hfe-error-padding-vertical) var(--hfe-error-padding-horizontal);background:var(--hfe-color-background);border-inline-start:var(--hfe-border-width) solid var(--hfe-color-error);border-radius:var(--hfe-error-border-radius);box-shadow:var(--hfe-menu-shadow)}.hfe-error[hidden]{display:none}.hfe-error__title{font-size:var(--hfe-font-size);color:var(--hfe-color-error);font-family:var(--hfe-font-family)}.hfe-error__message{font-size:12px;line-height:16px;color:var(--hfe-color-foreground-secondary);font-family:var(--hfe-font-family);margin-top:var(--hfe-gap)}.hfe-highlight{position:absolute;inset:0;pointer-events:none}.hfe-highlight__item{position:absolute;pointer-events:none;border-width:var(--hfe-reference-border-width);border-radius:var(--hfe-border-radius);border-style:dashed;box-sizing:border-box !important;z-index:5;display:none}.hfe-live-region{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}'; + ':root{--hfe-color-background: light-dark(#ffffff, #1e1f22);--hfe-color-foreground: light-dark(#1c1d20, #e6e6e6);--hfe-color-foreground-secondary: light-dark(#4e4f52, #aaaaaa);--hfe-color-border: light-dark(#eceef2, #28292c);--hfe-color-highlight: light-dark(#f7f7f9, #000000);--hfe-color-caret: light-dark(#1c1d20, #e6e6e6);--hfe-color-accent: light-dark(#1a42e8, #8aa4ff);--hfe-color-focus-ring: light-dark(#1a42e8, #8aa4ff);--hfe-color-error: light-dark(#fa4d56, #fa4d56);--hfe-color-shadow: color-mix(in srgb, #000000 var(--hfe-shadow-opacity), transparent);--hfe-scrollbar-color: color-mix(in srgb, var(--hfe-color-foreground) 20%, transparent);--hfe-scrollbar-color-hover: color-mix(in srgb, var(--hfe-color-foreground) 50%, transparent);--hfe-scrollbar-width: thin;--hfe-token-fn: var(--hfe-color-foreground);--hfe-token-fn-unknown: light-dark(#ea580c, #fdba74);--hfe-token-num: var(--hfe-color-foreground);--hfe-token-str: var(--hfe-color-foreground);--hfe-token-bool: var(--hfe-color-foreground);--hfe-token-op: var(--hfe-color-foreground);--hfe-token-paren: var(--hfe-color-foreground);--hfe-token-paren-err: var(--hfe-color-error);--hfe-token-paren-match: var(--hfe-color-accent);--hfe-token-bracket: var(--hfe-color-foreground);--hfe-token-sep: var(--hfe-color-foreground);--hfe-token-sheet: light-dark(#64748b, #7c8694);--hfe-token-ident: var(--hfe-color-foreground);--hfe-token-err: var(--hfe-color-error);--hfe-token-ref-invalid: var(--hfe-color-error);--hfe-token-comment: light-dark(#8a8f99, #6c7280);--hfe-ref-1: light-dark(#007bff, #0A84FF);--hfe-ref-2: light-dark(#ff9500, #FF9D0A);--hfe-ref-3: light-dark(#af52de, #BF5AF2);--hfe-ref-4: light-dark(#30d158, #34c759);--hfe-ref-5: light-dark(#ff2d54, #ff375f);--hfe-ref-6: light-dark(#30b0c7, #40c8e0);--hfe-ref-7: light-dark(#5856d6, #5E5CE6);--hfe-ref-8: light-dark(#a2855e, #AC8E68);--hfe-formula-font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;--hfe-font-family: inherit;--hfe-error-font-family: var(--hfe-font-family);--hfe-font-size: 14px;--hfe-line-height: 20px;--hfe-selection-opacity: 0.08;--hfe-dim-opacity: 0.6;--hfe-gap: 4px;--hfe-shadow-x: 0;--hfe-shadow-y: 8px;--hfe-shadow-blur: 16px;--hfe-shadow-opacity: 8%;--hfe-editor-color-border: var(--hfe-color-accent);--hfe-padding-vertical: 8px;--hfe-padding-horizontal: 12px;--hfe-border-radius: 0;--hfe-border-width: 1px;--hfe-border-width-focus: 2px;--hfe-reference-border-width: 1px;--hfe-menu-item-padding-horizontal: 12px;--hfe-menu-item-padding-vertical: 8px;--hfe-menu-item-border-radius: 8px;--hfe-menu-padding-horizontal: 4px;--hfe-menu-padding-vertical: 4px;--hfe-menu-border-radius: 12px;--hfe-menu-border-width: 0;--hfe-menu-border-color: var(--hfe-color-border);--hfe-menu-background: var(--hfe-color-background);--hfe-menu-max-height: 280px;--hfe-menu-separator-color: var(--hfe-color-border);--hfe-menu-kbd-row-border-width: 1px;--hfe-menu-kbd-row-border-color: var(--hfe-color-border);--hfe-menu-kbd-row-chip-color: light-dark(#f7f7f9, #000000);--hfe-menu-chip-border-radius: 8px;--hfe-menu-chip-background: var(--hfe-color-accent);--hfe-menu-chip-foreground: var(--hfe-color-background);--hfe-menu-shadow: var(--hfe-shadow-x) var(--hfe-shadow-y) var(--hfe-shadow-blur) var(--hfe-color-shadow);--hfe-menu-close-bg: var(--hfe-menu-background);--hfe-menu-close-hover-bg: var(--hfe-color-highlight);--hfe-error-padding-horizontal: 16px;--hfe-error-padding-vertical: 8px;--hfe-error-border-radius: 0;--hfe-formula-bar-background: var(--hfe-color-background);--hfe-formula-bar-foreground: var(--hfe-color-foreground);--hfe-formula-bar-border: var(--hfe-color-border);--hfe-formula-bar-border-width: var(--hfe-border-width);--hfe-formula-bar-border-radius: var(--hfe-border-radius);--hfe-formula-bar-padding-vertical: 4px;--hfe-formula-bar-padding-horizontal: 8px;--hfe-formula-bar-gap-address: 4px;--hfe-formula-bar-gap-formula: 8px;--hfe-formula-bar-address-width: 120px;--hfe-formula-bar-scrollbar-width: auto;--hfe-formula-bar-input-background-hover: transparent;--hfe-formula-bar-input-focus-ring: var(--hfe-color-focus-ring);--hfe-z-index-popup: 210;--hfe-z-index-formula-bar: 200}:where([class*=hfe-]){color-scheme:light dark}.hfe-popup__close{position:absolute;inset-block-start:-10px;inset-inline-end:-10px;width:24px;height:24px;padding:0;border-radius:12px;background:var(--hfe-menu-close-bg);border:var(--hfe-menu-border-width) solid var(--hfe-menu-border-color);cursor:pointer;display:flex;align-items:center;justify-content:center}.hfe-popup__close:hover{background:var(--hfe-menu-close-hover-bg)}.hfe-popup__close:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-color-focus-ring);outline-offset:2px}.hfe-popup.hfe-popup--hosted{position:static;width:100%;max-height:none}.hfe-editor{position:relative;display:inline-block;width:max-content;background:var(--hfe-color-background);color:var(--hfe-color-foreground);border:none;box-shadow:inset 0 0 0 var(--hfe-border-width) var(--hfe-editor-color-border);border-radius:var(--hfe-border-radius);font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);font-variant-ligatures:none;line-height:var(--hfe-line-height);min-width:var(--hfe-editor-min-width, 100%);min-height:100%;box-sizing:border-box !important;overflow:hidden;resize:var(--hfe-editor-resize, none)}.hfe-editor--focused{box-shadow:inset 0 0 0 var(--hfe-border-width-focus) var(--hfe-color-focus-ring)}.hfe-editor--height-capped{overflow:hidden}.hfe-editor--height-capped .hfe-overlay,.hfe-editor--height-capped .hfe-editor__input{overflow-x:hidden;overflow-y:scroll;scrollbar-width:var(--hfe-scrollbar-width);scrollbar-color:var(--hfe-scrollbar-color) rgba(0,0,0,0)}.hfe-editor--height-capped:hover .hfe-overlay,.hfe-editor--height-capped:hover .hfe-editor__input{scrollbar-color:var(--hfe-scrollbar-color-hover) rgba(0,0,0,0)}.hfe-editor__layers{display:inline-block;vertical-align:top;min-width:100%;max-width:100%;min-height:inherit;box-sizing:border-box !important}.hfe-editor__input{position:absolute;inset:0;display:block;width:100%;height:100%;background:rgba(0,0,0,0);color:rgba(0,0,0,0);caret-color:var(--hfe-color-caret);resize:none;outline:none;overflow:hidden;z-index:2}.hfe-editor__input::selection{background:color-mix(in srgb, var(--hfe-color-accent) 15%, transparent);color:rgba(0,0,0,0)}.hfe-editor__input,.hfe-overlay{font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);font-weight:400;font-variant-ligatures:none;letter-spacing:0;line-height:var(--hfe-line-height);padding:var(--hfe-padding-vertical) var(--hfe-padding-horizontal);box-sizing:border-box;white-space:pre-wrap;word-break:break-all;tab-size:4;margin:0;border:0}.hfe-editor[dir=rtl] .hfe-editor__input,.hfe-editor[dir=rtl] .hfe-overlay{text-align:right}.hfe-editor--formula .hfe-editor__input,.hfe-editor--formula .hfe-overlay{font-family:var(--hfe-formula-font-family)}.hfe-formula-bar{display:flex;background:var(--hfe-formula-bar-background);border:var(--hfe-formula-bar-border-width) solid var(--hfe-formula-bar-border);color:var(--hfe-formula-bar-foreground);border-radius:var(--hfe-formula-bar-border-radius)}.hfe-formula-bar__cell{display:flex;align-items:center;box-sizing:border-box;border-inline-end:var(--hfe-formula-bar-border-width) solid var(--hfe-formula-bar-border)}.hfe-formula-bar__cell--address{flex:0 0 var(--hfe-formula-bar-address-width);gap:var(--hfe-formula-bar-gap-address)}.hfe-formula-bar__cell--formula{flex:1;min-width:0;gap:var(--hfe-formula-bar-gap-formula);padding-inline-start:var(--hfe-formula-bar-padding-horizontal);border-inline-end:none}.hfe-formula-bar__address-input{width:100%;min-width:0;box-sizing:border-box;border:none;outline:none;background:rgba(0,0,0,0);color:inherit;font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);padding:var(--hfe-formula-bar-padding-vertical) var(--hfe-formula-bar-padding-horizontal);border-start-start-radius:var(--hfe-formula-bar-border-radius)}.hfe-formula-bar__address-input:hover{background:var(--hfe-formula-bar-input-background-hover)}.hfe-formula-bar__address-input:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-formula-bar-input-focus-ring);outline-offset:calc(-1*var(--hfe-border-width-focus))}.hfe-formula-bar__fx-icon{flex:none;color:var(--hfe-formula-bar-foreground)}.hfe-formula-bar__body{position:relative;flex:1;min-width:0}.hfe-formula-bar__idle{cursor:text;height:calc(var(--hfe-line-height) + 2*var(--hfe-formula-bar-padding-vertical));font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);white-space:pre-wrap;word-break:break-all;overflow-x:hidden;overflow-y:auto}.hfe-formula-bar__overlay{position:absolute;top:0;inset-inline:0;z-index:var(--hfe-z-index-formula-bar)}.hfe-formula-bar .hfe-editor{display:block;max-width:100% !important;background:var(--hfe-formula-bar-background);--hfe-scrollbar-width: var(--hfe-formula-bar-scrollbar-width)}.hfe-formula-bar .hfe-editor,.hfe-formula-bar .hfe-overlay,.hfe-formula-bar .hfe-editor__input{border:none;box-shadow:none;border-radius:0;padding:0;min-height:unset}.hfe-formula-bar .hfe-overlay,.hfe-formula-bar .hfe-editor__input{padding:var(--hfe-formula-bar-padding-vertical) 0}.hfe-formula-bar__overlay .hfe-editor{border-start-end-radius:var(--hfe-formula-bar-border-radius)}.hfe-formula-bar[dir=rtl] .hfe-formula-bar__idle .hfe-overlay{text-align:right}.hfe-overlay{position:static;display:block;pointer-events:none;color:var(--hfe-color-foreground);z-index:1}.hfe-token--fn{color:var(--hfe-token-fn)}.hfe-token--fn-unknown{color:var(--hfe-token-fn-unknown)}.hfe-token--num{color:var(--hfe-token-num)}.hfe-token--str{color:var(--hfe-token-str)}.hfe-token--op{color:var(--hfe-token-op)}.hfe-token--paren{color:var(--hfe-token-paren)}.hfe-token--paren-err{color:var(--hfe-token-paren-err)}.hfe-token--bracket{color:var(--hfe-token-bracket)}.hfe-token--sep{color:var(--hfe-token-sep)}.hfe-token--sheet{color:var(--hfe-token-sheet)}.hfe-token--ident{color:var(--hfe-token-ident)}.hfe-token--err{color:var(--hfe-token-err);text-decoration:underline wavy var(--hfe-token-err)}.hfe-token--errlit{color:var(--hfe-token-err);font-weight:600}.hfe-token--ref-invalid{color:var(--hfe-token-ref-invalid);text-decoration:line-through}.hfe-token--comment{color:var(--hfe-token-comment);font-style:italic}.hfe-token--comment-err{text-decoration:underline wavy var(--hfe-token-err);text-underline-offset:2px}.hfe-token--paren-match{background:color-mix(in srgb, var(--hfe-token-paren-match) calc(var(--hfe-selection-opacity) * 100%), transparent);border-radius:3px}.hfe-token--dim{opacity:var(--hfe-dim-opacity)}.hfe-token--arg-active{text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px}.hfe-ref-mark{display:inline;padding:0;margin:0;border:0;background:rgba(0,0,0,0);color:var(--hfe-ref-1);pointer-events:none;font:inherit;white-space:inherit}.hfe-ref-mark[data-ref-index="0"]{color:var(--hfe-ref-1)}.hfe-ref-mark[data-ref-index="1"]{color:var(--hfe-ref-2)}.hfe-ref-mark[data-ref-index="2"]{color:var(--hfe-ref-3)}.hfe-ref-mark[data-ref-index="3"]{color:var(--hfe-ref-4)}.hfe-ref-mark[data-ref-index="4"]{color:var(--hfe-ref-5)}.hfe-ref-mark[data-ref-index="5"]{color:var(--hfe-ref-6)}.hfe-ref-mark[data-ref-index="6"]{color:var(--hfe-ref-7)}.hfe-ref-mark[data-ref-index="7"]{color:var(--hfe-ref-8)}.hfe-ref-mark--named{text-decoration:underline dotted;text-underline-offset:3px}.hfe-suggestions{position:fixed;z-index:var(--hfe-z-index-popup);width:264px;max-height:var(--hfe-menu-max-height);display:flex;flex-direction:column;background:var(--hfe-menu-background);color:var(--hfe-color-foreground);border:var(--hfe-menu-border-width) solid var(--hfe-menu-border-color);border-radius:var(--hfe-menu-border-radius);box-shadow:var(--hfe-menu-shadow);font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);gap:var(--hfe-gap)}.hfe-suggestions[hidden]{display:none}.hfe-suggestions__list{overflow-y:auto;padding:var(--hfe-menu-padding-vertical) var(--hfe-menu-padding-horizontal) 0;scrollbar-width:var(--hfe-scrollbar-width);scrollbar-color:var(--hfe-scrollbar-color) rgba(0,0,0,0)}.hfe-suggestions__list:hover{scrollbar-color:var(--hfe-scrollbar-color-hover) rgba(0,0,0,0)}.hfe-suggestions__list:only-of-type{padding-bottom:var(--hfe-menu-padding-vertical)}.hfe-suggestions__item{padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);border-radius:var(--hfe-menu-item-border-radius);cursor:pointer;white-space:nowrap}.hfe-suggestions__item--active{background:var(--hfe-color-highlight)}.hfe-suggestions__item-label{display:block;font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);font-weight:400;line-height:var(--hfe-line-height)}.hfe-suggestions__item-description{display:none;font-size:12px;line-height:16px;color:var(--hfe-color-foreground-secondary);font-family:var(--hfe-font-family);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.hfe-suggestions__item--active .hfe-suggestions__item-description{display:block}.hfe-suggestions__item--named .hfe-suggestions__item-label{display:flex;align-items:center;gap:var(--hfe-gap);color:var(--hfe-token-sheet)}.hfe-suggestions__match{color:var(--hfe-color-accent);font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);font-weight:400;line-height:var(--hfe-line-height)}.hfe-suggestions__kbd-row{flex-shrink:0;display:flex;align-items:center;gap:var(--hfe-gap);padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);border:var(--hfe-menu-kbd-row-border-width) solid var(--hfe-menu-kbd-row-border-color);border-radius:var(--hfe-menu-item-border-radius);font-family:var(--hfe-font-family);font-size:12px;line-height:16px;color:var(--hfe-color-foreground);margin:0 var(--hfe-menu-padding-horizontal) var(--hfe-menu-padding-vertical)}.hfe-suggestions__kbd-chip{background:var(--hfe-menu-kbd-row-chip-color);border-radius:4px;padding:2px 4px}.hfe-fn-help{position:fixed;z-index:var(--hfe-z-index-popup);width:320px;border-radius:var(--hfe-menu-border-radius);background:var(--hfe-menu-background);color:var(--hfe-color-foreground);border:var(--hfe-menu-border-width) solid var(--hfe-menu-border-color);box-shadow:var(--hfe-menu-shadow);font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);padding:var(--hfe-menu-padding-vertical) var(--hfe-menu-padding-horizontal);max-height:var(--hfe-menu-max-height);display:flex;flex-direction:column}.hfe-fn-help[hidden]{display:none}.hfe-fn-help__body{overflow-y:auto;scrollbar-width:var(--hfe-scrollbar-width);scrollbar-color:var(--hfe-scrollbar-color) rgba(0,0,0,0)}.hfe-fn-help__body:hover{scrollbar-color:var(--hfe-scrollbar-color-hover) rgba(0,0,0,0)}.hfe-fn-help__body:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-color-focus-ring);outline-offset:calc(-1*var(--hfe-border-width-focus))}.hfe-fn-help__signature{display:flex;flex-wrap:wrap;align-items:center;padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);background:var(--hfe-color-highlight);border-radius:var(--hfe-menu-item-border-radius);font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height)}.hfe-fn-help__name{color:var(--hfe-color-foreground)}.hfe-fn-help__arg{color:var(--hfe-color-foreground)}.hfe-fn-help__arg--active{background:var(--hfe-menu-chip-background);color:var(--hfe-menu-chip-foreground);border-radius:var(--hfe-menu-chip-border-radius);padding:0 4px}.hfe-fn-help__section{display:flex;flex-direction:column;gap:var(--hfe-gap);padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);border-radius:var(--hfe-menu-item-border-radius)}.hfe-fn-help__section-label{font-size:12px;line-height:16px;color:var(--hfe-color-foreground-secondary);font-family:var(--hfe-font-family)}.hfe-fn-help__example{font-family:var(--hfe-formula-font-family);color:var(--hfe-color-accent);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height)}.hfe-fn-help__about{font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);color:var(--hfe-color-foreground)}.hfe-fn-help__param-name{font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);color:var(--hfe-color-foreground)}.hfe-fn-help__param-name--active{color:var(--hfe-color-accent)}.hfe-fn-help__param-desc{font-family:var(--hfe-font-family);font-size:12px;line-height:16px;color:var(--hfe-color-foreground)}.hfe-fn-help__separator{height:1px;border:0;background:var(--hfe-menu-separator-color);margin:var(--hfe-gap) var(--hfe-menu-item-padding-horizontal)}.hfe-fn-help__learn-more{display:flex;align-items:center;gap:var(--hfe-gap);padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal) calc(var(--hfe-menu-item-padding-vertical) + var(--hfe-gap));border-radius:var(--hfe-menu-item-border-radius);color:var(--hfe-color-foreground);font-size:var(--hfe-font-size);font-family:var(--hfe-font-family);text-decoration:none;cursor:pointer}.hfe-fn-help__learn-more:hover{text-decoration:underline}.hfe-fn-help__learn-more:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-color-focus-ring);outline-offset:2px}.hfe-cell--error{position:relative;color:var(--hfe-token-err) !important;font-family:var(--hfe-error-font-family);font-size:var(--hfe-font-size);font-weight:400;line-height:var(--hfe-line-height)}.hfe-cell--error::after{content:"";position:absolute;inset-block-end:0;inset-inline-end:0;width:0;height:0;border-style:solid;border-width:0;border-block-end-width:6px;border-inline-start-width:6px;border-color:rgba(0,0,0,0);border-block-end-color:var(--hfe-color-error);pointer-events:none}.hfe-error{position:fixed;z-index:1000;max-width:280px;padding:var(--hfe-error-padding-vertical) var(--hfe-error-padding-horizontal);background:var(--hfe-color-background);border-inline-start:var(--hfe-border-width) solid var(--hfe-color-error);border-radius:var(--hfe-error-border-radius);box-shadow:var(--hfe-menu-shadow)}.hfe-error[hidden]{display:none}.hfe-error__title{font-size:var(--hfe-font-size);color:var(--hfe-color-error);font-family:var(--hfe-error-font-family)}.hfe-error__message{font-size:12px;line-height:16px;color:var(--hfe-color-foreground-secondary);font-family:var(--hfe-font-family);margin-top:var(--hfe-gap)}.hfe-highlight{position:absolute;inset:0;pointer-events:none}.hfe-highlight__item{position:absolute;pointer-events:none;border-width:var(--hfe-reference-border-width);border-radius:var(--hfe-border-radius);border-style:dashed;box-sizing:border-box !important;z-index:5;display:none}.hfe-live-region{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}'; diff --git a/hyperformula-ui/packages/core/tests/theme/stylesheet.test.ts b/hyperformula-ui/packages/core/tests/theme/stylesheet.test.ts new file mode 100644 index 000000000..02b9eb9a9 --- /dev/null +++ b/hyperformula-ui/packages/core/tests/theme/stylesheet.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { CSS_TEXT } from "../../src/theme/cssText"; + +function declarations(selector: string): Record { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const rule = new RegExp(`(?:^|})${escaped}\\{([^}]*)\\}`).exec(CSS_TEXT); + + if (!rule) { + throw new Error(`expected a rule for ${selector}`); + } + + return Object.fromEntries( + (rule[1] ?? "") + .split(";") + .filter((declaration) => declaration.includes(":")) + .map((declaration) => { + const separator = declaration.indexOf(":"); + + return [declaration.slice(0, separator).trim(), declaration.slice(separator + 1).trim()]; + }), + ); +} + +describe("package stylesheet", () => { + it("renders the error value in a cell and the error popup title in the same font", () => { + const cellFont = declarations(".hfe-cell--error")["font-family"]; + + expect(cellFont).toBe("var(--hfe-error-font-family)"); + expect(declarations(".hfe-error__title")["font-family"]).toBe(cellFont); + expect(declarations(":root")["--hfe-error-font-family"]).toBe("var(--hfe-font-family)"); + }); +}); From 3e037528d577319f3a2e4ebf643748c3c02f53f8 Mon Sep 17 00:00:00 2001 From: adrianspdev Date: Thu, 22 Oct 2026 12:23:54 +0200 Subject: [PATCH 05/35] Set color-scheme only on the package roots The stylesheet set color-scheme: light dark on every package element through a class-attribute substring selector, so light-dark() followed the operating system instead of the host theme: a dark grid on a light system showed light popups, a host could undo it only element by element, and the selector was tested against every element of the page. It is now set once per root (the editor, the formula bar, the popup portal and a hosted popup), with no specificity, so a host theme class on a root overrides it and the rest inherits. --- hyperformula-ui/dev-docs/SPEC.md | 2 +- .../packages/core/src/styles/tokens.scss | 2 +- hyperformula-ui/packages/core/src/theme/cssText.ts | 2 +- .../packages/core/tests/theme/stylesheet.test.ts | 13 +++++++++++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/hyperformula-ui/dev-docs/SPEC.md b/hyperformula-ui/dev-docs/SPEC.md index 975efc7b2..9cdf38b5d 100644 --- a/hyperformula-ui/dev-docs/SPEC.md +++ b/hyperformula-ui/dev-docs/SPEC.md @@ -291,7 +291,7 @@ No adapter ships in this repository; see §1. An adapter implements `IGridAdapte `createStyleInjector(marker, cssText)` for their own generated stylesheets. - BEM blocks: `hfe-editor`, `hfe-overlay`, `hfe-ref-mark`, `hfe-popup`, `hfe-popup-portal`, `hfe-suggestions`, `hfe-fn-help`, `hfe-highlight`, `hfe-token`, `hfe-error`, `hfe-cell--error`, `hfe-formula-bar` (`__cell`, `__cell--address`, `__cell--formula`, `__address-input`, `__fx-icon`, `__body`, `__idle`, `__overlay`). Decoration modifiers: `hfe-token--paren-match`, `hfe-token--paren-err`, `hfe-token--dim`, `hfe-token--arg-active`; error/status modifiers `hfe-token--{comment-err,fn-unknown,ref-invalid}`; per-token-type classes via `TOKEN_CN`. Full element/modifier inventory: `theme/classNames.ts`. - Every class name string lives in `theme/classNames.ts` (`CN`, `TOKEN_CN`) — no string literals scattered in components. -- `color-scheme` is set on the editor root in `tokens.scss`: `light` under `[data-theme="light"]`, `dark` under `[data-theme="dark"]`. All themed colors via `light-dark(lightVal, darkVal)`; auto when no `data-theme` override. +- `color-scheme: light dark` is set in `tokens.scss` on the package roots only (`hfe-editor`, `hfe-formula-bar`, `hfe-popup-portal`, `hfe-popup--hosted`), inside `:where()` so it has no specificity: a host theme class on a root (the `className` option lands on every root) overrides it with a single class selector, and the elements inside a root inherit it. Never set it through a class-attribute substring selector such as `[class*="hfe-"]`: that overrides the inherited scheme on every package element, and the browser tests it against every element of the page. All themed colors via `light-dark(lightVal, darkVal)`, which follows the operating system unless the host sets the scheme. - Variable namespace `--hfe-*`. Ref colors from the fixed `REF_PALETTE` (`theme/refPalette.ts`). Formula-bar tokens: the color/border ones (`--hfe-formula-bar-{background,foreground,border,border-width,border-radius}`) alias global tokens — no bar-specific colors; the sizing ones (`--hfe-formula-bar-{padding-vertical,padding-horizontal,gap-address,gap-formula,address-width,scrollbar-width}`, `--hfe-z-index-formula-bar`) carry literal defaults. The vertical padding sits on the bar's overlay and textarea, not on the formula cell, and the cell has no end padding. - Fonts: `--hfe-font-family` (default `inherit`, the host's font) for UI text, `--hfe-formula-font-family` (monospace) for formula text, and `--hfe-error-font-family` (default `var(--hfe-font-family)`) read by both `hfe-cell--error` and `hfe-error__title`, so an error value in a cell and the error popup title always render in the same font. - Scrollbars: `--hfe-scrollbar-width` (default `thin`) sets `scrollbar-width` on a height-capped editor and the two popup lists; the bar-hosted editor takes `--hfe-formula-bar-scrollbar-width` (default `auto`) instead, because a thin bar in a one-line scroll port is hard to grab. diff --git a/hyperformula-ui/packages/core/src/styles/tokens.scss b/hyperformula-ui/packages/core/src/styles/tokens.scss index f18e30f2f..806a18cff 100644 --- a/hyperformula-ui/packages/core/src/styles/tokens.scss +++ b/hyperformula-ui/packages/core/src/styles/tokens.scss @@ -116,6 +116,6 @@ --hfe-z-index-formula-bar: 200; } -:where([class*="hfe-"]) { +:where(.hfe-editor, .hfe-formula-bar, .hfe-popup-portal, .hfe-popup--hosted) { color-scheme: light dark; } diff --git a/hyperformula-ui/packages/core/src/theme/cssText.ts b/hyperformula-ui/packages/core/src/theme/cssText.ts index 3435a6959..b42159588 100644 --- a/hyperformula-ui/packages/core/src/theme/cssText.ts +++ b/hyperformula-ui/packages/core/src/theme/cssText.ts @@ -4,4 +4,4 @@ * Regenerate: npm run build:styles --workspace=hyperformula-ui/packages/core */ export const CSS_TEXT = - ':root{--hfe-color-background: light-dark(#ffffff, #1e1f22);--hfe-color-foreground: light-dark(#1c1d20, #e6e6e6);--hfe-color-foreground-secondary: light-dark(#4e4f52, #aaaaaa);--hfe-color-border: light-dark(#eceef2, #28292c);--hfe-color-highlight: light-dark(#f7f7f9, #000000);--hfe-color-caret: light-dark(#1c1d20, #e6e6e6);--hfe-color-accent: light-dark(#1a42e8, #8aa4ff);--hfe-color-focus-ring: light-dark(#1a42e8, #8aa4ff);--hfe-color-error: light-dark(#fa4d56, #fa4d56);--hfe-color-shadow: color-mix(in srgb, #000000 var(--hfe-shadow-opacity), transparent);--hfe-scrollbar-color: color-mix(in srgb, var(--hfe-color-foreground) 20%, transparent);--hfe-scrollbar-color-hover: color-mix(in srgb, var(--hfe-color-foreground) 50%, transparent);--hfe-scrollbar-width: thin;--hfe-token-fn: var(--hfe-color-foreground);--hfe-token-fn-unknown: light-dark(#ea580c, #fdba74);--hfe-token-num: var(--hfe-color-foreground);--hfe-token-str: var(--hfe-color-foreground);--hfe-token-bool: var(--hfe-color-foreground);--hfe-token-op: var(--hfe-color-foreground);--hfe-token-paren: var(--hfe-color-foreground);--hfe-token-paren-err: var(--hfe-color-error);--hfe-token-paren-match: var(--hfe-color-accent);--hfe-token-bracket: var(--hfe-color-foreground);--hfe-token-sep: var(--hfe-color-foreground);--hfe-token-sheet: light-dark(#64748b, #7c8694);--hfe-token-ident: var(--hfe-color-foreground);--hfe-token-err: var(--hfe-color-error);--hfe-token-ref-invalid: var(--hfe-color-error);--hfe-token-comment: light-dark(#8a8f99, #6c7280);--hfe-ref-1: light-dark(#007bff, #0A84FF);--hfe-ref-2: light-dark(#ff9500, #FF9D0A);--hfe-ref-3: light-dark(#af52de, #BF5AF2);--hfe-ref-4: light-dark(#30d158, #34c759);--hfe-ref-5: light-dark(#ff2d54, #ff375f);--hfe-ref-6: light-dark(#30b0c7, #40c8e0);--hfe-ref-7: light-dark(#5856d6, #5E5CE6);--hfe-ref-8: light-dark(#a2855e, #AC8E68);--hfe-formula-font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;--hfe-font-family: inherit;--hfe-error-font-family: var(--hfe-font-family);--hfe-font-size: 14px;--hfe-line-height: 20px;--hfe-selection-opacity: 0.08;--hfe-dim-opacity: 0.6;--hfe-gap: 4px;--hfe-shadow-x: 0;--hfe-shadow-y: 8px;--hfe-shadow-blur: 16px;--hfe-shadow-opacity: 8%;--hfe-editor-color-border: var(--hfe-color-accent);--hfe-padding-vertical: 8px;--hfe-padding-horizontal: 12px;--hfe-border-radius: 0;--hfe-border-width: 1px;--hfe-border-width-focus: 2px;--hfe-reference-border-width: 1px;--hfe-menu-item-padding-horizontal: 12px;--hfe-menu-item-padding-vertical: 8px;--hfe-menu-item-border-radius: 8px;--hfe-menu-padding-horizontal: 4px;--hfe-menu-padding-vertical: 4px;--hfe-menu-border-radius: 12px;--hfe-menu-border-width: 0;--hfe-menu-border-color: var(--hfe-color-border);--hfe-menu-background: var(--hfe-color-background);--hfe-menu-max-height: 280px;--hfe-menu-separator-color: var(--hfe-color-border);--hfe-menu-kbd-row-border-width: 1px;--hfe-menu-kbd-row-border-color: var(--hfe-color-border);--hfe-menu-kbd-row-chip-color: light-dark(#f7f7f9, #000000);--hfe-menu-chip-border-radius: 8px;--hfe-menu-chip-background: var(--hfe-color-accent);--hfe-menu-chip-foreground: var(--hfe-color-background);--hfe-menu-shadow: var(--hfe-shadow-x) var(--hfe-shadow-y) var(--hfe-shadow-blur) var(--hfe-color-shadow);--hfe-menu-close-bg: var(--hfe-menu-background);--hfe-menu-close-hover-bg: var(--hfe-color-highlight);--hfe-error-padding-horizontal: 16px;--hfe-error-padding-vertical: 8px;--hfe-error-border-radius: 0;--hfe-formula-bar-background: var(--hfe-color-background);--hfe-formula-bar-foreground: var(--hfe-color-foreground);--hfe-formula-bar-border: var(--hfe-color-border);--hfe-formula-bar-border-width: var(--hfe-border-width);--hfe-formula-bar-border-radius: var(--hfe-border-radius);--hfe-formula-bar-padding-vertical: 4px;--hfe-formula-bar-padding-horizontal: 8px;--hfe-formula-bar-gap-address: 4px;--hfe-formula-bar-gap-formula: 8px;--hfe-formula-bar-address-width: 120px;--hfe-formula-bar-scrollbar-width: auto;--hfe-formula-bar-input-background-hover: transparent;--hfe-formula-bar-input-focus-ring: var(--hfe-color-focus-ring);--hfe-z-index-popup: 210;--hfe-z-index-formula-bar: 200}:where([class*=hfe-]){color-scheme:light dark}.hfe-popup__close{position:absolute;inset-block-start:-10px;inset-inline-end:-10px;width:24px;height:24px;padding:0;border-radius:12px;background:var(--hfe-menu-close-bg);border:var(--hfe-menu-border-width) solid var(--hfe-menu-border-color);cursor:pointer;display:flex;align-items:center;justify-content:center}.hfe-popup__close:hover{background:var(--hfe-menu-close-hover-bg)}.hfe-popup__close:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-color-focus-ring);outline-offset:2px}.hfe-popup.hfe-popup--hosted{position:static;width:100%;max-height:none}.hfe-editor{position:relative;display:inline-block;width:max-content;background:var(--hfe-color-background);color:var(--hfe-color-foreground);border:none;box-shadow:inset 0 0 0 var(--hfe-border-width) var(--hfe-editor-color-border);border-radius:var(--hfe-border-radius);font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);font-variant-ligatures:none;line-height:var(--hfe-line-height);min-width:var(--hfe-editor-min-width, 100%);min-height:100%;box-sizing:border-box !important;overflow:hidden;resize:var(--hfe-editor-resize, none)}.hfe-editor--focused{box-shadow:inset 0 0 0 var(--hfe-border-width-focus) var(--hfe-color-focus-ring)}.hfe-editor--height-capped{overflow:hidden}.hfe-editor--height-capped .hfe-overlay,.hfe-editor--height-capped .hfe-editor__input{overflow-x:hidden;overflow-y:scroll;scrollbar-width:var(--hfe-scrollbar-width);scrollbar-color:var(--hfe-scrollbar-color) rgba(0,0,0,0)}.hfe-editor--height-capped:hover .hfe-overlay,.hfe-editor--height-capped:hover .hfe-editor__input{scrollbar-color:var(--hfe-scrollbar-color-hover) rgba(0,0,0,0)}.hfe-editor__layers{display:inline-block;vertical-align:top;min-width:100%;max-width:100%;min-height:inherit;box-sizing:border-box !important}.hfe-editor__input{position:absolute;inset:0;display:block;width:100%;height:100%;background:rgba(0,0,0,0);color:rgba(0,0,0,0);caret-color:var(--hfe-color-caret);resize:none;outline:none;overflow:hidden;z-index:2}.hfe-editor__input::selection{background:color-mix(in srgb, var(--hfe-color-accent) 15%, transparent);color:rgba(0,0,0,0)}.hfe-editor__input,.hfe-overlay{font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);font-weight:400;font-variant-ligatures:none;letter-spacing:0;line-height:var(--hfe-line-height);padding:var(--hfe-padding-vertical) var(--hfe-padding-horizontal);box-sizing:border-box;white-space:pre-wrap;word-break:break-all;tab-size:4;margin:0;border:0}.hfe-editor[dir=rtl] .hfe-editor__input,.hfe-editor[dir=rtl] .hfe-overlay{text-align:right}.hfe-editor--formula .hfe-editor__input,.hfe-editor--formula .hfe-overlay{font-family:var(--hfe-formula-font-family)}.hfe-formula-bar{display:flex;background:var(--hfe-formula-bar-background);border:var(--hfe-formula-bar-border-width) solid var(--hfe-formula-bar-border);color:var(--hfe-formula-bar-foreground);border-radius:var(--hfe-formula-bar-border-radius)}.hfe-formula-bar__cell{display:flex;align-items:center;box-sizing:border-box;border-inline-end:var(--hfe-formula-bar-border-width) solid var(--hfe-formula-bar-border)}.hfe-formula-bar__cell--address{flex:0 0 var(--hfe-formula-bar-address-width);gap:var(--hfe-formula-bar-gap-address)}.hfe-formula-bar__cell--formula{flex:1;min-width:0;gap:var(--hfe-formula-bar-gap-formula);padding-inline-start:var(--hfe-formula-bar-padding-horizontal);border-inline-end:none}.hfe-formula-bar__address-input{width:100%;min-width:0;box-sizing:border-box;border:none;outline:none;background:rgba(0,0,0,0);color:inherit;font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);padding:var(--hfe-formula-bar-padding-vertical) var(--hfe-formula-bar-padding-horizontal);border-start-start-radius:var(--hfe-formula-bar-border-radius)}.hfe-formula-bar__address-input:hover{background:var(--hfe-formula-bar-input-background-hover)}.hfe-formula-bar__address-input:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-formula-bar-input-focus-ring);outline-offset:calc(-1*var(--hfe-border-width-focus))}.hfe-formula-bar__fx-icon{flex:none;color:var(--hfe-formula-bar-foreground)}.hfe-formula-bar__body{position:relative;flex:1;min-width:0}.hfe-formula-bar__idle{cursor:text;height:calc(var(--hfe-line-height) + 2*var(--hfe-formula-bar-padding-vertical));font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);white-space:pre-wrap;word-break:break-all;overflow-x:hidden;overflow-y:auto}.hfe-formula-bar__overlay{position:absolute;top:0;inset-inline:0;z-index:var(--hfe-z-index-formula-bar)}.hfe-formula-bar .hfe-editor{display:block;max-width:100% !important;background:var(--hfe-formula-bar-background);--hfe-scrollbar-width: var(--hfe-formula-bar-scrollbar-width)}.hfe-formula-bar .hfe-editor,.hfe-formula-bar .hfe-overlay,.hfe-formula-bar .hfe-editor__input{border:none;box-shadow:none;border-radius:0;padding:0;min-height:unset}.hfe-formula-bar .hfe-overlay,.hfe-formula-bar .hfe-editor__input{padding:var(--hfe-formula-bar-padding-vertical) 0}.hfe-formula-bar__overlay .hfe-editor{border-start-end-radius:var(--hfe-formula-bar-border-radius)}.hfe-formula-bar[dir=rtl] .hfe-formula-bar__idle .hfe-overlay{text-align:right}.hfe-overlay{position:static;display:block;pointer-events:none;color:var(--hfe-color-foreground);z-index:1}.hfe-token--fn{color:var(--hfe-token-fn)}.hfe-token--fn-unknown{color:var(--hfe-token-fn-unknown)}.hfe-token--num{color:var(--hfe-token-num)}.hfe-token--str{color:var(--hfe-token-str)}.hfe-token--op{color:var(--hfe-token-op)}.hfe-token--paren{color:var(--hfe-token-paren)}.hfe-token--paren-err{color:var(--hfe-token-paren-err)}.hfe-token--bracket{color:var(--hfe-token-bracket)}.hfe-token--sep{color:var(--hfe-token-sep)}.hfe-token--sheet{color:var(--hfe-token-sheet)}.hfe-token--ident{color:var(--hfe-token-ident)}.hfe-token--err{color:var(--hfe-token-err);text-decoration:underline wavy var(--hfe-token-err)}.hfe-token--errlit{color:var(--hfe-token-err);font-weight:600}.hfe-token--ref-invalid{color:var(--hfe-token-ref-invalid);text-decoration:line-through}.hfe-token--comment{color:var(--hfe-token-comment);font-style:italic}.hfe-token--comment-err{text-decoration:underline wavy var(--hfe-token-err);text-underline-offset:2px}.hfe-token--paren-match{background:color-mix(in srgb, var(--hfe-token-paren-match) calc(var(--hfe-selection-opacity) * 100%), transparent);border-radius:3px}.hfe-token--dim{opacity:var(--hfe-dim-opacity)}.hfe-token--arg-active{text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px}.hfe-ref-mark{display:inline;padding:0;margin:0;border:0;background:rgba(0,0,0,0);color:var(--hfe-ref-1);pointer-events:none;font:inherit;white-space:inherit}.hfe-ref-mark[data-ref-index="0"]{color:var(--hfe-ref-1)}.hfe-ref-mark[data-ref-index="1"]{color:var(--hfe-ref-2)}.hfe-ref-mark[data-ref-index="2"]{color:var(--hfe-ref-3)}.hfe-ref-mark[data-ref-index="3"]{color:var(--hfe-ref-4)}.hfe-ref-mark[data-ref-index="4"]{color:var(--hfe-ref-5)}.hfe-ref-mark[data-ref-index="5"]{color:var(--hfe-ref-6)}.hfe-ref-mark[data-ref-index="6"]{color:var(--hfe-ref-7)}.hfe-ref-mark[data-ref-index="7"]{color:var(--hfe-ref-8)}.hfe-ref-mark--named{text-decoration:underline dotted;text-underline-offset:3px}.hfe-suggestions{position:fixed;z-index:var(--hfe-z-index-popup);width:264px;max-height:var(--hfe-menu-max-height);display:flex;flex-direction:column;background:var(--hfe-menu-background);color:var(--hfe-color-foreground);border:var(--hfe-menu-border-width) solid var(--hfe-menu-border-color);border-radius:var(--hfe-menu-border-radius);box-shadow:var(--hfe-menu-shadow);font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);gap:var(--hfe-gap)}.hfe-suggestions[hidden]{display:none}.hfe-suggestions__list{overflow-y:auto;padding:var(--hfe-menu-padding-vertical) var(--hfe-menu-padding-horizontal) 0;scrollbar-width:var(--hfe-scrollbar-width);scrollbar-color:var(--hfe-scrollbar-color) rgba(0,0,0,0)}.hfe-suggestions__list:hover{scrollbar-color:var(--hfe-scrollbar-color-hover) rgba(0,0,0,0)}.hfe-suggestions__list:only-of-type{padding-bottom:var(--hfe-menu-padding-vertical)}.hfe-suggestions__item{padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);border-radius:var(--hfe-menu-item-border-radius);cursor:pointer;white-space:nowrap}.hfe-suggestions__item--active{background:var(--hfe-color-highlight)}.hfe-suggestions__item-label{display:block;font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);font-weight:400;line-height:var(--hfe-line-height)}.hfe-suggestions__item-description{display:none;font-size:12px;line-height:16px;color:var(--hfe-color-foreground-secondary);font-family:var(--hfe-font-family);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.hfe-suggestions__item--active .hfe-suggestions__item-description{display:block}.hfe-suggestions__item--named .hfe-suggestions__item-label{display:flex;align-items:center;gap:var(--hfe-gap);color:var(--hfe-token-sheet)}.hfe-suggestions__match{color:var(--hfe-color-accent);font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);font-weight:400;line-height:var(--hfe-line-height)}.hfe-suggestions__kbd-row{flex-shrink:0;display:flex;align-items:center;gap:var(--hfe-gap);padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);border:var(--hfe-menu-kbd-row-border-width) solid var(--hfe-menu-kbd-row-border-color);border-radius:var(--hfe-menu-item-border-radius);font-family:var(--hfe-font-family);font-size:12px;line-height:16px;color:var(--hfe-color-foreground);margin:0 var(--hfe-menu-padding-horizontal) var(--hfe-menu-padding-vertical)}.hfe-suggestions__kbd-chip{background:var(--hfe-menu-kbd-row-chip-color);border-radius:4px;padding:2px 4px}.hfe-fn-help{position:fixed;z-index:var(--hfe-z-index-popup);width:320px;border-radius:var(--hfe-menu-border-radius);background:var(--hfe-menu-background);color:var(--hfe-color-foreground);border:var(--hfe-menu-border-width) solid var(--hfe-menu-border-color);box-shadow:var(--hfe-menu-shadow);font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);padding:var(--hfe-menu-padding-vertical) var(--hfe-menu-padding-horizontal);max-height:var(--hfe-menu-max-height);display:flex;flex-direction:column}.hfe-fn-help[hidden]{display:none}.hfe-fn-help__body{overflow-y:auto;scrollbar-width:var(--hfe-scrollbar-width);scrollbar-color:var(--hfe-scrollbar-color) rgba(0,0,0,0)}.hfe-fn-help__body:hover{scrollbar-color:var(--hfe-scrollbar-color-hover) rgba(0,0,0,0)}.hfe-fn-help__body:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-color-focus-ring);outline-offset:calc(-1*var(--hfe-border-width-focus))}.hfe-fn-help__signature{display:flex;flex-wrap:wrap;align-items:center;padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);background:var(--hfe-color-highlight);border-radius:var(--hfe-menu-item-border-radius);font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height)}.hfe-fn-help__name{color:var(--hfe-color-foreground)}.hfe-fn-help__arg{color:var(--hfe-color-foreground)}.hfe-fn-help__arg--active{background:var(--hfe-menu-chip-background);color:var(--hfe-menu-chip-foreground);border-radius:var(--hfe-menu-chip-border-radius);padding:0 4px}.hfe-fn-help__section{display:flex;flex-direction:column;gap:var(--hfe-gap);padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);border-radius:var(--hfe-menu-item-border-radius)}.hfe-fn-help__section-label{font-size:12px;line-height:16px;color:var(--hfe-color-foreground-secondary);font-family:var(--hfe-font-family)}.hfe-fn-help__example{font-family:var(--hfe-formula-font-family);color:var(--hfe-color-accent);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height)}.hfe-fn-help__about{font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);color:var(--hfe-color-foreground)}.hfe-fn-help__param-name{font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);color:var(--hfe-color-foreground)}.hfe-fn-help__param-name--active{color:var(--hfe-color-accent)}.hfe-fn-help__param-desc{font-family:var(--hfe-font-family);font-size:12px;line-height:16px;color:var(--hfe-color-foreground)}.hfe-fn-help__separator{height:1px;border:0;background:var(--hfe-menu-separator-color);margin:var(--hfe-gap) var(--hfe-menu-item-padding-horizontal)}.hfe-fn-help__learn-more{display:flex;align-items:center;gap:var(--hfe-gap);padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal) calc(var(--hfe-menu-item-padding-vertical) + var(--hfe-gap));border-radius:var(--hfe-menu-item-border-radius);color:var(--hfe-color-foreground);font-size:var(--hfe-font-size);font-family:var(--hfe-font-family);text-decoration:none;cursor:pointer}.hfe-fn-help__learn-more:hover{text-decoration:underline}.hfe-fn-help__learn-more:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-color-focus-ring);outline-offset:2px}.hfe-cell--error{position:relative;color:var(--hfe-token-err) !important;font-family:var(--hfe-error-font-family);font-size:var(--hfe-font-size);font-weight:400;line-height:var(--hfe-line-height)}.hfe-cell--error::after{content:"";position:absolute;inset-block-end:0;inset-inline-end:0;width:0;height:0;border-style:solid;border-width:0;border-block-end-width:6px;border-inline-start-width:6px;border-color:rgba(0,0,0,0);border-block-end-color:var(--hfe-color-error);pointer-events:none}.hfe-error{position:fixed;z-index:1000;max-width:280px;padding:var(--hfe-error-padding-vertical) var(--hfe-error-padding-horizontal);background:var(--hfe-color-background);border-inline-start:var(--hfe-border-width) solid var(--hfe-color-error);border-radius:var(--hfe-error-border-radius);box-shadow:var(--hfe-menu-shadow)}.hfe-error[hidden]{display:none}.hfe-error__title{font-size:var(--hfe-font-size);color:var(--hfe-color-error);font-family:var(--hfe-error-font-family)}.hfe-error__message{font-size:12px;line-height:16px;color:var(--hfe-color-foreground-secondary);font-family:var(--hfe-font-family);margin-top:var(--hfe-gap)}.hfe-highlight{position:absolute;inset:0;pointer-events:none}.hfe-highlight__item{position:absolute;pointer-events:none;border-width:var(--hfe-reference-border-width);border-radius:var(--hfe-border-radius);border-style:dashed;box-sizing:border-box !important;z-index:5;display:none}.hfe-live-region{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}'; + ':root{--hfe-color-background: light-dark(#ffffff, #1e1f22);--hfe-color-foreground: light-dark(#1c1d20, #e6e6e6);--hfe-color-foreground-secondary: light-dark(#4e4f52, #aaaaaa);--hfe-color-border: light-dark(#eceef2, #28292c);--hfe-color-highlight: light-dark(#f7f7f9, #000000);--hfe-color-caret: light-dark(#1c1d20, #e6e6e6);--hfe-color-accent: light-dark(#1a42e8, #8aa4ff);--hfe-color-focus-ring: light-dark(#1a42e8, #8aa4ff);--hfe-color-error: light-dark(#fa4d56, #fa4d56);--hfe-color-shadow: color-mix(in srgb, #000000 var(--hfe-shadow-opacity), transparent);--hfe-scrollbar-color: color-mix(in srgb, var(--hfe-color-foreground) 20%, transparent);--hfe-scrollbar-color-hover: color-mix(in srgb, var(--hfe-color-foreground) 50%, transparent);--hfe-scrollbar-width: thin;--hfe-token-fn: var(--hfe-color-foreground);--hfe-token-fn-unknown: light-dark(#ea580c, #fdba74);--hfe-token-num: var(--hfe-color-foreground);--hfe-token-str: var(--hfe-color-foreground);--hfe-token-bool: var(--hfe-color-foreground);--hfe-token-op: var(--hfe-color-foreground);--hfe-token-paren: var(--hfe-color-foreground);--hfe-token-paren-err: var(--hfe-color-error);--hfe-token-paren-match: var(--hfe-color-accent);--hfe-token-bracket: var(--hfe-color-foreground);--hfe-token-sep: var(--hfe-color-foreground);--hfe-token-sheet: light-dark(#64748b, #7c8694);--hfe-token-ident: var(--hfe-color-foreground);--hfe-token-err: var(--hfe-color-error);--hfe-token-ref-invalid: var(--hfe-color-error);--hfe-token-comment: light-dark(#8a8f99, #6c7280);--hfe-ref-1: light-dark(#007bff, #0A84FF);--hfe-ref-2: light-dark(#ff9500, #FF9D0A);--hfe-ref-3: light-dark(#af52de, #BF5AF2);--hfe-ref-4: light-dark(#30d158, #34c759);--hfe-ref-5: light-dark(#ff2d54, #ff375f);--hfe-ref-6: light-dark(#30b0c7, #40c8e0);--hfe-ref-7: light-dark(#5856d6, #5E5CE6);--hfe-ref-8: light-dark(#a2855e, #AC8E68);--hfe-formula-font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;--hfe-font-family: inherit;--hfe-error-font-family: var(--hfe-font-family);--hfe-font-size: 14px;--hfe-line-height: 20px;--hfe-selection-opacity: 0.08;--hfe-dim-opacity: 0.6;--hfe-gap: 4px;--hfe-shadow-x: 0;--hfe-shadow-y: 8px;--hfe-shadow-blur: 16px;--hfe-shadow-opacity: 8%;--hfe-editor-color-border: var(--hfe-color-accent);--hfe-padding-vertical: 8px;--hfe-padding-horizontal: 12px;--hfe-border-radius: 0;--hfe-border-width: 1px;--hfe-border-width-focus: 2px;--hfe-reference-border-width: 1px;--hfe-menu-item-padding-horizontal: 12px;--hfe-menu-item-padding-vertical: 8px;--hfe-menu-item-border-radius: 8px;--hfe-menu-padding-horizontal: 4px;--hfe-menu-padding-vertical: 4px;--hfe-menu-border-radius: 12px;--hfe-menu-border-width: 0;--hfe-menu-border-color: var(--hfe-color-border);--hfe-menu-background: var(--hfe-color-background);--hfe-menu-max-height: 280px;--hfe-menu-separator-color: var(--hfe-color-border);--hfe-menu-kbd-row-border-width: 1px;--hfe-menu-kbd-row-border-color: var(--hfe-color-border);--hfe-menu-kbd-row-chip-color: light-dark(#f7f7f9, #000000);--hfe-menu-chip-border-radius: 8px;--hfe-menu-chip-background: var(--hfe-color-accent);--hfe-menu-chip-foreground: var(--hfe-color-background);--hfe-menu-shadow: var(--hfe-shadow-x) var(--hfe-shadow-y) var(--hfe-shadow-blur) var(--hfe-color-shadow);--hfe-menu-close-bg: var(--hfe-menu-background);--hfe-menu-close-hover-bg: var(--hfe-color-highlight);--hfe-error-padding-horizontal: 16px;--hfe-error-padding-vertical: 8px;--hfe-error-border-radius: 0;--hfe-formula-bar-background: var(--hfe-color-background);--hfe-formula-bar-foreground: var(--hfe-color-foreground);--hfe-formula-bar-border: var(--hfe-color-border);--hfe-formula-bar-border-width: var(--hfe-border-width);--hfe-formula-bar-border-radius: var(--hfe-border-radius);--hfe-formula-bar-padding-vertical: 4px;--hfe-formula-bar-padding-horizontal: 8px;--hfe-formula-bar-gap-address: 4px;--hfe-formula-bar-gap-formula: 8px;--hfe-formula-bar-address-width: 120px;--hfe-formula-bar-scrollbar-width: auto;--hfe-formula-bar-input-background-hover: transparent;--hfe-formula-bar-input-focus-ring: var(--hfe-color-focus-ring);--hfe-z-index-popup: 210;--hfe-z-index-formula-bar: 200}:where(.hfe-editor,.hfe-formula-bar,.hfe-popup-portal,.hfe-popup--hosted){color-scheme:light dark}.hfe-popup__close{position:absolute;inset-block-start:-10px;inset-inline-end:-10px;width:24px;height:24px;padding:0;border-radius:12px;background:var(--hfe-menu-close-bg);border:var(--hfe-menu-border-width) solid var(--hfe-menu-border-color);cursor:pointer;display:flex;align-items:center;justify-content:center}.hfe-popup__close:hover{background:var(--hfe-menu-close-hover-bg)}.hfe-popup__close:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-color-focus-ring);outline-offset:2px}.hfe-popup.hfe-popup--hosted{position:static;width:100%;max-height:none}.hfe-editor{position:relative;display:inline-block;width:max-content;background:var(--hfe-color-background);color:var(--hfe-color-foreground);border:none;box-shadow:inset 0 0 0 var(--hfe-border-width) var(--hfe-editor-color-border);border-radius:var(--hfe-border-radius);font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);font-variant-ligatures:none;line-height:var(--hfe-line-height);min-width:var(--hfe-editor-min-width, 100%);min-height:100%;box-sizing:border-box !important;overflow:hidden;resize:var(--hfe-editor-resize, none)}.hfe-editor--focused{box-shadow:inset 0 0 0 var(--hfe-border-width-focus) var(--hfe-color-focus-ring)}.hfe-editor--height-capped{overflow:hidden}.hfe-editor--height-capped .hfe-overlay,.hfe-editor--height-capped .hfe-editor__input{overflow-x:hidden;overflow-y:scroll;scrollbar-width:var(--hfe-scrollbar-width);scrollbar-color:var(--hfe-scrollbar-color) rgba(0,0,0,0)}.hfe-editor--height-capped:hover .hfe-overlay,.hfe-editor--height-capped:hover .hfe-editor__input{scrollbar-color:var(--hfe-scrollbar-color-hover) rgba(0,0,0,0)}.hfe-editor__layers{display:inline-block;vertical-align:top;min-width:100%;max-width:100%;min-height:inherit;box-sizing:border-box !important}.hfe-editor__input{position:absolute;inset:0;display:block;width:100%;height:100%;background:rgba(0,0,0,0);color:rgba(0,0,0,0);caret-color:var(--hfe-color-caret);resize:none;outline:none;overflow:hidden;z-index:2}.hfe-editor__input::selection{background:color-mix(in srgb, var(--hfe-color-accent) 15%, transparent);color:rgba(0,0,0,0)}.hfe-editor__input,.hfe-overlay{font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);font-weight:400;font-variant-ligatures:none;letter-spacing:0;line-height:var(--hfe-line-height);padding:var(--hfe-padding-vertical) var(--hfe-padding-horizontal);box-sizing:border-box;white-space:pre-wrap;word-break:break-all;tab-size:4;margin:0;border:0}.hfe-editor[dir=rtl] .hfe-editor__input,.hfe-editor[dir=rtl] .hfe-overlay{text-align:right}.hfe-editor--formula .hfe-editor__input,.hfe-editor--formula .hfe-overlay{font-family:var(--hfe-formula-font-family)}.hfe-formula-bar{display:flex;background:var(--hfe-formula-bar-background);border:var(--hfe-formula-bar-border-width) solid var(--hfe-formula-bar-border);color:var(--hfe-formula-bar-foreground);border-radius:var(--hfe-formula-bar-border-radius)}.hfe-formula-bar__cell{display:flex;align-items:center;box-sizing:border-box;border-inline-end:var(--hfe-formula-bar-border-width) solid var(--hfe-formula-bar-border)}.hfe-formula-bar__cell--address{flex:0 0 var(--hfe-formula-bar-address-width);gap:var(--hfe-formula-bar-gap-address)}.hfe-formula-bar__cell--formula{flex:1;min-width:0;gap:var(--hfe-formula-bar-gap-formula);padding-inline-start:var(--hfe-formula-bar-padding-horizontal);border-inline-end:none}.hfe-formula-bar__address-input{width:100%;min-width:0;box-sizing:border-box;border:none;outline:none;background:rgba(0,0,0,0);color:inherit;font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);padding:var(--hfe-formula-bar-padding-vertical) var(--hfe-formula-bar-padding-horizontal);border-start-start-radius:var(--hfe-formula-bar-border-radius)}.hfe-formula-bar__address-input:hover{background:var(--hfe-formula-bar-input-background-hover)}.hfe-formula-bar__address-input:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-formula-bar-input-focus-ring);outline-offset:calc(-1*var(--hfe-border-width-focus))}.hfe-formula-bar__fx-icon{flex:none;color:var(--hfe-formula-bar-foreground)}.hfe-formula-bar__body{position:relative;flex:1;min-width:0}.hfe-formula-bar__idle{cursor:text;height:calc(var(--hfe-line-height) + 2*var(--hfe-formula-bar-padding-vertical));font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);white-space:pre-wrap;word-break:break-all;overflow-x:hidden;overflow-y:auto}.hfe-formula-bar__overlay{position:absolute;top:0;inset-inline:0;z-index:var(--hfe-z-index-formula-bar)}.hfe-formula-bar .hfe-editor{display:block;max-width:100% !important;background:var(--hfe-formula-bar-background);--hfe-scrollbar-width: var(--hfe-formula-bar-scrollbar-width)}.hfe-formula-bar .hfe-editor,.hfe-formula-bar .hfe-overlay,.hfe-formula-bar .hfe-editor__input{border:none;box-shadow:none;border-radius:0;padding:0;min-height:unset}.hfe-formula-bar .hfe-overlay,.hfe-formula-bar .hfe-editor__input{padding:var(--hfe-formula-bar-padding-vertical) 0}.hfe-formula-bar__overlay .hfe-editor{border-start-end-radius:var(--hfe-formula-bar-border-radius)}.hfe-formula-bar[dir=rtl] .hfe-formula-bar__idle .hfe-overlay{text-align:right}.hfe-overlay{position:static;display:block;pointer-events:none;color:var(--hfe-color-foreground);z-index:1}.hfe-token--fn{color:var(--hfe-token-fn)}.hfe-token--fn-unknown{color:var(--hfe-token-fn-unknown)}.hfe-token--num{color:var(--hfe-token-num)}.hfe-token--str{color:var(--hfe-token-str)}.hfe-token--op{color:var(--hfe-token-op)}.hfe-token--paren{color:var(--hfe-token-paren)}.hfe-token--paren-err{color:var(--hfe-token-paren-err)}.hfe-token--bracket{color:var(--hfe-token-bracket)}.hfe-token--sep{color:var(--hfe-token-sep)}.hfe-token--sheet{color:var(--hfe-token-sheet)}.hfe-token--ident{color:var(--hfe-token-ident)}.hfe-token--err{color:var(--hfe-token-err);text-decoration:underline wavy var(--hfe-token-err)}.hfe-token--errlit{color:var(--hfe-token-err);font-weight:600}.hfe-token--ref-invalid{color:var(--hfe-token-ref-invalid);text-decoration:line-through}.hfe-token--comment{color:var(--hfe-token-comment);font-style:italic}.hfe-token--comment-err{text-decoration:underline wavy var(--hfe-token-err);text-underline-offset:2px}.hfe-token--paren-match{background:color-mix(in srgb, var(--hfe-token-paren-match) calc(var(--hfe-selection-opacity) * 100%), transparent);border-radius:3px}.hfe-token--dim{opacity:var(--hfe-dim-opacity)}.hfe-token--arg-active{text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px}.hfe-ref-mark{display:inline;padding:0;margin:0;border:0;background:rgba(0,0,0,0);color:var(--hfe-ref-1);pointer-events:none;font:inherit;white-space:inherit}.hfe-ref-mark[data-ref-index="0"]{color:var(--hfe-ref-1)}.hfe-ref-mark[data-ref-index="1"]{color:var(--hfe-ref-2)}.hfe-ref-mark[data-ref-index="2"]{color:var(--hfe-ref-3)}.hfe-ref-mark[data-ref-index="3"]{color:var(--hfe-ref-4)}.hfe-ref-mark[data-ref-index="4"]{color:var(--hfe-ref-5)}.hfe-ref-mark[data-ref-index="5"]{color:var(--hfe-ref-6)}.hfe-ref-mark[data-ref-index="6"]{color:var(--hfe-ref-7)}.hfe-ref-mark[data-ref-index="7"]{color:var(--hfe-ref-8)}.hfe-ref-mark--named{text-decoration:underline dotted;text-underline-offset:3px}.hfe-suggestions{position:fixed;z-index:var(--hfe-z-index-popup);width:264px;max-height:var(--hfe-menu-max-height);display:flex;flex-direction:column;background:var(--hfe-menu-background);color:var(--hfe-color-foreground);border:var(--hfe-menu-border-width) solid var(--hfe-menu-border-color);border-radius:var(--hfe-menu-border-radius);box-shadow:var(--hfe-menu-shadow);font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);gap:var(--hfe-gap)}.hfe-suggestions[hidden]{display:none}.hfe-suggestions__list{overflow-y:auto;padding:var(--hfe-menu-padding-vertical) var(--hfe-menu-padding-horizontal) 0;scrollbar-width:var(--hfe-scrollbar-width);scrollbar-color:var(--hfe-scrollbar-color) rgba(0,0,0,0)}.hfe-suggestions__list:hover{scrollbar-color:var(--hfe-scrollbar-color-hover) rgba(0,0,0,0)}.hfe-suggestions__list:only-of-type{padding-bottom:var(--hfe-menu-padding-vertical)}.hfe-suggestions__item{padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);border-radius:var(--hfe-menu-item-border-radius);cursor:pointer;white-space:nowrap}.hfe-suggestions__item--active{background:var(--hfe-color-highlight)}.hfe-suggestions__item-label{display:block;font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);font-weight:400;line-height:var(--hfe-line-height)}.hfe-suggestions__item-description{display:none;font-size:12px;line-height:16px;color:var(--hfe-color-foreground-secondary);font-family:var(--hfe-font-family);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.hfe-suggestions__item--active .hfe-suggestions__item-description{display:block}.hfe-suggestions__item--named .hfe-suggestions__item-label{display:flex;align-items:center;gap:var(--hfe-gap);color:var(--hfe-token-sheet)}.hfe-suggestions__match{color:var(--hfe-color-accent);font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);font-weight:400;line-height:var(--hfe-line-height)}.hfe-suggestions__kbd-row{flex-shrink:0;display:flex;align-items:center;gap:var(--hfe-gap);padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);border:var(--hfe-menu-kbd-row-border-width) solid var(--hfe-menu-kbd-row-border-color);border-radius:var(--hfe-menu-item-border-radius);font-family:var(--hfe-font-family);font-size:12px;line-height:16px;color:var(--hfe-color-foreground);margin:0 var(--hfe-menu-padding-horizontal) var(--hfe-menu-padding-vertical)}.hfe-suggestions__kbd-chip{background:var(--hfe-menu-kbd-row-chip-color);border-radius:4px;padding:2px 4px}.hfe-fn-help{position:fixed;z-index:var(--hfe-z-index-popup);width:320px;border-radius:var(--hfe-menu-border-radius);background:var(--hfe-menu-background);color:var(--hfe-color-foreground);border:var(--hfe-menu-border-width) solid var(--hfe-menu-border-color);box-shadow:var(--hfe-menu-shadow);font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);padding:var(--hfe-menu-padding-vertical) var(--hfe-menu-padding-horizontal);max-height:var(--hfe-menu-max-height);display:flex;flex-direction:column}.hfe-fn-help[hidden]{display:none}.hfe-fn-help__body{overflow-y:auto;scrollbar-width:var(--hfe-scrollbar-width);scrollbar-color:var(--hfe-scrollbar-color) rgba(0,0,0,0)}.hfe-fn-help__body:hover{scrollbar-color:var(--hfe-scrollbar-color-hover) rgba(0,0,0,0)}.hfe-fn-help__body:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-color-focus-ring);outline-offset:calc(-1*var(--hfe-border-width-focus))}.hfe-fn-help__signature{display:flex;flex-wrap:wrap;align-items:center;padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);background:var(--hfe-color-highlight);border-radius:var(--hfe-menu-item-border-radius);font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height)}.hfe-fn-help__name{color:var(--hfe-color-foreground)}.hfe-fn-help__arg{color:var(--hfe-color-foreground)}.hfe-fn-help__arg--active{background:var(--hfe-menu-chip-background);color:var(--hfe-menu-chip-foreground);border-radius:var(--hfe-menu-chip-border-radius);padding:0 4px}.hfe-fn-help__section{display:flex;flex-direction:column;gap:var(--hfe-gap);padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);border-radius:var(--hfe-menu-item-border-radius)}.hfe-fn-help__section-label{font-size:12px;line-height:16px;color:var(--hfe-color-foreground-secondary);font-family:var(--hfe-font-family)}.hfe-fn-help__example{font-family:var(--hfe-formula-font-family);color:var(--hfe-color-accent);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height)}.hfe-fn-help__about{font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);color:var(--hfe-color-foreground)}.hfe-fn-help__param-name{font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);color:var(--hfe-color-foreground)}.hfe-fn-help__param-name--active{color:var(--hfe-color-accent)}.hfe-fn-help__param-desc{font-family:var(--hfe-font-family);font-size:12px;line-height:16px;color:var(--hfe-color-foreground)}.hfe-fn-help__separator{height:1px;border:0;background:var(--hfe-menu-separator-color);margin:var(--hfe-gap) var(--hfe-menu-item-padding-horizontal)}.hfe-fn-help__learn-more{display:flex;align-items:center;gap:var(--hfe-gap);padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal) calc(var(--hfe-menu-item-padding-vertical) + var(--hfe-gap));border-radius:var(--hfe-menu-item-border-radius);color:var(--hfe-color-foreground);font-size:var(--hfe-font-size);font-family:var(--hfe-font-family);text-decoration:none;cursor:pointer}.hfe-fn-help__learn-more:hover{text-decoration:underline}.hfe-fn-help__learn-more:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-color-focus-ring);outline-offset:2px}.hfe-cell--error{position:relative;color:var(--hfe-token-err) !important;font-family:var(--hfe-error-font-family);font-size:var(--hfe-font-size);font-weight:400;line-height:var(--hfe-line-height)}.hfe-cell--error::after{content:"";position:absolute;inset-block-end:0;inset-inline-end:0;width:0;height:0;border-style:solid;border-width:0;border-block-end-width:6px;border-inline-start-width:6px;border-color:rgba(0,0,0,0);border-block-end-color:var(--hfe-color-error);pointer-events:none}.hfe-error{position:fixed;z-index:1000;max-width:280px;padding:var(--hfe-error-padding-vertical) var(--hfe-error-padding-horizontal);background:var(--hfe-color-background);border-inline-start:var(--hfe-border-width) solid var(--hfe-color-error);border-radius:var(--hfe-error-border-radius);box-shadow:var(--hfe-menu-shadow)}.hfe-error[hidden]{display:none}.hfe-error__title{font-size:var(--hfe-font-size);color:var(--hfe-color-error);font-family:var(--hfe-error-font-family)}.hfe-error__message{font-size:12px;line-height:16px;color:var(--hfe-color-foreground-secondary);font-family:var(--hfe-font-family);margin-top:var(--hfe-gap)}.hfe-highlight{position:absolute;inset:0;pointer-events:none}.hfe-highlight__item{position:absolute;pointer-events:none;border-width:var(--hfe-reference-border-width);border-radius:var(--hfe-border-radius);border-style:dashed;box-sizing:border-box !important;z-index:5;display:none}.hfe-live-region{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}'; diff --git a/hyperformula-ui/packages/core/tests/theme/stylesheet.test.ts b/hyperformula-ui/packages/core/tests/theme/stylesheet.test.ts index 02b9eb9a9..ab681cf75 100644 --- a/hyperformula-ui/packages/core/tests/theme/stylesheet.test.ts +++ b/hyperformula-ui/packages/core/tests/theme/stylesheet.test.ts @@ -30,3 +30,16 @@ describe("package stylesheet", () => { expect(declarations(":root")["--hfe-error-font-family"]).toBe("var(--hfe-font-family)"); }); }); + +describe("package color scheme", () => { + it("never selects package elements by a class-attribute substring", () => { + expect(CSS_TEXT).not.toContain("[class*="); + }); + + it("sets the color scheme only on the package roots, where a host theme class overrides it", () => { + const roots = ":where(.hfe-editor,.hfe-formula-bar,.hfe-popup-portal,.hfe-popup--hosted)"; + + expect(declarations(roots)["color-scheme"]).toBe("light dark"); + expect(CSS_TEXT.match(/color-scheme:/g)).toHaveLength(1); + }); +}); From 943009a33d6d397ebea05ca0327b7af148232021 Mon Sep 17 00:00:00 2001 From: adrianspdev Date: Thu, 22 Oct 2026 12:24:44 +0200 Subject: [PATCH 06/35] Describe a cell's error to screen readers without the error popup open A cell's error message was associated with the cell only while the error popup was open, through aria-errormessage, which some screen readers ignore, so a screen-reader user landing on the cell heard nothing. markCell() now also sets the message as the cell's aria-description, and removes it again when the error clears. --- hyperformula-ui/dev-docs/ARCHITECTURE.md | 6 +++++ hyperformula-ui/packages/core/DOCS.md | 2 +- .../packages/core/src/ui/popup/cellError.ts | 24 +++++++++++++++---- .../core/tests/ui/popup/error-popup.test.ts | 22 +++++++++++++++++ 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/hyperformula-ui/dev-docs/ARCHITECTURE.md b/hyperformula-ui/dev-docs/ARCHITECTURE.md index 46ef79aa4..507f593f0 100644 --- a/hyperformula-ui/dev-docs/ARCHITECTURE.md +++ b/hyperformula-ui/dev-docs/ARCHITECTURE.md @@ -732,6 +732,12 @@ re-render clears that identity, so a re-render after Esc-dismiss can make the to even though pointer/focus never logically left the cell. Accepted as a documented limitation; re-keying by logical cell would need a host render hook — future work if it bites. +`setCellError` (behind `FormulaBuilder.markCell`) also writes the message into the cell's +`aria-description`, so a screen reader that lands on an error cell reads the message without the +popup ever opening; `aria-errormessage` support is uneven (VoiceOver mostly ignores it). Clearing +the error removes the description only when it still holds the message `setCellError` wrote, so a +host's own description survives a cell that never had an error. + While a hovered/focused error cell surfaces its message via `ErrorPopup` and `aria-invalid`/`aria-errormessage` (the popup root carries a generated id, associated onto the cell for the screen reader to resolve on demand), committing a formula moves focus away before diff --git a/hyperformula-ui/packages/core/DOCS.md b/hyperformula-ui/packages/core/DOCS.md index 471fdaf3c..b27e04f20 100644 --- a/hyperformula-ui/packages/core/DOCS.md +++ b/hyperformula-ui/packages/core/DOCS.md @@ -180,7 +180,7 @@ const builder = new FormulaBuilder({ rootEl: gridRoot, adapter, formulaBarContai | `FunctionHelpOptions` | interface | Function-help popup options. | | `PopupHostOption` | interface | Docks a popup into a host container instead of the floating overlay. | | `PopupsOptions` | interface | Popup options bag on `FormulaBuilderOptions`: the top-level `showClose` is the shared default, overridable per popup. | -| `setCellError` | function | Marks (`message`) or clears (`null`) a cell element's error state — class, data attribute, and `aria-invalid`. | +| `setCellError` | function | Marks (`message`) or clears (`null`) a cell element's error state — class, data attribute, `aria-invalid`, and an `aria-description` carrying the message, so a screen reader reads the error without the error popup open. | | `SuggestionItem` | interface | One autocomplete row: a function name or (`kind: "named"`) a named expression. | | `SuggestionsAriaState` | interface | ARIA combobox state the suggestions popup reports so the host input can mirror it. | | `SuggestionsHandlers` | interface | Callbacks the suggestions popup invokes on selection, explicit close, and ARIA changes. | diff --git a/hyperformula-ui/packages/core/src/ui/popup/cellError.ts b/hyperformula-ui/packages/core/src/ui/popup/cellError.ts index 2b55b7536..a48f08b0c 100644 --- a/hyperformula-ui/packages/core/src/ui/popup/cellError.ts +++ b/hyperformula-ui/packages/core/src/ui/popup/cellError.ts @@ -19,18 +19,32 @@ export function cellErrorMessage(value: unknown): string | null { /** * Marks (`message`) or clears (`null`) a cell element's error state — class, data attribute, - * and `aria-invalid`. Adapter-facing: the host renders the error, core never does. + * `aria-invalid`, and an `aria-description` carrying the message, so a screen reader reads + * the error without the error popup open. Clearing removes the description only when it is + * the one this function set. Adapter-facing: the host renders the error, core never does. * * @category Popups */ export function setCellError(element: HTMLElement, message: string | null): void { + const previousMessage = element.dataset.hfeError; + if (message) { element.classList.add(CN.cellError); element.dataset.hfeError = message; element.setAttribute("aria-invalid", "true"); - } else { - element.classList.remove(CN.cellError); - delete element.dataset.hfeError; - element.removeAttribute("aria-invalid"); + element.setAttribute("aria-description", message); + + return; + } + + element.classList.remove(CN.cellError); + delete element.dataset.hfeError; + element.removeAttribute("aria-invalid"); + + if ( + previousMessage !== undefined && + element.getAttribute("aria-description") === previousMessage + ) { + element.removeAttribute("aria-description"); } } diff --git a/hyperformula-ui/packages/core/tests/ui/popup/error-popup.test.ts b/hyperformula-ui/packages/core/tests/ui/popup/error-popup.test.ts index c00ef6fc3..007853e11 100644 --- a/hyperformula-ui/packages/core/tests/ui/popup/error-popup.test.ts +++ b/hyperformula-ui/packages/core/tests/ui/popup/error-popup.test.ts @@ -37,6 +37,28 @@ describe("setCellError", () => { setCellError(cell, null); expect(cell.getAttribute("aria-invalid")).toBeNull(); }); + + it("describes the error to assistive technology without the popup open", () => { + const cell = document.createElement("div"); + + setCellError(cell, "#REF! — bad reference"); + expect(cell.getAttribute("aria-description")).toBe("#REF! — bad reference"); + + setCellError(cell, "#NAME?"); + expect(cell.getAttribute("aria-description")).toBe("#NAME?"); + + setCellError(cell, null); + expect(cell.getAttribute("aria-description")).toBeNull(); + }); + + it("keeps a description the host set when the cell has no error", () => { + const cell = document.createElement("div"); + + cell.setAttribute("aria-description", "Total for the quarter"); + setCellError(cell, null); + + expect(cell.getAttribute("aria-description")).toBe("Total for the quarter"); + }); }); describe("ErrorPopup DOM rendering", () => { From 963992f2658ccedd723688b0b5c5011babcc5b82 Mon Sep 17 00:00:00 2001 From: adrianspdev Date: Thu, 22 Oct 2026 12:25:48 +0200 Subject: [PATCH 07/35] Reject an address-box target the grid could not select The address box checked only the grid bounds and reported success even when the host could not select the cell, for example a filtered-out row or a hidden column, so it showed the typed address while the selection stayed put. IGridAdapter.setActiveCell() and setSelection() may now return false; the address box then reverts, announces the invalid address and does not scroll. Adapters that return nothing keep working as before. --- hyperformula-ui/dev-docs/ARCHITECTURE.md | 5 ++ hyperformula-ui/dev-docs/SPEC.md | 2 +- .../packages/core/src/adapter/types.ts | 15 ++-- .../core/src/formulaBar/formulaBar.ts | 10 ++- .../core/tests/formulaBar/formula-bar.test.ts | 70 +++++++++++++++++++ 5 files changed, 95 insertions(+), 7 deletions(-) diff --git a/hyperformula-ui/dev-docs/ARCHITECTURE.md b/hyperformula-ui/dev-docs/ARCHITECTURE.md index 507f593f0..e33bd0094 100644 --- a/hyperformula-ui/dev-docs/ARCHITECTURE.md +++ b/hyperformula-ui/dev-docs/ARCHITECTURE.md @@ -60,6 +60,11 @@ reparents between two locations via `FormulaEditor.attachTo(mount)`: address box), paired with `scrollCellIntoView`, and by `FormulaBuilder` after an outside-click commit (see the outside-click commit policy below). Addresses are HF-space; the external Handsontable plugin maps them back to visual indices before selecting. + Both `setActiveCell` and `setSelection` return `boolean | void`: `false` means the host could + not select the target (a filtered-out row, a hidden column), and the address box then rejects + the typed address — it reverts, announces "Invalid cell address", and skips + `scrollCellIntoView`. `void` counts as success, so adapters written before the result keep + working. - `commit(value, direction?)` — controller calls to commit the current value; the optional `Direction` drives the host's post-commit selection move (Enter → down, Tab → right, …). `commit(value, direction)` must apply the value to the engine synchronously before diff --git a/hyperformula-ui/dev-docs/SPEC.md b/hyperformula-ui/dev-docs/SPEC.md index 9cdf38b5d..a3d0de891 100644 --- a/hyperformula-ui/dev-docs/SPEC.md +++ b/hyperformula-ui/dev-docs/SPEC.md @@ -247,7 +247,7 @@ Role interfaces and their members: |---|---| | `GridPickSurface` | `outsideClickCommitMode` (readonly), `getCellAddressAt(x, y)`, `formatRef(ref)`, `setHighlights(items)`, `highlightRange(range, color)`, `clearEphemeralHighlight()`, `clearHighlights()`, `setOverlayClassName(className)`, `onCellPick(callback)`, `onRangePick(callback)`, `onOutsideCellClick(callback)`, `enterRefSelectionMode(resolveColor?, isPickEligible?)`, `exitRefSelectionMode()` | | `GridNavigation` | `getGridSize()`, `getDataEdge(from, direction)`, `stepCell(from, direction)`, `scrollCellIntoView(address)` | -| `GridEditLifecycle` | `getActiveCell()`, `onSelectionChange(callback)`, `getCellFormula(address)`, `beginEdit(address, seed?)`, `selectFirstCell()`, `setActiveCell(address)`, `setSelection(range)`, `onInlineEditStart(callback)`, `onInlineEditClose(callback)`, `onSwitchToInline(callback)`, `commit(value, direction?)` (must write to the engine synchronously before returning — commit-error announcements read the engine and capture `valuesUpdated` inside the commit call; a deferred write degrades announcements to silence, never a crash), `cancel()`, `setInlineEditorVisible(visible)` | +| `GridEditLifecycle` | `getActiveCell()`, `onSelectionChange(callback)`, `getCellFormula(address)`, `beginEdit(address, seed?)`, `selectFirstCell()`, `setActiveCell(address)` and `setSelection(range)` (return `false` when the host cannot select the target, so the address box rejects it; `void` counts as success), `onInlineEditStart(callback)`, `onInlineEditClose(callback)`, `onSwitchToInline(callback)`, `commit(value, direction?)` (must write to the engine synchronously before returning — commit-error announcements read the engine and capture `valuesUpdated` inside the commit call; a deferred write degrades announcements to silence, never a crash), `cancel()`, `setInlineEditorVisible(visible)` | | `GridHeaderPickSurface` (optional) | `onHeaderPick(callback)` — emits `HeaderPick` on row/column header click/drag; mixed into `IGridAdapter`/`EditorGridAdapter` as `Partial`, so adapters without headers implement nothing (no `NullAdapter` no-op) | | `IGridAdapter` | extends the three required role interfaces plus `Partial`; adds `destroy()` | | `GridGeometry` | adapter-side geometry contract consumed by `HighlightLayer`: `getOverlayHost()`, `getRangeRects(range)`, `getViewportClip?()`, `onViewportChange(callback)`, `getColHeaderEl?(col)`, `getRowHeaderEl?(row)` | diff --git a/hyperformula-ui/packages/core/src/adapter/types.ts b/hyperformula-ui/packages/core/src/adapter/types.ts index b04f8fcea..79783b85e 100644 --- a/hyperformula-ui/packages/core/src/adapter/types.ts +++ b/hyperformula-ui/packages/core/src/adapter/types.ts @@ -291,14 +291,21 @@ export interface GridEditLifecycle { */ selectFirstCell(): void; - /** Selects `address` without starting an edit, via the same path as `selectFirstCell`. */ - setActiveCell(address: CellAddress): void; + /** + * Selects `address` without starting an edit, via the same path as `selectFirstCell`. + * Returns `false` when the host could not select it (a filtered-out row, a hidden column); + * the address box then rejects the typed address. Returning nothing counts as success. + */ + // biome-ignore lint/suspicious/noConfusingVoidType: adapters written before the boolean result return void + setActiveCell(address: CellAddress): boolean | void; /** * Selects the given range. Selection gestures (mouse drag, shift+click, shift+arrows) are - * host-owned — core and adapters carry the data contract only. + * host-owned — core and adapters carry the data contract only. Returns `false` when the host + * could not select the range; returning nothing counts as success. */ - setSelection(range: CellRange): void; + // biome-ignore lint/suspicious/noConfusingVoidType: adapters written before the boolean result return void + setSelection(range: CellRange): boolean | void; /** * Fires when a cell editor opens. `mount` is the overlay element the shared editor attaches diff --git a/hyperformula-ui/packages/core/src/formulaBar/formulaBar.ts b/hyperformula-ui/packages/core/src/formulaBar/formulaBar.ts index 6da971b7a..7cf198be7 100644 --- a/hyperformula-ui/packages/core/src/formulaBar/formulaBar.ts +++ b/hyperformula-ui/packages/core/src/formulaBar/formulaBar.ts @@ -196,7 +196,10 @@ export class FormulaBar { const sheet = this.adapter.getActiveCell()?.sheet ?? ""; const address = { sheet, col: target.col, row: target.row }; - this.adapter.setActiveCell(address); + if (this.adapter.setActiveCell(address) === false) { + return false; + } + this.adapter.scrollCellIntoView(address); return true; @@ -220,7 +223,10 @@ export class FormulaBar { end: { sheet, col: range.end.col, row: range.end.row }, }; - this.adapter.setSelection(cellRange); + if (this.adapter.setSelection(cellRange) === false) { + return false; + } + this.adapter.scrollCellIntoView(cellRange.start); return true; diff --git a/hyperformula-ui/packages/core/tests/formulaBar/formula-bar.test.ts b/hyperformula-ui/packages/core/tests/formulaBar/formula-bar.test.ts index f600e3477..64fbb2070 100644 --- a/hyperformula-ui/packages/core/tests/formulaBar/formula-bar.test.ts +++ b/hyperformula-ui/packages/core/tests/formulaBar/formula-bar.test.ts @@ -1329,3 +1329,73 @@ describe("FormulaBar named expressions", () => { editor.destroy(); }); }); + +describe("FormulaBar address box when the grid cannot select", () => { + function makeRefusingHarness(overrides: Partial) { + const container = document.createElement("div"); + const announce = vi.fn(); + const scrollCellIntoView = vi.fn(); + const { adapter, fireSelection } = makeBarAdapter({ + getActiveCell: activeCellAtOrigin, + getGridSize: () => ({ rows: 10, cols: 10 }), + scrollCellIntoView, + ...overrides, + }); + const editor = makeEditor(adapter); + const liveRegion = { announce, destroy: () => {} } as unknown as LiveRegion; + const controller = makeFormulaBar(container, { + adapter, + editor, + knownFunctions: new Set(), + liveRegion, + }); + + fireSelection(singleCellSelection({ sheet: "", col: 0, row: 0 })); + + return { container, editor, controller, announce, scrollCellIntoView }; + } + + function submit(container: HTMLElement, text: string): HTMLInputElement { + const input = addressInput(container); + + input.value = text; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + return input; + } + + it("rejects a cell the grid could not select", () => { + const harness = makeRefusingHarness({ setActiveCell: vi.fn(() => false) }); + const input = submit(harness.container, "B3"); + + expect(input.value).toBe("A1"); + expect(harness.announce).toHaveBeenCalledWith("Invalid cell address: B3"); + expect(harness.scrollCellIntoView).not.toHaveBeenCalled(); + + harness.controller.destroy(); + harness.editor.destroy(); + }); + + it("rejects a range the grid could not select", () => { + const harness = makeRefusingHarness({ setSelection: vi.fn(() => false) }); + const input = submit(harness.container, "B3:C4"); + + expect(input.value).toBe("A1"); + expect(harness.announce).toHaveBeenCalledWith("Invalid cell address: B3:C4"); + expect(harness.scrollCellIntoView).not.toHaveBeenCalled(); + + harness.controller.destroy(); + harness.editor.destroy(); + }); + + it("accepts a cell when the adapter returns nothing", () => { + const harness = makeRefusingHarness({ setActiveCell: vi.fn(() => undefined) }); + const input = submit(harness.container, "B3"); + + expect(input.value).toBe("B3"); + expect(harness.announce).not.toHaveBeenCalled(); + + harness.controller.destroy(); + harness.editor.destroy(); + }); +}); From f0c617db2e0c4c739783aef3bad7880e805846ba Mon Sep 17 00:00:00 2001 From: adrianspdev Date: Thu, 22 Oct 2026 12:27:09 +0200 Subject: [PATCH 08/35] Fall back instead of throwing when a constructor container is missing A popups.*.container or formulaBarContainer selector that matched nothing made the FormulaBuilder constructor throw. A host that rebuilds the builder from an event handler may pass a container its framework has just unmounted, and the throw left it without a builder. The constructor now logs a warning once per selector and uses the default placement: a floating popup, or no formula bar. The attach methods still throw on a missing container. --- hyperformula-ui/dev-docs/ARCHITECTURE.md | 9 ++-- hyperformula-ui/dev-docs/SPEC.md | 2 +- .../packages/core/src/formulaBuilder.ts | 44 +++++++++++++------ .../packages/core/src/ui/popup/types.ts | 6 ++- .../core/tests/formula-builder.test.ts | 39 +++++++++------- 5 files changed, 67 insertions(+), 33 deletions(-) diff --git a/hyperformula-ui/dev-docs/ARCHITECTURE.md b/hyperformula-ui/dev-docs/ARCHITECTURE.md index e33bd0094..9a5ef5b7d 100644 --- a/hyperformula-ui/dev-docs/ARCHITECTURE.md +++ b/hyperformula-ui/dev-docs/ARCHITECTURE.md @@ -613,9 +613,12 @@ All popup-style overlays share a common base class `BasePopup` string` (selector via `document.querySelector`, throwing on a miss) plus `popups.suggestions.container`/`popups.fnHelp.container` constructor sugar (`PopupHostOption`) that delegates to attach after construction — mirroring - `formulaBarContainer`/`attachFormulaBar`. All three constructor selectors resolve - first thing in the constructor, before styles/portal/editor allocate, so a bad - selector throws without leaking resources. + `formulaBarContainer`/`attachFormulaBar`. The constructor never throws on a selector: + one that matches nothing logs a `console.warn` once per message and falls back to the + default placement (a floating popup, no bar), because a host that rebuilds the builder + from an event handler may pass a container its framework unmounted in between, and a + throw there would leave the host without a builder. The `attach*` methods still throw on + a miss: the host calls them directly and can handle the error. ### BasePopup lifecycle invariants diff --git a/hyperformula-ui/dev-docs/SPEC.md b/hyperformula-ui/dev-docs/SPEC.md index a3d0de891..f68577307 100644 --- a/hyperformula-ui/dev-docs/SPEC.md +++ b/hyperformula-ui/dev-docs/SPEC.md @@ -149,7 +149,7 @@ One `FormulaBuilder` per grid instance. It owns the `PopupContext` (shared popup Constructed as `new FormulaBuilder(options: FormulaBuilderOptions)`; the options interface lives in `packages/core/src/formulaBuilder.ts`. Fields: `rootEl` (required — grid root, error-popup hover/focus host), `adapter` (defaults to `NullAdapter`), `hyperformula` (instance — functions, validation, named exprs), `className` (single theming entry point: portal, bar, editor, overlay), `formulaBarContainer` (wires the formula bar — element or CSS selector), `popups` (`PopupsOptions` — shared `showClose` default + per-popup overrides), `icons` (`IconsOptions` — host factories that replace the built-in glyphs: `close`, `externalLink`, `namedExpression`, `function`; forwarded to the editor's popups, the error popup and the bar's fx icon), `initialValue` / `language` / `sheetId` (forwarded to the editor), `direction` (`LayoutDirection` — `"ltr" | "rtl"`, default `"ltr"`; construction-only — layout mirrors, formula text stays LTR), `onCommit(value, direction?)` (default: `adapter.commit`), `onCancel()` (default: `adapter.cancel`). -Methods: `openEditor(mount, seed)`, `closeEditor()`, `markCell(cellEl, value)`, `attachFormulaBar(container)`, `detachFormulaBar()`, `attachSuggestionsHost(container: HTMLElement | string)`, `detachSuggestionsHost()`, `attachFnHelpHost(container: HTMLElement | string)`, `detachFnHelpHost()`, `destroy()`. Getters: `editor` (the `FormulaEditor`), `hasFormulaBar`. The `formulaBarContainer` option is sugar for `attachFormulaBar`; detaching restores the barless inline-edit subscription. `popups.suggestions.container` / `popups.fnHelp.container` (`PopupHostOption`, `HTMLElement | string`) are constructor sugar mirroring `formulaBarContainer`: stripped from the popup options and delegated to `attachSuggestionsHost`/`attachFnHelpHost` after editor construction. A selector that resolves to nothing throws `FormulaBuilder: host "…" not found`; all four attach/detach methods are no-ops after `destroy()`. +Methods: `openEditor(mount, seed)`, `closeEditor()`, `markCell(cellEl, value)`, `attachFormulaBar(container)`, `detachFormulaBar()`, `attachSuggestionsHost(container: HTMLElement | string)`, `detachSuggestionsHost()`, `attachFnHelpHost(container: HTMLElement | string)`, `detachFnHelpHost()`, `destroy()`. Getters: `editor` (the `FormulaEditor`), `hasFormulaBar`. The `formulaBarContainer` option is sugar for `attachFormulaBar`; detaching restores the barless inline-edit subscription. `popups.suggestions.container` / `popups.fnHelp.container` (`PopupHostOption`, `HTMLElement | string`) are constructor sugar mirroring `formulaBarContainer`: stripped from the popup options and delegated to `attachSuggestionsHost`/`attachFnHelpHost` after editor construction. A selector that resolves to nothing makes the `attach*` methods throw `FormulaBuilder: host "…" not found`; in the constructor options it logs that message once as a warning and falls back to the default placement; all four attach/detach methods are no-ops after `destroy()`. `PopupsOptions`: shared `showClose` default plus per-popup overrides — `suggestions` (`showClose`, `showKeyboardHelp`, `showNamedExpressions` — all default `true`, plus `container?: HTMLElement | string` to dock it into a host element instead of floating), `fnHelp` (`showClose`, plus `container?: HTMLElement | string`), `error` (`showClose`, default `false`). diff --git a/hyperformula-ui/packages/core/src/formulaBuilder.ts b/hyperformula-ui/packages/core/src/formulaBuilder.ts index 2f4be8698..8c3c16b0f 100644 --- a/hyperformula-ui/packages/core/src/formulaBuilder.ts +++ b/hyperformula-ui/packages/core/src/formulaBuilder.ts @@ -15,6 +15,8 @@ import { withSharedClose } from "./ui/popup/popupsOptions"; import type { LayoutDirection, PopupsOptions } from "./ui/popup/types"; import type { IconsOptions } from "./ui/types"; +const reportedMissingContainers = new Set(); + /** * Options for {@link FormulaBuilder}; editor behavior options are inherited from * `FormulaEditorOptions`. @@ -42,7 +44,10 @@ export interface FormulaBuilderOptions */ className?: string; - /** Sugar for calling {@link FormulaBuilder.attachFormulaBar} during construction. */ + /** + * Sugar for calling {@link FormulaBuilder.attachFormulaBar} during construction. A selector + * that matches nothing logs a warning once and the builder starts without a bar. + */ formulaBarContainer?: HTMLElement | string; /** Popup behavior overrides (shared `showClose` default with per-popup overrides). */ @@ -99,9 +104,9 @@ export class FormulaBuilder { const { container: suggestionsHost, ...suggestionsOwn } = popups?.suggestions ?? {}; const { container: fnHelpHost, ...fnHelpOwn } = popups?.fnHelp ?? {}; - const suggestionsHostEl = this.resolveContainer(suggestionsHost, "suggestions host"); - const fnHelpHostEl = this.resolveContainer(fnHelpHost, "fnHelp host"); - const formulaBarEl = this.resolveContainer(formulaBarContainer, "formulaBarContainer"); + const suggestionsHostEl = this.resolveOptionalContainer(suggestionsHost, "suggestions host"); + const fnHelpHostEl = this.resolveOptionalContainer(fnHelpHost, "fnHelp host"); + const formulaBarEl = this.resolveOptionalContainer(formulaBarContainer, "formulaBarContainer"); injectStyles(); this.liveRegion = new LiveRegion(); @@ -205,12 +210,18 @@ export class FormulaBuilder { this.closeEditor(); } - private resolveContainer(container: HTMLElement | string, label: string): HTMLElement; - private resolveContainer( - container: HTMLElement | string | undefined, - label: string, - ): HTMLElement | undefined; - private resolveContainer( + private resolveContainer(container: HTMLElement | string, label: string): HTMLElement { + const containerEl = + typeof container === "string" ? document.querySelector(container) : container; + + if (containerEl === null) { + throw new Error(`FormulaBuilder: ${label} "${container}" not found`); + } + + return containerEl; + } + + private resolveOptionalContainer( container: HTMLElement | string | undefined, label: string, ): HTMLElement | undefined { @@ -221,11 +232,18 @@ export class FormulaBuilder { const containerEl = typeof container === "string" ? document.querySelector(container) : container; - if (containerEl === null) { - throw new Error(`FormulaBuilder: ${label} "${container}" not found`); + if (containerEl !== null) { + return containerEl; } - return containerEl; + const warning = `FormulaBuilder: ${label} "${container}" not found; using the default placement`; + + if (!reportedMissingContainers.has(warning)) { + reportedMissingContainers.add(warning); + console.warn(warning); + } + + return undefined; } private announceCommitErrors( diff --git a/hyperformula-ui/packages/core/src/ui/popup/types.ts b/hyperformula-ui/packages/core/src/ui/popup/types.ts index e14bc8996..8acf3e079 100644 --- a/hyperformula-ui/packages/core/src/ui/popup/types.ts +++ b/hyperformula-ui/packages/core/src/ui/popup/types.ts @@ -136,7 +136,11 @@ export interface ErrorOptions { * @category Popups */ export interface PopupHostOption { - /** Host element (or selector) the popup mounts into. Omit for floating behavior. */ + /** + * Host element (or selector) the popup mounts into. Omit for floating behavior. Passed to + * the `FormulaBuilder` constructor, a selector that matches nothing logs a warning once and + * the popup floats. + */ container?: HTMLElement | string; } diff --git a/hyperformula-ui/packages/core/tests/formula-builder.test.ts b/hyperformula-ui/packages/core/tests/formula-builder.test.ts index c3b4c4d9b..02094f14f 100644 --- a/hyperformula-ui/packages/core/tests/formula-builder.test.ts +++ b/hyperformula-ui/packages/core/tests/formula-builder.test.ts @@ -728,25 +728,34 @@ describe("FormulaBuilder", () => { builder = new FormulaBuilder({ rootEl }); }); - it("constructor throws on a missing popup host before allocating any resources", () => { - const portalCount = document.querySelectorAll(sel(CN.popupPortal)).length; + it("constructor falls back to the default placement when a selector matches nothing", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const scoped = new FormulaBuilder({ + rootEl, + popups: { suggestions: { container: "#missing" }, fnHelp: { container: "#missing" } }, + formulaBarContainer: "#missing", + }); - expect( - () => new FormulaBuilder({ rootEl, popups: { suggestions: { container: "#missing" } } }), - ).toThrow('FormulaBuilder: suggestions host "#missing" not found'); - expect( - () => new FormulaBuilder({ rootEl, popups: { fnHelp: { container: "#missing" } } }), - ).toThrow('FormulaBuilder: fnHelp host "#missing" not found'); - expect(() => new FormulaBuilder({ rootEl, formulaBarContainer: "#missing" })).toThrow( - 'FormulaBuilder: formulaBarContainer "#missing" not found', - ); + expect(scoped.hasFormulaBar).toBe(false); + expect(warn.mock.calls.map(([message]) => message)).toEqual([ + 'FormulaBuilder: suggestions host "#missing" not found; using the default placement', + 'FormulaBuilder: fnHelp host "#missing" not found; using the default placement', + 'FormulaBuilder: formulaBarContainer "#missing" not found; using the default placement', + ]); - expect(document.querySelectorAll(sel(CN.popupPortal)).length).toBe(portalCount); + scoped.destroy(); + warn.mockRestore(); + }); - builder.destroy(); + it("constructor warns only once per missing selector", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const options = { rootEl, popups: { suggestions: { container: "#gone-once" } } }; - expect(document.head.querySelector("style[data-hfe-styles]")).toBeNull(); - builder = new FormulaBuilder({ rootEl }); + new FormulaBuilder(options).destroy(); + new FormulaBuilder(options).destroy(); + + expect(warn).toHaveBeenCalledOnce(); + warn.mockRestore(); }); it("popups.*.container constructor options delegate to attach", () => { From 7be774f56ee115eb2b9b1c8de27f0c5928abf37f Mon Sep 17 00:00:00 2001 From: adrianspdev Date: Thu, 22 Oct 2026 12:29:31 +0200 Subject: [PATCH 09/35] Let a host commit to every selected cell with Ctrl/Cmd+Enter Ctrl/Cmd+Enter was bound to inserting a newline, and the adapter had no way to commit to the whole selection, so a spreadsheet host could not offer the usual fill-selection shortcut. Ctrl/Cmd+Enter now calls the new optional IGridAdapter.commitFill(value), or the onCommitFill option, when the host provides one, and inserts a newline otherwise. Alt+Enter always inserts a newline. --- hyperformula-ui/dev-docs/ARCHITECTURE.md | 6 ++ .../dev-docs/KEYBOARD-SHORTCUTS.md | 5 +- hyperformula-ui/dev-docs/SPEC.md | 5 +- .../packages/core/src/adapter/types.ts | 7 +++ .../packages/core/src/formulaBuilder.ts | 19 +++++-- .../src/formulaEditor/keyboardController.ts | 14 +++++ .../packages/core/src/formulaEditor/keymap.ts | 2 + .../packages/core/src/formulaEditor/types.ts | 6 ++ .../core/tests/formula-builder.test.ts | 55 +++++++++++++++++++ .../formulaEditor/keyboard-controller.test.ts | 32 +++++++++++ 10 files changed, 143 insertions(+), 8 deletions(-) diff --git a/hyperformula-ui/dev-docs/ARCHITECTURE.md b/hyperformula-ui/dev-docs/ARCHITECTURE.md index 9a5ef5b7d..9888c3392 100644 --- a/hyperformula-ui/dev-docs/ARCHITECTURE.md +++ b/hyperformula-ui/dev-docs/ARCHITECTURE.md @@ -72,6 +72,12 @@ reparents between two locations via `FormulaEditor.attachTo(mount)`: inside the commit call. Applies equally to a host-side commit bridge (such as the plain-table example's `PlainTableHost.commit`) and any consumer-supplied `onCommit`. A deferred write degrades announcements to silence (no crash, no wrong message). +- `commitFill?(value)` — optional; Ctrl/Cmd+Enter calls it (through `FormulaBuilder`'s + commit wrapper, so commit-error announcements work the same) to write `value` into every + selected cell. A builder `onCommitFill` option takes precedence, as `onCommit` does over + `commit`. With neither, the `commitFill` command returns `false` and `ShortcutManager` falls + through to the next Ctrl/Cmd+Enter binding, `insertNewline`; Alt+Enter always inserts a + newline. - `cancel()` — controller calls to cancel the current edit. - `setInlineEditorVisible(visible)` — hides or shows the cell overlay mount while the editor is hosted in the bar (the plain-table and TanStack example adapters toggle the container's CSS display; diff --git a/hyperformula-ui/dev-docs/KEYBOARD-SHORTCUTS.md b/hyperformula-ui/dev-docs/KEYBOARD-SHORTCUTS.md index 34d91a065..459257e5d 100644 --- a/hyperformula-ui/dev-docs/KEYBOARD-SHORTCUTS.md +++ b/hyperformula-ui/dev-docs/KEYBOARD-SHORTCUTS.md @@ -60,7 +60,8 @@ the formula). `rangeMode ∈ { auto, on, off }`, held in `KeyboardController`, d | Shift + Enter | Commit, move **up** | same | | Tab | Commit, move **right** | same | | Shift + Tab | Commit, move **left** | same | -| Ctrl/Cmd + Enter | Insert a newline in the formula | same | +| Ctrl/Cmd + Enter | Commit to every selected cell when the host supports it (`IGridAdapter.commitFill` or the `onCommitFill` option); otherwise insert a newline in the formula | same | +| Alt + Enter | Insert a newline in the formula | same | | Escape | Ladder first (see below); if neither popup is open, cancels the pending point ref and stays editing — a 2nd Escape cancels the edit | Ladder: close suggestions popup if open, else close fn-help popup if open (suppressing it for the edit session), else cancel the edit | | `(` | Auto-insert the matching `)`, caret inside | same | | `)` | Skip over an existing `)` (else insert) | same | @@ -132,7 +133,7 @@ resting on an error cell. No adapter involvement. | 13 | Ctrl/Cmd + E | Identical to F2 | | 14 | Enter / Tab / Shift+Tab commit | Commit value, move active cell down / right / left | | 15 | Shift+Enter commit | Commit value, move active cell up one cell | -| 16 | Ctrl/Cmd+Enter | Insert a newline instead of committing | +| 16 | Ctrl/Cmd+Enter | Commit to every selected cell when the host fills; else insert a newline | | 17 | Escape while pointing | First press removes the pending ref (stay editing); second cancels the edit | | 18 | `(` typed in a formula | Auto-insert `)` and place the caret inside | | 19 | `)` typed before an existing `)` | Skip over it instead of inserting a duplicate | diff --git a/hyperformula-ui/dev-docs/SPEC.md b/hyperformula-ui/dev-docs/SPEC.md index f68577307..3399d90a4 100644 --- a/hyperformula-ui/dev-docs/SPEC.md +++ b/hyperformula-ui/dev-docs/SPEC.md @@ -216,7 +216,8 @@ Keybindings are declarative: `formulaEditor/keymap.ts` (`BINDINGS`, first match - **Auto-close parens**: typing `(` while editing a formula inserts `()` and places the caret between. If a selection exists, it wraps the selection. - **Smart `)`**: typing `)` when one already sits at the caret moves the caret past it instead of inserting. -- **Newline**: `Cmd/Ctrl+Enter` inserts `\n`. +- **Newline**: `Alt+Enter` inserts `\n`; so does `Cmd/Ctrl+Enter` when the host has no fill commit. +- **Fill commit**: `Cmd/Ctrl+Enter` commits the value to every selected cell through `onCommitFill` or `IGridAdapter.commitFill`, when either is provided. - **Directional commit**: plain `Enter` commits down, `Shift+Enter` up, `Tab` right, `Shift+Tab` left — after `closeUnbalancedParens` tops off missing `)`. The direction reaches the host via `onCommit(value, direction?)` → `adapter.commit(value, direction?)`; the host owns the selection move. - **Escape**: a ladder — first closes the suggestions popup if open (`closeSuggestions`), else closes the function-help popup if open (`closeFnHelp`, suppressing it for the rest of the edit session), else falls to `cancel`: while pointing, a first `cancel` press cancels the pending point ref; a second (or plain) Escape cancels the edit via `onCancel`. Each press eats one layer (`keymap.ts` `BINDINGS`, first match wins). - **Point mode (keyboard range selection)**: at a value position, arrows move a grid marquee one visible cell (`adapter.stepCell`) and insert/replace the ref at the caret; `Shift+Arrow` extends the range; `Ctrl/Cmd+Arrow` jumps to the data-block edge (`adapter.getDataEdge`); `Ctrl/Cmd+Shift+Arrow` extends the range to the data-block edge (anchor kept). Both map visual↔engine indices in the adapter, so a step lands on the visually-adjacent cell even when rows/cols are trimmed or reordered. Each step draws an ephemeral preview highlight (`adapter.highlightRange`, cleared on exit/cancel) and scrolls the cursor cell into view. `F2` / `Ctrl/Cmd+E` toggles pointing on/off — toggling arms point state without inserting (the first arrow inserts); at a non-value position it force-inserts a ref. State machine: `pointModeController.ts`. @@ -247,7 +248,7 @@ Role interfaces and their members: |---|---| | `GridPickSurface` | `outsideClickCommitMode` (readonly), `getCellAddressAt(x, y)`, `formatRef(ref)`, `setHighlights(items)`, `highlightRange(range, color)`, `clearEphemeralHighlight()`, `clearHighlights()`, `setOverlayClassName(className)`, `onCellPick(callback)`, `onRangePick(callback)`, `onOutsideCellClick(callback)`, `enterRefSelectionMode(resolveColor?, isPickEligible?)`, `exitRefSelectionMode()` | | `GridNavigation` | `getGridSize()`, `getDataEdge(from, direction)`, `stepCell(from, direction)`, `scrollCellIntoView(address)` | -| `GridEditLifecycle` | `getActiveCell()`, `onSelectionChange(callback)`, `getCellFormula(address)`, `beginEdit(address, seed?)`, `selectFirstCell()`, `setActiveCell(address)` and `setSelection(range)` (return `false` when the host cannot select the target, so the address box rejects it; `void` counts as success), `onInlineEditStart(callback)`, `onInlineEditClose(callback)`, `onSwitchToInline(callback)`, `commit(value, direction?)` (must write to the engine synchronously before returning — commit-error announcements read the engine and capture `valuesUpdated` inside the commit call; a deferred write degrades announcements to silence, never a crash), `cancel()`, `setInlineEditorVisible(visible)` | +| `GridEditLifecycle` | `getActiveCell()`, `onSelectionChange(callback)`, `getCellFormula(address)`, `beginEdit(address, seed?)`, `selectFirstCell()`, `setActiveCell(address)` and `setSelection(range)` (return `false` when the host cannot select the target, so the address box rejects it; `void` counts as success), `onInlineEditStart(callback)`, `onInlineEditClose(callback)`, `onSwitchToInline(callback)`, `commit(value, direction?)` (must write to the engine synchronously before returning — commit-error announcements read the engine and capture `valuesUpdated` inside the commit call; a deferred write degrades announcements to silence, never a crash), optional `commitFill(value)` (Ctrl/Cmd+Enter: write `value` into every selected cell, synchronously like `commit`; when absent Ctrl/Cmd+Enter inserts a newline, and Alt+Enter always does), `cancel()`, `setInlineEditorVisible(visible)` | | `GridHeaderPickSurface` (optional) | `onHeaderPick(callback)` — emits `HeaderPick` on row/column header click/drag; mixed into `IGridAdapter`/`EditorGridAdapter` as `Partial`, so adapters without headers implement nothing (no `NullAdapter` no-op) | | `IGridAdapter` | extends the three required role interfaces plus `Partial`; adds `destroy()` | | `GridGeometry` | adapter-side geometry contract consumed by `HighlightLayer`: `getOverlayHost()`, `getRangeRects(range)`, `getViewportClip?()`, `onViewportChange(callback)`, `getColHeaderEl?(col)`, `getRowHeaderEl?(row)` | diff --git a/hyperformula-ui/packages/core/src/adapter/types.ts b/hyperformula-ui/packages/core/src/adapter/types.ts index 79783b85e..3a9c51150 100644 --- a/hyperformula-ui/packages/core/src/adapter/types.ts +++ b/hyperformula-ui/packages/core/src/adapter/types.ts @@ -325,6 +325,13 @@ export interface GridEditLifecycle { */ commit(value: string, direction?: Direction): void; + /** + * Commits `value` to every selected cell (Ctrl/Cmd+Enter). Optional: when absent, + * Ctrl/Cmd+Enter inserts a newline. Like `commit`, it must write to the engine before + * returning. + */ + commitFill?(value: string): void; + /** Cancels the current edit without committing. */ cancel(): void; diff --git a/hyperformula-ui/packages/core/src/formulaBuilder.ts b/hyperformula-ui/packages/core/src/formulaBuilder.ts index 8c3c16b0f..e0a0b709b 100644 --- a/hyperformula-ui/packages/core/src/formulaBuilder.ts +++ b/hyperformula-ui/packages/core/src/formulaBuilder.ts @@ -96,6 +96,7 @@ export class FormulaBuilder { sheetId, direction, onCommit, + onCommitFill, onCancel, popups, icons, @@ -117,14 +118,23 @@ export class FormulaBuilder { const baseCommitHandler = onCommit ?? ((value: string, direction?: Direction) => this.adapter.commit(value, direction)); - const commitHandler = (value: string, direction?: Direction): void => { + const announcingCommit = (write: () => void): void => { const committedAddress = this.adapter.getActiveCell(); - const changes = this.formulaEditor.engine.captureValueChanges(() => - baseCommitHandler(value, direction), - ); + const changes = this.formulaEditor.engine.captureValueChanges(write); this.announceCommitErrors(committedAddress, changes); }; + const commitHandler = (value: string, direction?: Direction): void => + announcingCommit(() => baseCommitHandler(value, direction)); + const baseFillHandler = + onCommitFill ?? + (this.adapter.commitFill === undefined + ? undefined + : (value: string) => this.adapter.commitFill?.(value)); + const fillHandler = + baseFillHandler === undefined + ? undefined + : (value: string): void => announcingCommit(() => baseFillHandler(value)); const cancelHandler = onCancel ?? (() => this.adapter.cancel()); if (className !== undefined) { @@ -154,6 +164,7 @@ export class FormulaBuilder { ...(sheetId !== undefined && { sheetId }), ...(direction !== undefined && { direction }), onCommit: commitHandler, + ...(fillHandler !== undefined && { onCommitFill: fillHandler }), onCancel: cancelHandler, ...(suggestionsOptions !== undefined && { suggestionsOptions }), ...(fnHelpOptions !== undefined && { fnHelpOptions }), diff --git a/hyperformula-ui/packages/core/src/formulaEditor/keyboardController.ts b/hyperformula-ui/packages/core/src/formulaEditor/keyboardController.ts index 7d4dbcb26..407ab9574 100644 --- a/hyperformula-ui/packages/core/src/formulaEditor/keyboardController.ts +++ b/hyperformula-ui/packages/core/src/formulaEditor/keyboardController.ts @@ -164,6 +164,8 @@ export class KeyboardController { this.manager.register("commitLeft", () => this.commit("left")); + this.manager.register("commitFill", () => this.commitFill()); + this.manager.register("togglePointMode", () => this.toggleRangeMode()); this.manager.register("cycleRef", () => this.cycleRefAtCaret()); @@ -210,6 +212,18 @@ export class KeyboardController { return true; } + private commitFill(): boolean { + if (!this.ctx.opts.onCommitFill) { + return false; + } + + this.closeUnbalancedParens(); + this.highlights.suppress(); + this.ctx.opts.onCommitFill(this.getValue()); + + return true; + } + private skipParen(): boolean { const caret = this.ctx.input.getCaret(); diff --git a/hyperformula-ui/packages/core/src/formulaEditor/keymap.ts b/hyperformula-ui/packages/core/src/formulaEditor/keymap.ts index cbc012e7e..d4a66e834 100644 --- a/hyperformula-ui/packages/core/src/formulaEditor/keymap.ts +++ b/hyperformula-ui/packages/core/src/formulaEditor/keymap.ts @@ -46,7 +46,9 @@ export const BINDINGS: Binding[] = [ { combo: { key: "F4" }, when: ["isFormula"], command: "cycleRef" }, { combo: { key: "F2" }, when: ["isFormula"], command: "togglePointMode" }, { combo: { key: "e", mod: true }, when: ["isFormula"], command: "togglePointMode" }, + { combo: { key: "Enter", mod: true }, when: ["always"], command: "commitFill" }, { combo: { key: "Enter", mod: true }, when: ["always"], command: "insertNewline" }, + { combo: { key: "Enter", alt: true }, when: ["always"], command: "insertNewline" }, { combo: { key: "Enter", shift: true }, when: ["always"], command: "commitUp" }, { combo: { key: "Enter" }, when: ["always"], command: "commitDown" }, { combo: { key: "Tab", shift: true }, when: ["always"], command: "commitLeft" }, diff --git a/hyperformula-ui/packages/core/src/formulaEditor/types.ts b/hyperformula-ui/packages/core/src/formulaEditor/types.ts index aa40c927b..30f01ba06 100644 --- a/hyperformula-ui/packages/core/src/formulaEditor/types.ts +++ b/hyperformula-ui/packages/core/src/formulaEditor/types.ts @@ -56,6 +56,12 @@ export interface FormulaEditorOptions { /** Receives the committed value; the optional `direction` drives the selection move. */ onCommit?: (value: string, direction?: Direction) => void; + /** + * Receives the value committed with Ctrl/Cmd+Enter, to write into every selected cell. + * When omitted, Ctrl/Cmd+Enter inserts a newline, like Alt+Enter. + */ + onCommitFill?: (value: string) => void; + /** Fires when the edit is cancelled (Escape). */ onCancel?: () => void; diff --git a/hyperformula-ui/packages/core/tests/formula-builder.test.ts b/hyperformula-ui/packages/core/tests/formula-builder.test.ts index 02094f14f..de8cdbfa2 100644 --- a/hyperformula-ui/packages/core/tests/formula-builder.test.ts +++ b/hyperformula-ui/packages/core/tests/formula-builder.test.ts @@ -958,3 +958,58 @@ describe("FormulaBuilder teardown", () => { expect(leftovers()).toEqual({ portal: null, liveRegion: null, styles: null }); }); }); + +class FillAdapter extends NullAdapter { + readonly fills: string[] = []; + + commitFill(value: string): void { + this.fills.push(value); + } +} + +describe("FormulaBuilder fill commit", () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + function pressCtrlEnter(mount: HTMLElement): void { + press(mount, "Enter", { ctrlKey: true }); + } + + it("commits Ctrl+Enter to every selected cell through the adapter", () => { + const adapter = new FillAdapter(); + const builder = new FormulaBuilder({ rootEl: document.body, adapter }); + const mount = document.createElement("div"); + + builder.openEditor(mount, "=A1"); + pressCtrlEnter(mount); + + expect(adapter.fills).toEqual(["=A1"]); + builder.destroy(); + }); + + it("prefers the onCommitFill option over the adapter", () => { + const adapter = new FillAdapter(); + const onCommitFill = vi.fn(); + const builder = new FormulaBuilder({ rootEl: document.body, adapter, onCommitFill }); + const mount = document.createElement("div"); + + builder.openEditor(mount, "=A1"); + pressCtrlEnter(mount); + + expect(onCommitFill).toHaveBeenCalledWith("=A1"); + expect(adapter.fills).toEqual([]); + builder.destroy(); + }); + + it("inserts a newline on Ctrl+Enter when the adapter cannot fill", () => { + const builder = new FormulaBuilder({ rootEl: document.body, adapter: new NullAdapter() }); + const mount = document.createElement("div"); + + builder.openEditor(mount, "=A1"); + pressCtrlEnter(mount); + + expect(builder.editor.getValue()).toBe("=A1\n"); + builder.destroy(); + }); +}); diff --git a/hyperformula-ui/packages/core/tests/formulaEditor/keyboard-controller.test.ts b/hyperformula-ui/packages/core/tests/formulaEditor/keyboard-controller.test.ts index 7d346f47c..88155fbb9 100644 --- a/hyperformula-ui/packages/core/tests/formulaEditor/keyboard-controller.test.ts +++ b/hyperformula-ui/packages/core/tests/formulaEditor/keyboard-controller.test.ts @@ -14,6 +14,7 @@ import { interface KeyboardHarnessOptions { onCommit?: (value: string, direction?: Direction) => void; + onCommitFill?: (value: string) => void; onCancel?: () => void; hyperformula?: HyperFormulaLike; } @@ -228,6 +229,37 @@ describe("newline insertion", () => { expect(onCommit).not.toHaveBeenCalled(); destroy(); }); + + it("Alt+Enter inserts a newline at the caret", () => { + const onCommitFill = vi.fn(); + const { host, editor, destroy } = createKeyboardHarness({ onCommitFill }); + + typeInto(host, "=1"); + press(host, "Enter", { altKey: true }); + + expect(editor.getValue()).toBe("=1\n"); + expect(onCommitFill).not.toHaveBeenCalled(); + destroy(); + }); +}); + +describe("fill commit", () => { + it.each([ + ["Ctrl+Enter", { ctrlKey: true }], + ["Cmd+Enter", { metaKey: true }], + ])("%s commits the value to every selected cell when the host supports it", (_name, init) => { + const onCommit = vi.fn(); + const onCommitFill = vi.fn(); + const { host, editor, destroy } = createKeyboardHarness({ onCommit, onCommitFill }); + + typeInto(host, "=SUM(A1"); + press(host, "Enter", init); + + expect(onCommitFill).toHaveBeenCalledWith("=SUM(A1)"); + expect(onCommit).not.toHaveBeenCalled(); + expect(editor.getValue()).toBe("=SUM(A1)"); + destroy(); + }); }); describe("escape", () => { From 081336deea1f32abc0fb0d792c2e26ddc49e92aa Mon Sep 17 00:00:00 2001 From: adrianspdev Date: Thu, 22 Oct 2026 12:31:11 +0200 Subject: [PATCH 10/35] Ship a reference palette that clears 4.5:1 contrast Most default reference colors fell below 4.5:1 against the editor background in light or dark mode (orange and green were near 2:1 on white), so reference text and highlight borders failed WCAG 1.4.3 and 1.4.11 unless the host restated the whole palette. The colors that failed are darkened in light mode or lightened in dark mode, keeping their hue, and the README documents --hfe-ref-1 to --hfe-ref-8 as the way to override them. --- hyperformula-ui/dev-docs/SPEC.md | 2 +- hyperformula-ui/packages/core/README.md | 12 +++++ .../packages/core/src/styles/tokens.scss | 16 +++---- .../packages/core/src/theme/cssText.ts | 2 +- .../core/tests/theme/stylesheet.test.ts | 46 +++++++++++++++++++ 5 files changed, 68 insertions(+), 10 deletions(-) diff --git a/hyperformula-ui/dev-docs/SPEC.md b/hyperformula-ui/dev-docs/SPEC.md index 3399d90a4..fb5c8e4e1 100644 --- a/hyperformula-ui/dev-docs/SPEC.md +++ b/hyperformula-ui/dev-docs/SPEC.md @@ -293,7 +293,7 @@ No adapter ships in this repository; see §1. An adapter implements `IGridAdapte - BEM blocks: `hfe-editor`, `hfe-overlay`, `hfe-ref-mark`, `hfe-popup`, `hfe-popup-portal`, `hfe-suggestions`, `hfe-fn-help`, `hfe-highlight`, `hfe-token`, `hfe-error`, `hfe-cell--error`, `hfe-formula-bar` (`__cell`, `__cell--address`, `__cell--formula`, `__address-input`, `__fx-icon`, `__body`, `__idle`, `__overlay`). Decoration modifiers: `hfe-token--paren-match`, `hfe-token--paren-err`, `hfe-token--dim`, `hfe-token--arg-active`; error/status modifiers `hfe-token--{comment-err,fn-unknown,ref-invalid}`; per-token-type classes via `TOKEN_CN`. Full element/modifier inventory: `theme/classNames.ts`. - Every class name string lives in `theme/classNames.ts` (`CN`, `TOKEN_CN`) — no string literals scattered in components. - `color-scheme: light dark` is set in `tokens.scss` on the package roots only (`hfe-editor`, `hfe-formula-bar`, `hfe-popup-portal`, `hfe-popup--hosted`), inside `:where()` so it has no specificity: a host theme class on a root (the `className` option lands on every root) overrides it with a single class selector, and the elements inside a root inherit it. Never set it through a class-attribute substring selector such as `[class*="hfe-"]`: that overrides the inherited scheme on every package element, and the browser tests it against every element of the page. All themed colors via `light-dark(lightVal, darkVal)`, which follows the operating system unless the host sets the scheme. -- Variable namespace `--hfe-*`. Ref colors from the fixed `REF_PALETTE` (`theme/refPalette.ts`). Formula-bar tokens: the color/border ones (`--hfe-formula-bar-{background,foreground,border,border-width,border-radius}`) alias global tokens — no bar-specific colors; the sizing ones (`--hfe-formula-bar-{padding-vertical,padding-horizontal,gap-address,gap-formula,address-width,scrollbar-width}`, `--hfe-z-index-formula-bar`) carry literal defaults. The vertical padding sits on the bar's overlay and textarea, not on the formula cell, and the cell has no end padding. +- Variable namespace `--hfe-*`. Ref colors from the fixed `REF_PALETTE` (`theme/refPalette.ts`), which points at `--hfe-ref-1..8`. Those eight properties are a public override surface, documented in the package README; every default clears 4.5:1 against `--hfe-color-background` in both schemes (WCAG 1.4.3 for reference text, which also covers 1.4.11's 3:1 for highlight borders), and `tests/theme/stylesheet.test.ts` checks it. Formula-bar tokens: the color/border ones (`--hfe-formula-bar-{background,foreground,border,border-width,border-radius}`) alias global tokens — no bar-specific colors; the sizing ones (`--hfe-formula-bar-{padding-vertical,padding-horizontal,gap-address,gap-formula,address-width,scrollbar-width}`, `--hfe-z-index-formula-bar`) carry literal defaults. The vertical padding sits on the bar's overlay and textarea, not on the formula cell, and the cell has no end padding. - Fonts: `--hfe-font-family` (default `inherit`, the host's font) for UI text, `--hfe-formula-font-family` (monospace) for formula text, and `--hfe-error-font-family` (default `var(--hfe-font-family)`) read by both `hfe-cell--error` and `hfe-error__title`, so an error value in a cell and the error popup title always render in the same font. - Scrollbars: `--hfe-scrollbar-width` (default `thin`) sets `scrollbar-width` on a height-capped editor and the two popup lists; the bar-hosted editor takes `--hfe-formula-bar-scrollbar-width` (default `auto`) instead, because a thin bar in a one-line scroll port is hard to grab. - Editor sizing is host-configurable via `--hfe-editor-min-lines` / `--hfe-editor-max-lines` (line-unit height clamps, read by `EditorSizeController`), `--hfe-editor-resize` (`none`/`vertical`/`horizontal`/`both` for the native resize grip), `--hfe-editor-max-width` (px width cap, applied with or without a clipping ancestor), and `--hfe-editor-min-width` (width floor for the resize grip; defaults to `100%` of the mount). Set them on the editor root or an ancestor. diff --git a/hyperformula-ui/packages/core/README.md b/hyperformula-ui/packages/core/README.md index 878c99145..2ecfd14b9 100644 --- a/hyperformula-ui/packages/core/README.md +++ b/hyperformula-ui/packages/core/README.md @@ -30,6 +30,18 @@ builder.openEditor(host, "=SUM(A1:B5)"); Styles are injected automatically on first use; there is no CSS file to import. +## Reference colors + +Each reference in a formula takes one of eight colors, in the editor text and in the grid highlight. The colors are the custom properties `--hfe-ref-1` to `--hfe-ref-8`, and each default clears a 4.5:1 contrast ratio on the editor's light and dark backgrounds. To replace them, set the properties on the class you pass as `className`, which the builder adds to the editor, the formula bar, the popups and the adapter's highlight overlays: + +```css +.my-theme { + --hfe-ref-1: #0057b8; +} +``` + +The defaults are declared on `:root` in a stylesheet injected after yours, so an override declared on `:root` itself needs a more specific selector, such as `html:root`. + The public API is listed in [`DOCS.md`](DOCS.md). ## License diff --git a/hyperformula-ui/packages/core/src/styles/tokens.scss b/hyperformula-ui/packages/core/src/styles/tokens.scss index 806a18cff..a3d1c9898 100644 --- a/hyperformula-ui/packages/core/src/styles/tokens.scss +++ b/hyperformula-ui/packages/core/src/styles/tokens.scss @@ -33,14 +33,14 @@ --hfe-token-comment: light-dark(#8a8f99, #6c7280); /* ── Reference rainbow — global primitives ───────────────────────────────── */ - --hfe-ref-1: light-dark(#007bff, #0A84FF); - --hfe-ref-2: light-dark(#ff9500, #FF9D0A); - --hfe-ref-3: light-dark(#af52de, #BF5AF2); - --hfe-ref-4: light-dark(#30d158, #34c759); - --hfe-ref-5: light-dark(#ff2d54, #ff375f); - --hfe-ref-6: light-dark(#30b0c7, #40c8e0); - --hfe-ref-7: light-dark(#5856d6, #5E5CE6); - --hfe-ref-8: light-dark(#a2855e, #AC8E68); + --hfe-ref-1: light-dark(#0970e6, #0a84ff); + --hfe-ref-2: light-dark(#ac6304, #ff9d0a); + --hfe-ref-3: light-dark(#a548d4, #bf5af2); + --hfe-ref-4: light-dark(#0c8732, #34c759); + --hfe-ref-5: light-dark(#e61045, #ff375f); + --hfe-ref-6: light-dark(#048092, #40c8e0); + --hfe-ref-7: light-dark(#5856d6, #767afd); + --hfe-ref-8: light-dark(#8c704a, #ac8e68); /* ── Typography & layout ─────────────────────────────────── */ --hfe-formula-font-family: diff --git a/hyperformula-ui/packages/core/src/theme/cssText.ts b/hyperformula-ui/packages/core/src/theme/cssText.ts index b42159588..d7f7ef3e2 100644 --- a/hyperformula-ui/packages/core/src/theme/cssText.ts +++ b/hyperformula-ui/packages/core/src/theme/cssText.ts @@ -4,4 +4,4 @@ * Regenerate: npm run build:styles --workspace=hyperformula-ui/packages/core */ export const CSS_TEXT = - ':root{--hfe-color-background: light-dark(#ffffff, #1e1f22);--hfe-color-foreground: light-dark(#1c1d20, #e6e6e6);--hfe-color-foreground-secondary: light-dark(#4e4f52, #aaaaaa);--hfe-color-border: light-dark(#eceef2, #28292c);--hfe-color-highlight: light-dark(#f7f7f9, #000000);--hfe-color-caret: light-dark(#1c1d20, #e6e6e6);--hfe-color-accent: light-dark(#1a42e8, #8aa4ff);--hfe-color-focus-ring: light-dark(#1a42e8, #8aa4ff);--hfe-color-error: light-dark(#fa4d56, #fa4d56);--hfe-color-shadow: color-mix(in srgb, #000000 var(--hfe-shadow-opacity), transparent);--hfe-scrollbar-color: color-mix(in srgb, var(--hfe-color-foreground) 20%, transparent);--hfe-scrollbar-color-hover: color-mix(in srgb, var(--hfe-color-foreground) 50%, transparent);--hfe-scrollbar-width: thin;--hfe-token-fn: var(--hfe-color-foreground);--hfe-token-fn-unknown: light-dark(#ea580c, #fdba74);--hfe-token-num: var(--hfe-color-foreground);--hfe-token-str: var(--hfe-color-foreground);--hfe-token-bool: var(--hfe-color-foreground);--hfe-token-op: var(--hfe-color-foreground);--hfe-token-paren: var(--hfe-color-foreground);--hfe-token-paren-err: var(--hfe-color-error);--hfe-token-paren-match: var(--hfe-color-accent);--hfe-token-bracket: var(--hfe-color-foreground);--hfe-token-sep: var(--hfe-color-foreground);--hfe-token-sheet: light-dark(#64748b, #7c8694);--hfe-token-ident: var(--hfe-color-foreground);--hfe-token-err: var(--hfe-color-error);--hfe-token-ref-invalid: var(--hfe-color-error);--hfe-token-comment: light-dark(#8a8f99, #6c7280);--hfe-ref-1: light-dark(#007bff, #0A84FF);--hfe-ref-2: light-dark(#ff9500, #FF9D0A);--hfe-ref-3: light-dark(#af52de, #BF5AF2);--hfe-ref-4: light-dark(#30d158, #34c759);--hfe-ref-5: light-dark(#ff2d54, #ff375f);--hfe-ref-6: light-dark(#30b0c7, #40c8e0);--hfe-ref-7: light-dark(#5856d6, #5E5CE6);--hfe-ref-8: light-dark(#a2855e, #AC8E68);--hfe-formula-font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;--hfe-font-family: inherit;--hfe-error-font-family: var(--hfe-font-family);--hfe-font-size: 14px;--hfe-line-height: 20px;--hfe-selection-opacity: 0.08;--hfe-dim-opacity: 0.6;--hfe-gap: 4px;--hfe-shadow-x: 0;--hfe-shadow-y: 8px;--hfe-shadow-blur: 16px;--hfe-shadow-opacity: 8%;--hfe-editor-color-border: var(--hfe-color-accent);--hfe-padding-vertical: 8px;--hfe-padding-horizontal: 12px;--hfe-border-radius: 0;--hfe-border-width: 1px;--hfe-border-width-focus: 2px;--hfe-reference-border-width: 1px;--hfe-menu-item-padding-horizontal: 12px;--hfe-menu-item-padding-vertical: 8px;--hfe-menu-item-border-radius: 8px;--hfe-menu-padding-horizontal: 4px;--hfe-menu-padding-vertical: 4px;--hfe-menu-border-radius: 12px;--hfe-menu-border-width: 0;--hfe-menu-border-color: var(--hfe-color-border);--hfe-menu-background: var(--hfe-color-background);--hfe-menu-max-height: 280px;--hfe-menu-separator-color: var(--hfe-color-border);--hfe-menu-kbd-row-border-width: 1px;--hfe-menu-kbd-row-border-color: var(--hfe-color-border);--hfe-menu-kbd-row-chip-color: light-dark(#f7f7f9, #000000);--hfe-menu-chip-border-radius: 8px;--hfe-menu-chip-background: var(--hfe-color-accent);--hfe-menu-chip-foreground: var(--hfe-color-background);--hfe-menu-shadow: var(--hfe-shadow-x) var(--hfe-shadow-y) var(--hfe-shadow-blur) var(--hfe-color-shadow);--hfe-menu-close-bg: var(--hfe-menu-background);--hfe-menu-close-hover-bg: var(--hfe-color-highlight);--hfe-error-padding-horizontal: 16px;--hfe-error-padding-vertical: 8px;--hfe-error-border-radius: 0;--hfe-formula-bar-background: var(--hfe-color-background);--hfe-formula-bar-foreground: var(--hfe-color-foreground);--hfe-formula-bar-border: var(--hfe-color-border);--hfe-formula-bar-border-width: var(--hfe-border-width);--hfe-formula-bar-border-radius: var(--hfe-border-radius);--hfe-formula-bar-padding-vertical: 4px;--hfe-formula-bar-padding-horizontal: 8px;--hfe-formula-bar-gap-address: 4px;--hfe-formula-bar-gap-formula: 8px;--hfe-formula-bar-address-width: 120px;--hfe-formula-bar-scrollbar-width: auto;--hfe-formula-bar-input-background-hover: transparent;--hfe-formula-bar-input-focus-ring: var(--hfe-color-focus-ring);--hfe-z-index-popup: 210;--hfe-z-index-formula-bar: 200}:where(.hfe-editor,.hfe-formula-bar,.hfe-popup-portal,.hfe-popup--hosted){color-scheme:light dark}.hfe-popup__close{position:absolute;inset-block-start:-10px;inset-inline-end:-10px;width:24px;height:24px;padding:0;border-radius:12px;background:var(--hfe-menu-close-bg);border:var(--hfe-menu-border-width) solid var(--hfe-menu-border-color);cursor:pointer;display:flex;align-items:center;justify-content:center}.hfe-popup__close:hover{background:var(--hfe-menu-close-hover-bg)}.hfe-popup__close:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-color-focus-ring);outline-offset:2px}.hfe-popup.hfe-popup--hosted{position:static;width:100%;max-height:none}.hfe-editor{position:relative;display:inline-block;width:max-content;background:var(--hfe-color-background);color:var(--hfe-color-foreground);border:none;box-shadow:inset 0 0 0 var(--hfe-border-width) var(--hfe-editor-color-border);border-radius:var(--hfe-border-radius);font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);font-variant-ligatures:none;line-height:var(--hfe-line-height);min-width:var(--hfe-editor-min-width, 100%);min-height:100%;box-sizing:border-box !important;overflow:hidden;resize:var(--hfe-editor-resize, none)}.hfe-editor--focused{box-shadow:inset 0 0 0 var(--hfe-border-width-focus) var(--hfe-color-focus-ring)}.hfe-editor--height-capped{overflow:hidden}.hfe-editor--height-capped .hfe-overlay,.hfe-editor--height-capped .hfe-editor__input{overflow-x:hidden;overflow-y:scroll;scrollbar-width:var(--hfe-scrollbar-width);scrollbar-color:var(--hfe-scrollbar-color) rgba(0,0,0,0)}.hfe-editor--height-capped:hover .hfe-overlay,.hfe-editor--height-capped:hover .hfe-editor__input{scrollbar-color:var(--hfe-scrollbar-color-hover) rgba(0,0,0,0)}.hfe-editor__layers{display:inline-block;vertical-align:top;min-width:100%;max-width:100%;min-height:inherit;box-sizing:border-box !important}.hfe-editor__input{position:absolute;inset:0;display:block;width:100%;height:100%;background:rgba(0,0,0,0);color:rgba(0,0,0,0);caret-color:var(--hfe-color-caret);resize:none;outline:none;overflow:hidden;z-index:2}.hfe-editor__input::selection{background:color-mix(in srgb, var(--hfe-color-accent) 15%, transparent);color:rgba(0,0,0,0)}.hfe-editor__input,.hfe-overlay{font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);font-weight:400;font-variant-ligatures:none;letter-spacing:0;line-height:var(--hfe-line-height);padding:var(--hfe-padding-vertical) var(--hfe-padding-horizontal);box-sizing:border-box;white-space:pre-wrap;word-break:break-all;tab-size:4;margin:0;border:0}.hfe-editor[dir=rtl] .hfe-editor__input,.hfe-editor[dir=rtl] .hfe-overlay{text-align:right}.hfe-editor--formula .hfe-editor__input,.hfe-editor--formula .hfe-overlay{font-family:var(--hfe-formula-font-family)}.hfe-formula-bar{display:flex;background:var(--hfe-formula-bar-background);border:var(--hfe-formula-bar-border-width) solid var(--hfe-formula-bar-border);color:var(--hfe-formula-bar-foreground);border-radius:var(--hfe-formula-bar-border-radius)}.hfe-formula-bar__cell{display:flex;align-items:center;box-sizing:border-box;border-inline-end:var(--hfe-formula-bar-border-width) solid var(--hfe-formula-bar-border)}.hfe-formula-bar__cell--address{flex:0 0 var(--hfe-formula-bar-address-width);gap:var(--hfe-formula-bar-gap-address)}.hfe-formula-bar__cell--formula{flex:1;min-width:0;gap:var(--hfe-formula-bar-gap-formula);padding-inline-start:var(--hfe-formula-bar-padding-horizontal);border-inline-end:none}.hfe-formula-bar__address-input{width:100%;min-width:0;box-sizing:border-box;border:none;outline:none;background:rgba(0,0,0,0);color:inherit;font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);padding:var(--hfe-formula-bar-padding-vertical) var(--hfe-formula-bar-padding-horizontal);border-start-start-radius:var(--hfe-formula-bar-border-radius)}.hfe-formula-bar__address-input:hover{background:var(--hfe-formula-bar-input-background-hover)}.hfe-formula-bar__address-input:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-formula-bar-input-focus-ring);outline-offset:calc(-1*var(--hfe-border-width-focus))}.hfe-formula-bar__fx-icon{flex:none;color:var(--hfe-formula-bar-foreground)}.hfe-formula-bar__body{position:relative;flex:1;min-width:0}.hfe-formula-bar__idle{cursor:text;height:calc(var(--hfe-line-height) + 2*var(--hfe-formula-bar-padding-vertical));font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);white-space:pre-wrap;word-break:break-all;overflow-x:hidden;overflow-y:auto}.hfe-formula-bar__overlay{position:absolute;top:0;inset-inline:0;z-index:var(--hfe-z-index-formula-bar)}.hfe-formula-bar .hfe-editor{display:block;max-width:100% !important;background:var(--hfe-formula-bar-background);--hfe-scrollbar-width: var(--hfe-formula-bar-scrollbar-width)}.hfe-formula-bar .hfe-editor,.hfe-formula-bar .hfe-overlay,.hfe-formula-bar .hfe-editor__input{border:none;box-shadow:none;border-radius:0;padding:0;min-height:unset}.hfe-formula-bar .hfe-overlay,.hfe-formula-bar .hfe-editor__input{padding:var(--hfe-formula-bar-padding-vertical) 0}.hfe-formula-bar__overlay .hfe-editor{border-start-end-radius:var(--hfe-formula-bar-border-radius)}.hfe-formula-bar[dir=rtl] .hfe-formula-bar__idle .hfe-overlay{text-align:right}.hfe-overlay{position:static;display:block;pointer-events:none;color:var(--hfe-color-foreground);z-index:1}.hfe-token--fn{color:var(--hfe-token-fn)}.hfe-token--fn-unknown{color:var(--hfe-token-fn-unknown)}.hfe-token--num{color:var(--hfe-token-num)}.hfe-token--str{color:var(--hfe-token-str)}.hfe-token--op{color:var(--hfe-token-op)}.hfe-token--paren{color:var(--hfe-token-paren)}.hfe-token--paren-err{color:var(--hfe-token-paren-err)}.hfe-token--bracket{color:var(--hfe-token-bracket)}.hfe-token--sep{color:var(--hfe-token-sep)}.hfe-token--sheet{color:var(--hfe-token-sheet)}.hfe-token--ident{color:var(--hfe-token-ident)}.hfe-token--err{color:var(--hfe-token-err);text-decoration:underline wavy var(--hfe-token-err)}.hfe-token--errlit{color:var(--hfe-token-err);font-weight:600}.hfe-token--ref-invalid{color:var(--hfe-token-ref-invalid);text-decoration:line-through}.hfe-token--comment{color:var(--hfe-token-comment);font-style:italic}.hfe-token--comment-err{text-decoration:underline wavy var(--hfe-token-err);text-underline-offset:2px}.hfe-token--paren-match{background:color-mix(in srgb, var(--hfe-token-paren-match) calc(var(--hfe-selection-opacity) * 100%), transparent);border-radius:3px}.hfe-token--dim{opacity:var(--hfe-dim-opacity)}.hfe-token--arg-active{text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px}.hfe-ref-mark{display:inline;padding:0;margin:0;border:0;background:rgba(0,0,0,0);color:var(--hfe-ref-1);pointer-events:none;font:inherit;white-space:inherit}.hfe-ref-mark[data-ref-index="0"]{color:var(--hfe-ref-1)}.hfe-ref-mark[data-ref-index="1"]{color:var(--hfe-ref-2)}.hfe-ref-mark[data-ref-index="2"]{color:var(--hfe-ref-3)}.hfe-ref-mark[data-ref-index="3"]{color:var(--hfe-ref-4)}.hfe-ref-mark[data-ref-index="4"]{color:var(--hfe-ref-5)}.hfe-ref-mark[data-ref-index="5"]{color:var(--hfe-ref-6)}.hfe-ref-mark[data-ref-index="6"]{color:var(--hfe-ref-7)}.hfe-ref-mark[data-ref-index="7"]{color:var(--hfe-ref-8)}.hfe-ref-mark--named{text-decoration:underline dotted;text-underline-offset:3px}.hfe-suggestions{position:fixed;z-index:var(--hfe-z-index-popup);width:264px;max-height:var(--hfe-menu-max-height);display:flex;flex-direction:column;background:var(--hfe-menu-background);color:var(--hfe-color-foreground);border:var(--hfe-menu-border-width) solid var(--hfe-menu-border-color);border-radius:var(--hfe-menu-border-radius);box-shadow:var(--hfe-menu-shadow);font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);gap:var(--hfe-gap)}.hfe-suggestions[hidden]{display:none}.hfe-suggestions__list{overflow-y:auto;padding:var(--hfe-menu-padding-vertical) var(--hfe-menu-padding-horizontal) 0;scrollbar-width:var(--hfe-scrollbar-width);scrollbar-color:var(--hfe-scrollbar-color) rgba(0,0,0,0)}.hfe-suggestions__list:hover{scrollbar-color:var(--hfe-scrollbar-color-hover) rgba(0,0,0,0)}.hfe-suggestions__list:only-of-type{padding-bottom:var(--hfe-menu-padding-vertical)}.hfe-suggestions__item{padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);border-radius:var(--hfe-menu-item-border-radius);cursor:pointer;white-space:nowrap}.hfe-suggestions__item--active{background:var(--hfe-color-highlight)}.hfe-suggestions__item-label{display:block;font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);font-weight:400;line-height:var(--hfe-line-height)}.hfe-suggestions__item-description{display:none;font-size:12px;line-height:16px;color:var(--hfe-color-foreground-secondary);font-family:var(--hfe-font-family);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.hfe-suggestions__item--active .hfe-suggestions__item-description{display:block}.hfe-suggestions__item--named .hfe-suggestions__item-label{display:flex;align-items:center;gap:var(--hfe-gap);color:var(--hfe-token-sheet)}.hfe-suggestions__match{color:var(--hfe-color-accent);font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);font-weight:400;line-height:var(--hfe-line-height)}.hfe-suggestions__kbd-row{flex-shrink:0;display:flex;align-items:center;gap:var(--hfe-gap);padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);border:var(--hfe-menu-kbd-row-border-width) solid var(--hfe-menu-kbd-row-border-color);border-radius:var(--hfe-menu-item-border-radius);font-family:var(--hfe-font-family);font-size:12px;line-height:16px;color:var(--hfe-color-foreground);margin:0 var(--hfe-menu-padding-horizontal) var(--hfe-menu-padding-vertical)}.hfe-suggestions__kbd-chip{background:var(--hfe-menu-kbd-row-chip-color);border-radius:4px;padding:2px 4px}.hfe-fn-help{position:fixed;z-index:var(--hfe-z-index-popup);width:320px;border-radius:var(--hfe-menu-border-radius);background:var(--hfe-menu-background);color:var(--hfe-color-foreground);border:var(--hfe-menu-border-width) solid var(--hfe-menu-border-color);box-shadow:var(--hfe-menu-shadow);font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);padding:var(--hfe-menu-padding-vertical) var(--hfe-menu-padding-horizontal);max-height:var(--hfe-menu-max-height);display:flex;flex-direction:column}.hfe-fn-help[hidden]{display:none}.hfe-fn-help__body{overflow-y:auto;scrollbar-width:var(--hfe-scrollbar-width);scrollbar-color:var(--hfe-scrollbar-color) rgba(0,0,0,0)}.hfe-fn-help__body:hover{scrollbar-color:var(--hfe-scrollbar-color-hover) rgba(0,0,0,0)}.hfe-fn-help__body:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-color-focus-ring);outline-offset:calc(-1*var(--hfe-border-width-focus))}.hfe-fn-help__signature{display:flex;flex-wrap:wrap;align-items:center;padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);background:var(--hfe-color-highlight);border-radius:var(--hfe-menu-item-border-radius);font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height)}.hfe-fn-help__name{color:var(--hfe-color-foreground)}.hfe-fn-help__arg{color:var(--hfe-color-foreground)}.hfe-fn-help__arg--active{background:var(--hfe-menu-chip-background);color:var(--hfe-menu-chip-foreground);border-radius:var(--hfe-menu-chip-border-radius);padding:0 4px}.hfe-fn-help__section{display:flex;flex-direction:column;gap:var(--hfe-gap);padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);border-radius:var(--hfe-menu-item-border-radius)}.hfe-fn-help__section-label{font-size:12px;line-height:16px;color:var(--hfe-color-foreground-secondary);font-family:var(--hfe-font-family)}.hfe-fn-help__example{font-family:var(--hfe-formula-font-family);color:var(--hfe-color-accent);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height)}.hfe-fn-help__about{font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);color:var(--hfe-color-foreground)}.hfe-fn-help__param-name{font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);color:var(--hfe-color-foreground)}.hfe-fn-help__param-name--active{color:var(--hfe-color-accent)}.hfe-fn-help__param-desc{font-family:var(--hfe-font-family);font-size:12px;line-height:16px;color:var(--hfe-color-foreground)}.hfe-fn-help__separator{height:1px;border:0;background:var(--hfe-menu-separator-color);margin:var(--hfe-gap) var(--hfe-menu-item-padding-horizontal)}.hfe-fn-help__learn-more{display:flex;align-items:center;gap:var(--hfe-gap);padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal) calc(var(--hfe-menu-item-padding-vertical) + var(--hfe-gap));border-radius:var(--hfe-menu-item-border-radius);color:var(--hfe-color-foreground);font-size:var(--hfe-font-size);font-family:var(--hfe-font-family);text-decoration:none;cursor:pointer}.hfe-fn-help__learn-more:hover{text-decoration:underline}.hfe-fn-help__learn-more:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-color-focus-ring);outline-offset:2px}.hfe-cell--error{position:relative;color:var(--hfe-token-err) !important;font-family:var(--hfe-error-font-family);font-size:var(--hfe-font-size);font-weight:400;line-height:var(--hfe-line-height)}.hfe-cell--error::after{content:"";position:absolute;inset-block-end:0;inset-inline-end:0;width:0;height:0;border-style:solid;border-width:0;border-block-end-width:6px;border-inline-start-width:6px;border-color:rgba(0,0,0,0);border-block-end-color:var(--hfe-color-error);pointer-events:none}.hfe-error{position:fixed;z-index:1000;max-width:280px;padding:var(--hfe-error-padding-vertical) var(--hfe-error-padding-horizontal);background:var(--hfe-color-background);border-inline-start:var(--hfe-border-width) solid var(--hfe-color-error);border-radius:var(--hfe-error-border-radius);box-shadow:var(--hfe-menu-shadow)}.hfe-error[hidden]{display:none}.hfe-error__title{font-size:var(--hfe-font-size);color:var(--hfe-color-error);font-family:var(--hfe-error-font-family)}.hfe-error__message{font-size:12px;line-height:16px;color:var(--hfe-color-foreground-secondary);font-family:var(--hfe-font-family);margin-top:var(--hfe-gap)}.hfe-highlight{position:absolute;inset:0;pointer-events:none}.hfe-highlight__item{position:absolute;pointer-events:none;border-width:var(--hfe-reference-border-width);border-radius:var(--hfe-border-radius);border-style:dashed;box-sizing:border-box !important;z-index:5;display:none}.hfe-live-region{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}'; + ':root{--hfe-color-background: light-dark(#ffffff, #1e1f22);--hfe-color-foreground: light-dark(#1c1d20, #e6e6e6);--hfe-color-foreground-secondary: light-dark(#4e4f52, #aaaaaa);--hfe-color-border: light-dark(#eceef2, #28292c);--hfe-color-highlight: light-dark(#f7f7f9, #000000);--hfe-color-caret: light-dark(#1c1d20, #e6e6e6);--hfe-color-accent: light-dark(#1a42e8, #8aa4ff);--hfe-color-focus-ring: light-dark(#1a42e8, #8aa4ff);--hfe-color-error: light-dark(#fa4d56, #fa4d56);--hfe-color-shadow: color-mix(in srgb, #000000 var(--hfe-shadow-opacity), transparent);--hfe-scrollbar-color: color-mix(in srgb, var(--hfe-color-foreground) 20%, transparent);--hfe-scrollbar-color-hover: color-mix(in srgb, var(--hfe-color-foreground) 50%, transparent);--hfe-scrollbar-width: thin;--hfe-token-fn: var(--hfe-color-foreground);--hfe-token-fn-unknown: light-dark(#ea580c, #fdba74);--hfe-token-num: var(--hfe-color-foreground);--hfe-token-str: var(--hfe-color-foreground);--hfe-token-bool: var(--hfe-color-foreground);--hfe-token-op: var(--hfe-color-foreground);--hfe-token-paren: var(--hfe-color-foreground);--hfe-token-paren-err: var(--hfe-color-error);--hfe-token-paren-match: var(--hfe-color-accent);--hfe-token-bracket: var(--hfe-color-foreground);--hfe-token-sep: var(--hfe-color-foreground);--hfe-token-sheet: light-dark(#64748b, #7c8694);--hfe-token-ident: var(--hfe-color-foreground);--hfe-token-err: var(--hfe-color-error);--hfe-token-ref-invalid: var(--hfe-color-error);--hfe-token-comment: light-dark(#8a8f99, #6c7280);--hfe-ref-1: light-dark(#0970e6, #0a84ff);--hfe-ref-2: light-dark(#ac6304, #ff9d0a);--hfe-ref-3: light-dark(#a548d4, #bf5af2);--hfe-ref-4: light-dark(#0c8732, #34c759);--hfe-ref-5: light-dark(#e61045, #ff375f);--hfe-ref-6: light-dark(#048092, #40c8e0);--hfe-ref-7: light-dark(#5856d6, #767afd);--hfe-ref-8: light-dark(#8c704a, #ac8e68);--hfe-formula-font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;--hfe-font-family: inherit;--hfe-error-font-family: var(--hfe-font-family);--hfe-font-size: 14px;--hfe-line-height: 20px;--hfe-selection-opacity: 0.08;--hfe-dim-opacity: 0.6;--hfe-gap: 4px;--hfe-shadow-x: 0;--hfe-shadow-y: 8px;--hfe-shadow-blur: 16px;--hfe-shadow-opacity: 8%;--hfe-editor-color-border: var(--hfe-color-accent);--hfe-padding-vertical: 8px;--hfe-padding-horizontal: 12px;--hfe-border-radius: 0;--hfe-border-width: 1px;--hfe-border-width-focus: 2px;--hfe-reference-border-width: 1px;--hfe-menu-item-padding-horizontal: 12px;--hfe-menu-item-padding-vertical: 8px;--hfe-menu-item-border-radius: 8px;--hfe-menu-padding-horizontal: 4px;--hfe-menu-padding-vertical: 4px;--hfe-menu-border-radius: 12px;--hfe-menu-border-width: 0;--hfe-menu-border-color: var(--hfe-color-border);--hfe-menu-background: var(--hfe-color-background);--hfe-menu-max-height: 280px;--hfe-menu-separator-color: var(--hfe-color-border);--hfe-menu-kbd-row-border-width: 1px;--hfe-menu-kbd-row-border-color: var(--hfe-color-border);--hfe-menu-kbd-row-chip-color: light-dark(#f7f7f9, #000000);--hfe-menu-chip-border-radius: 8px;--hfe-menu-chip-background: var(--hfe-color-accent);--hfe-menu-chip-foreground: var(--hfe-color-background);--hfe-menu-shadow: var(--hfe-shadow-x) var(--hfe-shadow-y) var(--hfe-shadow-blur) var(--hfe-color-shadow);--hfe-menu-close-bg: var(--hfe-menu-background);--hfe-menu-close-hover-bg: var(--hfe-color-highlight);--hfe-error-padding-horizontal: 16px;--hfe-error-padding-vertical: 8px;--hfe-error-border-radius: 0;--hfe-formula-bar-background: var(--hfe-color-background);--hfe-formula-bar-foreground: var(--hfe-color-foreground);--hfe-formula-bar-border: var(--hfe-color-border);--hfe-formula-bar-border-width: var(--hfe-border-width);--hfe-formula-bar-border-radius: var(--hfe-border-radius);--hfe-formula-bar-padding-vertical: 4px;--hfe-formula-bar-padding-horizontal: 8px;--hfe-formula-bar-gap-address: 4px;--hfe-formula-bar-gap-formula: 8px;--hfe-formula-bar-address-width: 120px;--hfe-formula-bar-scrollbar-width: auto;--hfe-formula-bar-input-background-hover: transparent;--hfe-formula-bar-input-focus-ring: var(--hfe-color-focus-ring);--hfe-z-index-popup: 210;--hfe-z-index-formula-bar: 200}:where(.hfe-editor,.hfe-formula-bar,.hfe-popup-portal,.hfe-popup--hosted){color-scheme:light dark}.hfe-popup__close{position:absolute;inset-block-start:-10px;inset-inline-end:-10px;width:24px;height:24px;padding:0;border-radius:12px;background:var(--hfe-menu-close-bg);border:var(--hfe-menu-border-width) solid var(--hfe-menu-border-color);cursor:pointer;display:flex;align-items:center;justify-content:center}.hfe-popup__close:hover{background:var(--hfe-menu-close-hover-bg)}.hfe-popup__close:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-color-focus-ring);outline-offset:2px}.hfe-popup.hfe-popup--hosted{position:static;width:100%;max-height:none}.hfe-editor{position:relative;display:inline-block;width:max-content;background:var(--hfe-color-background);color:var(--hfe-color-foreground);border:none;box-shadow:inset 0 0 0 var(--hfe-border-width) var(--hfe-editor-color-border);border-radius:var(--hfe-border-radius);font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);font-variant-ligatures:none;line-height:var(--hfe-line-height);min-width:var(--hfe-editor-min-width, 100%);min-height:100%;box-sizing:border-box !important;overflow:hidden;resize:var(--hfe-editor-resize, none)}.hfe-editor--focused{box-shadow:inset 0 0 0 var(--hfe-border-width-focus) var(--hfe-color-focus-ring)}.hfe-editor--height-capped{overflow:hidden}.hfe-editor--height-capped .hfe-overlay,.hfe-editor--height-capped .hfe-editor__input{overflow-x:hidden;overflow-y:scroll;scrollbar-width:var(--hfe-scrollbar-width);scrollbar-color:var(--hfe-scrollbar-color) rgba(0,0,0,0)}.hfe-editor--height-capped:hover .hfe-overlay,.hfe-editor--height-capped:hover .hfe-editor__input{scrollbar-color:var(--hfe-scrollbar-color-hover) rgba(0,0,0,0)}.hfe-editor__layers{display:inline-block;vertical-align:top;min-width:100%;max-width:100%;min-height:inherit;box-sizing:border-box !important}.hfe-editor__input{position:absolute;inset:0;display:block;width:100%;height:100%;background:rgba(0,0,0,0);color:rgba(0,0,0,0);caret-color:var(--hfe-color-caret);resize:none;outline:none;overflow:hidden;z-index:2}.hfe-editor__input::selection{background:color-mix(in srgb, var(--hfe-color-accent) 15%, transparent);color:rgba(0,0,0,0)}.hfe-editor__input,.hfe-overlay{font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);font-weight:400;font-variant-ligatures:none;letter-spacing:0;line-height:var(--hfe-line-height);padding:var(--hfe-padding-vertical) var(--hfe-padding-horizontal);box-sizing:border-box;white-space:pre-wrap;word-break:break-all;tab-size:4;margin:0;border:0}.hfe-editor[dir=rtl] .hfe-editor__input,.hfe-editor[dir=rtl] .hfe-overlay{text-align:right}.hfe-editor--formula .hfe-editor__input,.hfe-editor--formula .hfe-overlay{font-family:var(--hfe-formula-font-family)}.hfe-formula-bar{display:flex;background:var(--hfe-formula-bar-background);border:var(--hfe-formula-bar-border-width) solid var(--hfe-formula-bar-border);color:var(--hfe-formula-bar-foreground);border-radius:var(--hfe-formula-bar-border-radius)}.hfe-formula-bar__cell{display:flex;align-items:center;box-sizing:border-box;border-inline-end:var(--hfe-formula-bar-border-width) solid var(--hfe-formula-bar-border)}.hfe-formula-bar__cell--address{flex:0 0 var(--hfe-formula-bar-address-width);gap:var(--hfe-formula-bar-gap-address)}.hfe-formula-bar__cell--formula{flex:1;min-width:0;gap:var(--hfe-formula-bar-gap-formula);padding-inline-start:var(--hfe-formula-bar-padding-horizontal);border-inline-end:none}.hfe-formula-bar__address-input{width:100%;min-width:0;box-sizing:border-box;border:none;outline:none;background:rgba(0,0,0,0);color:inherit;font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);padding:var(--hfe-formula-bar-padding-vertical) var(--hfe-formula-bar-padding-horizontal);border-start-start-radius:var(--hfe-formula-bar-border-radius)}.hfe-formula-bar__address-input:hover{background:var(--hfe-formula-bar-input-background-hover)}.hfe-formula-bar__address-input:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-formula-bar-input-focus-ring);outline-offset:calc(-1*var(--hfe-border-width-focus))}.hfe-formula-bar__fx-icon{flex:none;color:var(--hfe-formula-bar-foreground)}.hfe-formula-bar__body{position:relative;flex:1;min-width:0}.hfe-formula-bar__idle{cursor:text;height:calc(var(--hfe-line-height) + 2*var(--hfe-formula-bar-padding-vertical));font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);white-space:pre-wrap;word-break:break-all;overflow-x:hidden;overflow-y:auto}.hfe-formula-bar__overlay{position:absolute;top:0;inset-inline:0;z-index:var(--hfe-z-index-formula-bar)}.hfe-formula-bar .hfe-editor{display:block;max-width:100% !important;background:var(--hfe-formula-bar-background);--hfe-scrollbar-width: var(--hfe-formula-bar-scrollbar-width)}.hfe-formula-bar .hfe-editor,.hfe-formula-bar .hfe-overlay,.hfe-formula-bar .hfe-editor__input{border:none;box-shadow:none;border-radius:0;padding:0;min-height:unset}.hfe-formula-bar .hfe-overlay,.hfe-formula-bar .hfe-editor__input{padding:var(--hfe-formula-bar-padding-vertical) 0}.hfe-formula-bar__overlay .hfe-editor{border-start-end-radius:var(--hfe-formula-bar-border-radius)}.hfe-formula-bar[dir=rtl] .hfe-formula-bar__idle .hfe-overlay{text-align:right}.hfe-overlay{position:static;display:block;pointer-events:none;color:var(--hfe-color-foreground);z-index:1}.hfe-token--fn{color:var(--hfe-token-fn)}.hfe-token--fn-unknown{color:var(--hfe-token-fn-unknown)}.hfe-token--num{color:var(--hfe-token-num)}.hfe-token--str{color:var(--hfe-token-str)}.hfe-token--op{color:var(--hfe-token-op)}.hfe-token--paren{color:var(--hfe-token-paren)}.hfe-token--paren-err{color:var(--hfe-token-paren-err)}.hfe-token--bracket{color:var(--hfe-token-bracket)}.hfe-token--sep{color:var(--hfe-token-sep)}.hfe-token--sheet{color:var(--hfe-token-sheet)}.hfe-token--ident{color:var(--hfe-token-ident)}.hfe-token--err{color:var(--hfe-token-err);text-decoration:underline wavy var(--hfe-token-err)}.hfe-token--errlit{color:var(--hfe-token-err);font-weight:600}.hfe-token--ref-invalid{color:var(--hfe-token-ref-invalid);text-decoration:line-through}.hfe-token--comment{color:var(--hfe-token-comment);font-style:italic}.hfe-token--comment-err{text-decoration:underline wavy var(--hfe-token-err);text-underline-offset:2px}.hfe-token--paren-match{background:color-mix(in srgb, var(--hfe-token-paren-match) calc(var(--hfe-selection-opacity) * 100%), transparent);border-radius:3px}.hfe-token--dim{opacity:var(--hfe-dim-opacity)}.hfe-token--arg-active{text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px}.hfe-ref-mark{display:inline;padding:0;margin:0;border:0;background:rgba(0,0,0,0);color:var(--hfe-ref-1);pointer-events:none;font:inherit;white-space:inherit}.hfe-ref-mark[data-ref-index="0"]{color:var(--hfe-ref-1)}.hfe-ref-mark[data-ref-index="1"]{color:var(--hfe-ref-2)}.hfe-ref-mark[data-ref-index="2"]{color:var(--hfe-ref-3)}.hfe-ref-mark[data-ref-index="3"]{color:var(--hfe-ref-4)}.hfe-ref-mark[data-ref-index="4"]{color:var(--hfe-ref-5)}.hfe-ref-mark[data-ref-index="5"]{color:var(--hfe-ref-6)}.hfe-ref-mark[data-ref-index="6"]{color:var(--hfe-ref-7)}.hfe-ref-mark[data-ref-index="7"]{color:var(--hfe-ref-8)}.hfe-ref-mark--named{text-decoration:underline dotted;text-underline-offset:3px}.hfe-suggestions{position:fixed;z-index:var(--hfe-z-index-popup);width:264px;max-height:var(--hfe-menu-max-height);display:flex;flex-direction:column;background:var(--hfe-menu-background);color:var(--hfe-color-foreground);border:var(--hfe-menu-border-width) solid var(--hfe-menu-border-color);border-radius:var(--hfe-menu-border-radius);box-shadow:var(--hfe-menu-shadow);font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);gap:var(--hfe-gap)}.hfe-suggestions[hidden]{display:none}.hfe-suggestions__list{overflow-y:auto;padding:var(--hfe-menu-padding-vertical) var(--hfe-menu-padding-horizontal) 0;scrollbar-width:var(--hfe-scrollbar-width);scrollbar-color:var(--hfe-scrollbar-color) rgba(0,0,0,0)}.hfe-suggestions__list:hover{scrollbar-color:var(--hfe-scrollbar-color-hover) rgba(0,0,0,0)}.hfe-suggestions__list:only-of-type{padding-bottom:var(--hfe-menu-padding-vertical)}.hfe-suggestions__item{padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);border-radius:var(--hfe-menu-item-border-radius);cursor:pointer;white-space:nowrap}.hfe-suggestions__item--active{background:var(--hfe-color-highlight)}.hfe-suggestions__item-label{display:block;font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);font-weight:400;line-height:var(--hfe-line-height)}.hfe-suggestions__item-description{display:none;font-size:12px;line-height:16px;color:var(--hfe-color-foreground-secondary);font-family:var(--hfe-font-family);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.hfe-suggestions__item--active .hfe-suggestions__item-description{display:block}.hfe-suggestions__item--named .hfe-suggestions__item-label{display:flex;align-items:center;gap:var(--hfe-gap);color:var(--hfe-token-sheet)}.hfe-suggestions__match{color:var(--hfe-color-accent);font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);font-weight:400;line-height:var(--hfe-line-height)}.hfe-suggestions__kbd-row{flex-shrink:0;display:flex;align-items:center;gap:var(--hfe-gap);padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);border:var(--hfe-menu-kbd-row-border-width) solid var(--hfe-menu-kbd-row-border-color);border-radius:var(--hfe-menu-item-border-radius);font-family:var(--hfe-font-family);font-size:12px;line-height:16px;color:var(--hfe-color-foreground);margin:0 var(--hfe-menu-padding-horizontal) var(--hfe-menu-padding-vertical)}.hfe-suggestions__kbd-chip{background:var(--hfe-menu-kbd-row-chip-color);border-radius:4px;padding:2px 4px}.hfe-fn-help{position:fixed;z-index:var(--hfe-z-index-popup);width:320px;border-radius:var(--hfe-menu-border-radius);background:var(--hfe-menu-background);color:var(--hfe-color-foreground);border:var(--hfe-menu-border-width) solid var(--hfe-menu-border-color);box-shadow:var(--hfe-menu-shadow);font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);padding:var(--hfe-menu-padding-vertical) var(--hfe-menu-padding-horizontal);max-height:var(--hfe-menu-max-height);display:flex;flex-direction:column}.hfe-fn-help[hidden]{display:none}.hfe-fn-help__body{overflow-y:auto;scrollbar-width:var(--hfe-scrollbar-width);scrollbar-color:var(--hfe-scrollbar-color) rgba(0,0,0,0)}.hfe-fn-help__body:hover{scrollbar-color:var(--hfe-scrollbar-color-hover) rgba(0,0,0,0)}.hfe-fn-help__body:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-color-focus-ring);outline-offset:calc(-1*var(--hfe-border-width-focus))}.hfe-fn-help__signature{display:flex;flex-wrap:wrap;align-items:center;padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);background:var(--hfe-color-highlight);border-radius:var(--hfe-menu-item-border-radius);font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height)}.hfe-fn-help__name{color:var(--hfe-color-foreground)}.hfe-fn-help__arg{color:var(--hfe-color-foreground)}.hfe-fn-help__arg--active{background:var(--hfe-menu-chip-background);color:var(--hfe-menu-chip-foreground);border-radius:var(--hfe-menu-chip-border-radius);padding:0 4px}.hfe-fn-help__section{display:flex;flex-direction:column;gap:var(--hfe-gap);padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal);border-radius:var(--hfe-menu-item-border-radius)}.hfe-fn-help__section-label{font-size:12px;line-height:16px;color:var(--hfe-color-foreground-secondary);font-family:var(--hfe-font-family)}.hfe-fn-help__example{font-family:var(--hfe-formula-font-family);color:var(--hfe-color-accent);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height)}.hfe-fn-help__about{font-family:var(--hfe-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);color:var(--hfe-color-foreground)}.hfe-fn-help__param-name{font-family:var(--hfe-formula-font-family);font-size:var(--hfe-font-size);line-height:var(--hfe-line-height);color:var(--hfe-color-foreground)}.hfe-fn-help__param-name--active{color:var(--hfe-color-accent)}.hfe-fn-help__param-desc{font-family:var(--hfe-font-family);font-size:12px;line-height:16px;color:var(--hfe-color-foreground)}.hfe-fn-help__separator{height:1px;border:0;background:var(--hfe-menu-separator-color);margin:var(--hfe-gap) var(--hfe-menu-item-padding-horizontal)}.hfe-fn-help__learn-more{display:flex;align-items:center;gap:var(--hfe-gap);padding:var(--hfe-menu-item-padding-vertical) var(--hfe-menu-item-padding-horizontal) calc(var(--hfe-menu-item-padding-vertical) + var(--hfe-gap));border-radius:var(--hfe-menu-item-border-radius);color:var(--hfe-color-foreground);font-size:var(--hfe-font-size);font-family:var(--hfe-font-family);text-decoration:none;cursor:pointer}.hfe-fn-help__learn-more:hover{text-decoration:underline}.hfe-fn-help__learn-more:focus-visible{outline:var(--hfe-border-width-focus) solid var(--hfe-color-focus-ring);outline-offset:2px}.hfe-cell--error{position:relative;color:var(--hfe-token-err) !important;font-family:var(--hfe-error-font-family);font-size:var(--hfe-font-size);font-weight:400;line-height:var(--hfe-line-height)}.hfe-cell--error::after{content:"";position:absolute;inset-block-end:0;inset-inline-end:0;width:0;height:0;border-style:solid;border-width:0;border-block-end-width:6px;border-inline-start-width:6px;border-color:rgba(0,0,0,0);border-block-end-color:var(--hfe-color-error);pointer-events:none}.hfe-error{position:fixed;z-index:1000;max-width:280px;padding:var(--hfe-error-padding-vertical) var(--hfe-error-padding-horizontal);background:var(--hfe-color-background);border-inline-start:var(--hfe-border-width) solid var(--hfe-color-error);border-radius:var(--hfe-error-border-radius);box-shadow:var(--hfe-menu-shadow)}.hfe-error[hidden]{display:none}.hfe-error__title{font-size:var(--hfe-font-size);color:var(--hfe-color-error);font-family:var(--hfe-error-font-family)}.hfe-error__message{font-size:12px;line-height:16px;color:var(--hfe-color-foreground-secondary);font-family:var(--hfe-font-family);margin-top:var(--hfe-gap)}.hfe-highlight{position:absolute;inset:0;pointer-events:none}.hfe-highlight__item{position:absolute;pointer-events:none;border-width:var(--hfe-reference-border-width);border-radius:var(--hfe-border-radius);border-style:dashed;box-sizing:border-box !important;z-index:5;display:none}.hfe-live-region{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}'; diff --git a/hyperformula-ui/packages/core/tests/theme/stylesheet.test.ts b/hyperformula-ui/packages/core/tests/theme/stylesheet.test.ts index ab681cf75..c0474eac2 100644 --- a/hyperformula-ui/packages/core/tests/theme/stylesheet.test.ts +++ b/hyperformula-ui/packages/core/tests/theme/stylesheet.test.ts @@ -43,3 +43,49 @@ describe("package color scheme", () => { expect(CSS_TEXT.match(/color-scheme:/g)).toHaveLength(1); }); }); + +const MIN_TEXT_CONTRAST = 4.5; +const REF_COLOR_COUNT = 8; + +function lightDarkPair(property: string): [string, string] { + const value = declarations(":root")[property] ?? ""; + const pair = /^light-dark\((#[0-9a-f]{6}),\s*(#[0-9a-f]{6})\)$/i.exec(value); + + if (!pair?.[1] || !pair[2]) { + throw new Error(`expected ${property} to be light-dark(#rrggbb, #rrggbb), got ${value}`); + } + + return [pair[1], pair[2]]; +} + +function relativeLuminance(hex: string): number { + const [red = 0, green = 0, blue = 0] = [1, 3, 5].map((offset) => { + const channel = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255; + + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + }); + + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; +} + +function contrastRatio(foreground: string, background: string): number { + const [lighter, darker] = [relativeLuminance(foreground), relativeLuminance(background)].sort( + (first, second) => second - first, + ); + + return ((lighter ?? 0) + 0.05) / ((darker ?? 0) + 0.05); +} + +describe("reference palette", () => { + const [lightBackground, darkBackground] = lightDarkPair("--hfe-color-background"); + + it.each(Array.from({ length: REF_COLOR_COUNT }, (_, i) => `--hfe-ref-${i + 1}`))( + "%s clears 4.5:1 on the light and dark editor backgrounds", + (property) => { + const [light, dark] = lightDarkPair(property); + + expect(contrastRatio(light, lightBackground)).toBeGreaterThanOrEqual(MIN_TEXT_CONTRAST); + expect(contrastRatio(dark, darkBackground)).toBeGreaterThanOrEqual(MIN_TEXT_CONTRAST); + }, + ); +}); From 46aed0af36d2555b94371712dfde6491aa01891e Mon Sep 17 00:00:00 2001 From: adrianspdev Date: Thu, 22 Oct 2026 12:35:34 +0200 Subject: [PATCH 11/35] Let a host choose the document and the parent of the popups The popup portal, the live region and the stylesheet always went into the global document's body and head, and focus checks read the global document.activeElement. Inside a shadow root the editor's ARIA references pointed across the boundary and resolved to nothing; in an iframe the popups landed in the parent page without its styles; and focus checks were wrong in both. FormulaBuilder now takes rootDocument and portalParent options, creates its elements and resolves selectors in that document, appends the portal and live region to the portal parent, injects the styles into a shadow root when the portal parent is in one, and reads focus through open shadow roots. --- hyperformula-ui/dev-docs/ARCHITECTURE.md | 15 +++- hyperformula-ui/dev-docs/SPEC.md | 4 +- hyperformula-ui/packages/core/DOCS.md | 3 +- .../core/src/formulaBar/formulaBar.ts | 7 +- .../packages/core/src/formulaBuilder.ts | 62 +++++++++++-- hyperformula-ui/packages/core/src/index.ts | 2 +- .../packages/core/src/theme/index.ts | 2 +- .../packages/core/src/theme/injectStyles.ts | 71 ++++++++++----- hyperformula-ui/packages/core/src/ui/dom.ts | 15 +++- .../packages/core/src/ui/focusTrap.ts | 8 +- .../packages/core/src/ui/liveRegion.ts | 24 ++++-- .../packages/core/src/ui/popup/errorPopup.ts | 24 ++++-- .../core/src/ui/popup/popupContext.ts | 4 +- .../core/tests/formula-builder.test.ts | 86 +++++++++++++++++++ .../core/tests/theme/inject-styles.test.ts | 26 ++++++ .../core/tests/theme/stylesheet.test.ts | 17 ++-- .../packages/core/tests/ui/dom.test.ts | 31 +++++++ .../packages/core/tests/ui/focus-trap.test.ts | 20 +++++ 18 files changed, 352 insertions(+), 69 deletions(-) diff --git a/hyperformula-ui/dev-docs/ARCHITECTURE.md b/hyperformula-ui/dev-docs/ARCHITECTURE.md index 9888c3392..b42fca492 100644 --- a/hyperformula-ui/dev-docs/ARCHITECTURE.md +++ b/hyperformula-ui/dev-docs/ARCHITECTURE.md @@ -496,6 +496,19 @@ All popup-style overlays share a common base class `BasePopup` (`popupCtx.portal`) is the shared DOM parent all three popups append to. All three popups take a plain `container: HTMLElement`, not the full `PopupContext` — `FormulaBuilder` passes `popupCtx.portal` to each; `popupCtx.destroy()` runs in `FormulaBuilder.destroy()`. +- **Document and portal parent:** `FormulaBuilder`'s `portalParent` option (default + `rootDocument.body`) is where the portal and the `LiveRegion` go; both are created in the + parent's own document. A host inside a shadow root passes that shadow root, so the + textarea's `aria-controls`/`aria-activedescendant` and a cell's `aria-errormessage` resolve + in one tree, and an axe scan of that root sees the popups. `rootDocument` (default the global + `document`) is the document selector options are resolved against, the detached mount is + created in, and the stylesheet goes into. Styles are injected per root + (`createStyleInjector(...).inject(root)`, ref-counted per document or shadow root): into + `rootDocument`, plus into the portal parent's shadow root when it is in one. Focus checks + never read `document.activeElement`: they call `deepActiveElement(element.ownerDocument)` + (`ui/dom.ts`), which follows open shadow roots down to the focused element and reads the + document the element is actually in. Window-level listeners (scroll, resize, the error + popup's keydown) and viewport sizes still use the global `window`. - `FormulaBar` constructor signature: `(container: HTMLElement, options: { adapter: IGridAdapter; editor: FormulaEditor; knownFunctions: ReadonlySet; className?: string })`. It receives the pre-built `FormulaEditor` from `FormulaBuilder` — it does not construct @@ -717,7 +730,7 @@ inside their host-specific renderers, because each host renders cells differentl The error tooltip itself is Esc-dismissible: `ErrorPopup` owns a window-level `keydown` (capture) listener, registered while attached; it consumes Escape only while the tooltip is visible AND no builder surface holds focus (`builderSurfaceHasFocus()` — -`document.activeElement` inside a `.hfe-editor`, `.hfe-fn-help`, or `.hfe-formula-bar` root; +the deep active element inside a `.hfe-editor`, `.hfe-fn-help`, or `.hfe-formula-bar` root; the fn-help dialog's own Escape handler and the address box's Escape-revert must win the same way the editor's ladder does), acted on in `attach()`'s scope alongside its existing scroll listener — no adapter or diff --git a/hyperformula-ui/dev-docs/SPEC.md b/hyperformula-ui/dev-docs/SPEC.md index fb5c8e4e1..ec4b9028a 100644 --- a/hyperformula-ui/dev-docs/SPEC.md +++ b/hyperformula-ui/dev-docs/SPEC.md @@ -147,7 +147,7 @@ Internal classes (`Kernel`, `Lexer`, `EngineBridge`, `RichInput`, `VisualOverlay One `FormulaBuilder` per grid instance. It owns the `PopupContext` (shared popup portal), the `ErrorPopup`, the single `FormulaEditor`, and — when `formulaBarContainer` is given — a `FormulaBar`. -Constructed as `new FormulaBuilder(options: FormulaBuilderOptions)`; the options interface lives in `packages/core/src/formulaBuilder.ts`. Fields: `rootEl` (required — grid root, error-popup hover/focus host), `adapter` (defaults to `NullAdapter`), `hyperformula` (instance — functions, validation, named exprs), `className` (single theming entry point: portal, bar, editor, overlay), `formulaBarContainer` (wires the formula bar — element or CSS selector), `popups` (`PopupsOptions` — shared `showClose` default + per-popup overrides), `icons` (`IconsOptions` — host factories that replace the built-in glyphs: `close`, `externalLink`, `namedExpression`, `function`; forwarded to the editor's popups, the error popup and the bar's fx icon), `initialValue` / `language` / `sheetId` (forwarded to the editor), `direction` (`LayoutDirection` — `"ltr" | "rtl"`, default `"ltr"`; construction-only — layout mirrors, formula text stays LTR), `onCommit(value, direction?)` (default: `adapter.commit`), `onCancel()` (default: `adapter.cancel`). +Constructed as `new FormulaBuilder(options: FormulaBuilderOptions)`; the options interface lives in `packages/core/src/formulaBuilder.ts`. Fields: `rootEl` (required — grid root, error-popup hover/focus host), `adapter` (defaults to `NullAdapter`), `hyperformula` (instance — functions, validation, named exprs), `className` (single theming entry point: portal, bar, editor, overlay), `formulaBarContainer` (wires the formula bar — element or CSS selector), `popups` (`PopupsOptions` — shared `showClose` default + per-popup overrides), `icons` (`IconsOptions` — host factories that replace the built-in glyphs: `close`, `externalLink`, `namedExpression`, `function`; forwarded to the editor's popups, the error popup and the bar's fx icon), `initialValue` / `language` / `sheetId` (forwarded to the editor), `direction` (`LayoutDirection` — `"ltr" | "rtl"`, default `"ltr"`; construction-only — layout mirrors, formula text stays LTR), `onCommit(value, direction?)` (default: `adapter.commit`), `onCommitFill(value)` (default: `adapter.commitFill` when present), `onCancel()` (default: `adapter.cancel`), `rootDocument` (the grid's document, e.g. an iframe's — elements, selector lookup and styles; default `document`), `portalParent` (`HTMLElement | ShadowRoot` — parent of the popup portal and the live region, and a shadow root that receives the styles; default `rootDocument.body`). Methods: `openEditor(mount, seed)`, `closeEditor()`, `markCell(cellEl, value)`, `attachFormulaBar(container)`, `detachFormulaBar()`, `attachSuggestionsHost(container: HTMLElement | string)`, `detachSuggestionsHost()`, `attachFnHelpHost(container: HTMLElement | string)`, `detachFnHelpHost()`, `destroy()`. Getters: `editor` (the `FormulaEditor`), `hasFormulaBar`. The `formulaBarContainer` option is sugar for `attachFormulaBar`; detaching restores the barless inline-edit subscription. `popups.suggestions.container` / `popups.fnHelp.container` (`PopupHostOption`, `HTMLElement | string`) are constructor sugar mirroring `formulaBarContainer`: stripped from the popup options and delegated to `attachSuggestionsHost`/`attachFnHelpHost` after editor construction. A selector that resolves to nothing makes the `attach*` methods throw `FormulaBuilder: host "…" not found`; in the constructor options it logs that message once as a warning and falls back to the default placement; all four attach/detach methods are no-ops after `destroy()`. @@ -170,7 +170,7 @@ There is exactly **one** `FormulaEditor` per grid; it reparents between the inli ## 4. Layered Text Editing — DOM & CSS -DOM structure (built by `packages/core/src/ui/text/richInput.ts` + `ui/text/visualOverlay.ts`; class names from `theme/classNames.ts`): a root `div.hfe-editor` wraps `div.hfe-editor__layers`, which stacks `div.hfe-overlay` (`aria-hidden` — token spans + ref marks) beneath a `textarea.hfe-editor__input` (`spellcheck=false`). Popups render into the shared `PopupContext` portal appended to `document.body`. +DOM structure (built by `packages/core/src/ui/text/richInput.ts` + `ui/text/visualOverlay.ts`; class names from `theme/classNames.ts`): a root `div.hfe-editor` wraps `div.hfe-editor__layers`, which stacks `div.hfe-overlay` (`aria-hidden` — token spans + ref marks) beneath a `textarea.hfe-editor__input` (`spellcheck=false`). Popups render into the shared `PopupContext` portal appended to `FormulaBuilder`'s `portalParent` (default `document.body`). **Pixel-alignment-critical props** (must match on both layers): `font-family`, `font-size`, `font-weight`, `letter-spacing`, `line-height` (unitless), `padding`, `tab-size`, `white-space: pre-wrap`, `word-break`, `box-sizing: border-box`. Textarea: `position:absolute; inset:0; background:transparent; color:transparent; caret-color: var(--hfe-color-caret); resize:none; border:0`. Overlay: `position:absolute; inset:0; pointer-events:none`. Default font: `ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`; `font-variant-ligatures: none`. diff --git a/hyperformula-ui/packages/core/DOCS.md b/hyperformula-ui/packages/core/DOCS.md index b27e04f20..af786703f 100644 --- a/hyperformula-ui/packages/core/DOCS.md +++ b/hyperformula-ui/packages/core/DOCS.md @@ -191,10 +191,11 @@ const builder = new FormulaBuilder({ rootEl: gridRoot, adapter, formulaBarContai | Export | Kind | Summary | |---|---|---| | `CN` | const | The `hfe-` BEM class-name table shared by core, adapters, and consumer CSS overrides. | -| `createStyleInjector` | function | Creates a ref-counted, SSR-guarded injector that maintains one `