From 8ba9fa006dedcd64cb91eafa97f982112d757da6 Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 14 Nov 2026 10:43:37 +0100 Subject: [PATCH 1/4] feat(theme): design tokens for every color, type and shape; theme objects Every color in the stylesheet is now a --de-* token on .de-root, with the dark values on .de-root.de-dark: find highlights, collaboration status, avatars, slideshow, tooltips, file badges, paper and placeholders, and the source editor's syntax colors (moved to the root so a theme reaches them). Fonts, radii and shadows are tokens too. Rendering is unchanged: a computed-style comparison against the previous stylesheet over DOCX, XLSX, PPTX and Markdown, light and dark, differs only in equivalent font stacks and the color-button swatch edge, which now follows swatch-border (visible in dark). - theme accepts { mode, tokens, light, dark } with typed camelCase token names (ThemeTokens); THEME_TOKENS, createTheme, themeStyle, useThemeRoot - injectStyles={false} and @p10node/office-engine/styles.css for hosts that load the stylesheet themselves (CSP, extracted CSS, cascade layers) - docs/theming.md; unit tests keep THEME_TOKENS and the stylesheet in step and reject literal colors outside token definitions; e2e for theme objects in both modes and for injectStyles=false Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- docs/api.md | 7 +- docs/features.md | 2 +- docs/theming.md | 90 ++++++++++ e2e/app/main.tsx | 1 + e2e/viewer.spec.ts | 29 ++++ package.json | 6 +- playground/main.tsx | 2 +- src/index.ts | 2 +- src/react/DocViewer.tsx | 22 ++- src/react/common/hooks.ts | 4 +- src/react/pptx/PptxView.tsx | 2 +- src/react/pptx/Slide.tsx | 4 +- src/react/styles.css | 333 +++++++++++++++++++++--------------- src/react/theme.ts | 81 +++++++++ test/theme.test.ts | 62 +++++++ 16 files changed, 493 insertions(+), 157 deletions(-) create mode 100644 docs/theming.md create mode 100644 src/react/theme.ts create mode 100644 test/theme.test.ts diff --git a/README.md b/README.md index 7bac394..45ca7a7 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ Also online at [p10node.github.io/office-engine](https://p10node.github.io/offic - [Features](docs/features.md): format coverage, editing, saving, limitations - [API](docs/api.md): props, custom views, `mount()`, ``, core API, CSP +- [Theming](docs/theming.md): design tokens, light / dark, loading the stylesheet yourself - [Real-time collaboration](docs/collaboration.md) - [Development](docs/development.md): playground, architecture, demo deploy, release @@ -75,7 +76,7 @@ Also online at [p10node.github.io/office-engine](https://p10node.github.io/offic - **File menu**: new, open (also DOC / XLS / PPT / RTF / OpenDocument / Pages / Keynote / Numbers / CSV / text / Markdown / HTML / SVG / diagrams), import CSV, rename, download as PDF / HTML / text / CSV, document details, print - **Comments**: threads with replies, resolve, edit / delete, @mentions; on DOCX text, XLSX cells and PPTX slides, stored in the file the way Word, Excel and PowerPoint store them - **Real-time collaboration**: live edits, avatars, collaborators' cursors, offline edits sync on reconnect -- **Dark mode**: `theme="dark"` or `"auto"` (follows the OS); documents keep their own colors +- **Dark mode and theming**: `theme="dark"` or `"auto"` (follows the OS), or a theme object with design tokens for colors, type and shape ([Theming](docs/theming.md)); documents keep their own colors - **Embed anywhere**: React component, hook, vanilla `mount()`, `` web component, single ` ``` -``: attributes `src`, `name`, `editable`, `toolbar="false"`, `thumbnails="false"`, `save="false"`, `download="false"`, `file-menu="false"`, `open="false"`, `capture-select-all="false"`, `theme="dark"` (or `"auto"`), `collab` / `room` / `user` / `base` (real-time collaboration; `user` also writes the comments), `comments="false"`; property `.file = File`; events `load`, `error`, `comment` (`detail`: a `CommentEvent`). +``: attributes `src`, `name`, `editable`, `toolbar="false"`, `thumbnails="false"`, `save="false"`, `download="false"`, `file-menu="false"`, `open="false"`, `capture-select-all="false"`, `theme="dark"` (or `"auto"`), `collab` / `room` / `user` / `base` (real-time collaboration; `user` also writes the comments), `comments="false"`; properties `.file = File` and `.icons` (map or resolver, as `icons` above); events `load`, `error`, `comment` (`detail`: a `CommentEvent`). ## Core API (no React) diff --git a/docs/features.md b/docs/features.md index 505502c..215373e 100644 --- a/docs/features.md +++ b/docs/features.md @@ -170,7 +170,7 @@ Diagram sources open as a document holding the drawing (an SVG picture, editable ### Toolbar -Single row, buttons that don't fit collapse into `⋮`; custom-drawn dropdowns (font/style preview, number formats with examples, ↑↓/Enter/Esc/type-ahead keys, never steals focus from the document); font size box `− 11 +` accepts direct input; cell borders picked from an icon grid; color pickers have a palette plus a **Custom** row (`+` opens the system color picker, recent custom colors are remembered across sessions). +Single row, buttons that don't fit collapse into `⋮`; custom-drawn dropdowns (font/style preview, number formats with examples, ↑↓/Enter/Esc/type-ahead keys, never steals focus from the document); font size box `− 11 +` accepts direct input; cell borders picked from an icon grid; color pickers have a palette plus a **Custom** row (`+` opens the system color picker, recent custom colors are remembered across sessions). Every icon can be replaced with the host's own set (Lucide, Phosphor or any other) through the `icons` prop, see [API](api.md#icons). ### Dark mode diff --git a/e2e/app/main.tsx b/e2e/app/main.tsx index 79fa95b..be08f97 100644 --- a/e2e/app/main.tsx +++ b/e2e/app/main.tsx @@ -2,6 +2,7 @@ * Fixture for the browser tests: DocViewer configured from the query string, callbacks recorded on window. * ?src=samples/sample.docx&editable=1&allowSave=0&onSave=1&items=1&close=1&collab=ws://…&room=r * comments: &me=Ann&meId=u1 (author), &people=1 (mentions: Ann, Bob Stone, Cid), &comments=0 (hidden), &cmOpen=1 + * &icons=1: every icon from a host resolver, as `` */ import { useState } from 'react' import { createRoot } from 'react-dom/client' @@ -63,6 +64,13 @@ const initial: DocViewerProps = { open: flag('cmOpen'), onEvent: (e) => log({ type: 'comment', action: e.type, id: e.comment.id, text: e.comment.text, thread: e.thread.id, quote: e.quote, mentioned: e.mentioned.map((m) => m.id ?? m.name), participants: e.participants.map((m) => m.id ?? m.name) }), }, + icons: q.has('icons') + ? (name) => ( + + + + ) + : undefined, collab: q.get('collab') ? { transport: q.get('collab')!, room: q.get('room') ?? 'e2e', base: Number(q.get('base')) || 0, user: { name: q.get('user') ?? 'Tester' } } : undefined, } diff --git a/e2e/icons.spec.ts b/e2e/icons.spec.ts new file mode 100644 index 0000000..14892ee --- /dev/null +++ b/e2e/icons.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'bun:test' +import { useBrowser, viewer, type Viewer } from './harness' + +useBrowser() + +/** host icons (`.de-icon`) and built-in icon svgs left in the chrome matching `sel` */ +async function icons(v: Viewer, sel: string) { + return v.page.evaluate((s) => { + const roots = [...document.querySelectorAll(s)] + const svgs = roots.flatMap((r) => [...r.querySelectorAll('svg')]) + return { + host: roots.reduce((n, r) => n + r.querySelectorAll('.de-icon > svg[data-host-icon]').length, 0), + builtIn: svgs.filter((x) => !x.closest('.de-icon')).length, + } + }, sel) +} + +const hostIcon = (v: Viewer, button: string, name: string) => v.page.locator(`${button} .de-icon > svg[data-host-icon="${name}"]`) + +describe('host icons', () => { + test('docx: zoom, outline toggle and close, toolbar, find & replace', async () => { + const v = await viewer({ src: 'samples/sample.docx', icons: true }) + expect(await hostIcon(v, 'button[aria-label="Zoom in"]', 'zoomIn').count()).toBe(1) + expect(await hostIcon(v, 'button[aria-label="Zoom out"]', 'zoomOut').count()).toBe(1) + expect(await hostIcon(v, '.de-outline-toggle', 'outline').count()).toBe(1) + await v.page.locator('.de-outline-toggle').click() + expect(await hostIcon(v, 'button[aria-label="Close outline"]', 'close').count()).toBe(1) + await v.page.locator('button[aria-label="Close outline"]').click() + + await v.edit() + const bar = await icons(v, '.de-ebar') + expect(bar.host).toBeGreaterThan(20) + expect(bar.builtIn).toBe(0) + await v.btn('Find & replace').click() + expect(await hostIcon(v, '.de-find button[aria-label="Replace"]', 'replace').count()).toBe(1) + expect((await icons(v, '.de-find')).builtIn).toBe(0) + expect(v.errors).toEqual([]) + }) + + test('xlsx: toolbar and the add-sheet tab', async () => { + const v = await viewer({ src: 'samples/sample.xlsx', editable: true, icons: true }) + await v.page.locator('.de-ebar').first().waitFor() + const bar = await icons(v, '.de-ebar') + expect(bar.host).toBeGreaterThan(20) + expect(bar.builtIn).toBe(0) + expect(await hostIcon(v, '.de-tab-add', 'sheetAdd').count()).toBe(1) + expect(await v.page.locator('.de-tab-add').getAttribute('title')).toBe('Add sheet') + expect(v.errors).toEqual([]) + }) + + test('pptx: Present, zoom, toolbar', async () => { + const v = await viewer({ src: 'samples/sample.pptx', icons: true }) + expect(await hostIcon(v, '.de-present-btn', 'present').count()).toBe(1) + expect(await v.page.locator('.de-present-btn').innerText()).toContain('Present') + expect(await hostIcon(v, 'button[aria-label="Zoom in"]', 'zoomIn').count()).toBe(1) + + await v.edit() + const bar = await icons(v, '.de-ebar') + expect(bar.host).toBeGreaterThan(20) + expect(bar.builtIn).toBe(0) + expect(v.errors).toEqual([]) + }) + + test('without icons the built-in svgs stay', async () => { + const v = await viewer({ src: 'samples/sample.docx', editable: true }) + await v.page.locator('.de-ebar').first().waitFor() + const bar = await icons(v, '.de-ebar') + expect(bar.host).toBe(0) + expect(bar.builtIn).toBeGreaterThan(20) + expect(await v.page.locator('button[aria-label="Zoom in"] > svg').count()).toBe(1) + }) +}) diff --git a/src/embed.ts b/src/embed.ts index 4b41b0c..81318e0 100644 --- a/src/embed.ts +++ b/src/embed.ts @@ -2,6 +2,7 @@ import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import './core/default-worker' import { DocViewer, type DocViewerProps } from './react/DocViewer' +import type { Icons } from './react/edit/Toolbar' export type MountOptions = Omit @@ -37,12 +38,13 @@ export function mount(el: HTMLElement, options: MountOptions): MountedViewer { * Attributes: src, name, editable, toolbar / thumbnails / save / download / file-menu / open / capture-select-all ("false" to turn off), * theme ("light" | "dark" | "auto"); * real-time collaboration: collab (hub WebSocket URL), room, user (display name), base (snapshot seq `src` was opened at). - * Property: `file` (File/Blob/ArrayBuffer) for local data. + * Properties: `file` (File/Blob/ArrayBuffer) for local data; `icons` (map or resolver, as DocViewer's `icons`). */ export class OfficeViewerElement extends HTMLElement { static observedAttributes = ['src', 'name', 'toolbar', 'thumbnails', 'editable', 'save', 'download', 'file-menu', 'open', 'capture-select-all', 'theme', 'collab', 'room', 'user', 'base', 'comments'] private viewer?: MountedViewer private _file?: Blob | ArrayBuffer + private _icons?: Icons get file() { return this._file @@ -52,6 +54,14 @@ export class OfficeViewerElement extends HTMLElement { this.sync() } + get icons() { + return this._icons + } + set icons(v: Icons | undefined) { + this._icons = v + this.sync() + } + connectedCallback() { if (this.viewer) return const shadow = this.shadowRoot ?? this.attachShadow({ mode: 'open' }) @@ -85,6 +95,7 @@ export class OfficeViewerElement extends HTMLElement { allowOpen: this.getAttribute('open') !== 'false', captureSelectAll: this.getAttribute('capture-select-all') !== 'false', theme: THEMES.find((t) => t === this.getAttribute('theme')), + icons: this._icons, collab: this.getAttribute('collab') && this.getAttribute('room') ? { diff --git a/src/index.ts b/src/index.ts index 0acc1e9..9d018d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export { useDocument, type DocumentState } from './react/useDocument' export { injectStyles, stylesText } from './react/injectStyles' export type { Zoom } from './react/common/ZoomControl' export { THEME_TOKENS, createTheme, themeStyle, useThemeRoot, type Theme, type ThemeMode, type ThemeOptions, type ThemeToken, type ThemeTokens } from './react/theme' +export { IconsProvider, ICON_NAMES, type IconName, type Icons } from './react/edit/Toolbar' export { DocumentClient, setDefaultWorker, type OpenOptions } from './core/worker/client' export type { DocSource } from './core/source' diff --git a/src/react/DocViewer.tsx b/src/react/DocViewer.tsx index 63865be..8d1f37c 100644 --- a/src/react/DocViewer.tsx +++ b/src/react/DocViewer.tsx @@ -6,7 +6,7 @@ import { DocxView } from './docx/DocxView' import { ViewerContext, type FileOps, type ViewerApi } from './common/Slots' import { StatusContext } from './common/Status' import { ConfirmDialog, PromptDialog, Toast } from './common/Dialog' -import { download, Icon } from './edit/Toolbar' +import { download, Icon, IconsProvider, type Icons } from './edit/Toolbar' import { DetailsDialog } from './file/DetailsDialog' import { FileMenu, KIND_LABEL, useFileActions, type FileMenuItem } from './file/FileMenu' import { importFile, type Imported } from './file/importFile' @@ -105,6 +105,12 @@ export interface DocViewerProps { * host hears about. Shown by default, written while editing; `false` hides them. */ comments?: CommentOptions | false + /** + * The host's icons, shown instead of the built-in ones: a map by name (see `ICON_NAMES`) or a resolver + * `(name, { size }) => node`; a missing entry, or `undefined` / `null` from the resolver, keeps the built-in icon. + * Each is rendered in `span.de-icon`, sized by font-size (1em). + */ + icons?: Icons } const EXT: Record = { docx: '.docx', xlsx: '.xlsx', pptx: '.pptx' } @@ -541,6 +547,7 @@ export function DocViewer(props: DocViewerProps) { else body = return ( +
{toolbar && (
@@ -696,5 +703,6 @@ export function DocViewer(props: DocViewerProps) { /> )}
+ ) } diff --git a/src/react/common/ContextMenu.tsx b/src/react/common/ContextMenu.tsx index d342000..15bc2ce 100644 --- a/src/react/common/ContextMenu.tsx +++ b/src/react/common/ContextMenu.tsx @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import { Icon } from '../edit/Toolbar' +import { Icon, type IconName } from '../edit/Toolbar' export interface MenuItem { label: string @@ -10,7 +10,7 @@ export interface MenuItem { sep?: boolean /** shortcut / secondary text */ hint?: string - icon?: string + icon?: IconName /** small section title above this item */ head?: string /** submenu: the item opens these beside it */ diff --git a/src/react/common/ZoomControl.tsx b/src/react/common/ZoomControl.tsx index 219d1e2..808aa3f 100644 --- a/src/react/common/ZoomControl.tsx +++ b/src/react/common/ZoomControl.tsx @@ -1,3 +1,5 @@ +import { Icon } from '../edit/Toolbar' + export type Zoom = number | 'fit' const STEPS = [0.25, 0.33, 0.5, 0.67, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3] @@ -11,13 +13,13 @@ export function ZoomControl({ zoom, effective, onChange }: { zoom: Zoom; effecti return (
onChange(zoom === 'fit' ? 1 : 'fit')}> {zoom === 'fit' ? 'Fit' : `${Math.round(effective * 100)}%`}
) diff --git a/src/react/docx/DocxToolbar.tsx b/src/react/docx/DocxToolbar.tsx index 54da9b9..ba88d7c 100644 --- a/src/react/docx/DocxToolbar.tsx +++ b/src/react/docx/DocxToolbar.tsx @@ -2,13 +2,13 @@ import { useEffect, useState, type CSSProperties } from 'react' import type { CanonStyle, StyleInfo } from '../../core/docx/types' import { MOD } from '../common/ContextMenu' import { EditorBar } from '../common/Slots' -import { Btn, ColorBtn, Dropdown, fontName, fontOptions, GridPicker, Icon, Menu, pickFiles, Pop, Select, Sep, SizeBox, type DropOption } from '../edit/Toolbar' +import { Btn, ColorBtn, Dropdown, fontName, fontOptions, GridPicker, Icon, Menu, pickFiles, Pop, Select, Sep, SizeBox, type DropOption, type IconName } from '../edit/Toolbar' import type { BorderPreset } from './tables' import type { DocxEditor, FormatState } from './DocxEditor' const HIGHLIGHTS = ['#ffff00', '#00ff00', '#00ffff', '#ff00ff', '#0000ff', '#ff0000', '#000080', '#008080', '#008000', '#800080', '#800000', '#808000', '#808080', '#c0c0c0', '#000000'] -const CELL_BORDERS: { k: BorderPreset; icon: string; title: string }[] = [ +const CELL_BORDERS: { k: BorderPreset; icon: IconName; title: string }[] = [ { k: 'all', icon: 'bAll', title: 'All borders' }, { k: 'inner', icon: 'bInner', title: 'Inner borders' }, { k: 'outer', icon: 'bOuter', title: 'Outer borders' }, @@ -27,7 +27,7 @@ export const CELL_LINES = [ { value: '0.5pt dotted', label: 'Dotted', hint: '·····' }, { value: '2.25pt double', label: 'Double', hint: '═══' }, ] -const VALIGN = [ +const VALIGN: DropOption[] = [ { value: 'top', label: 'Top', icon: 'vTop' }, { value: 'middle', label: 'Middle', icon: 'vMiddle' }, { value: 'bottom', label: 'Bottom', icon: 'vBottom' }, diff --git a/src/react/docx/DocxView.tsx b/src/react/docx/DocxView.tsx index eccaaad..de64edf 100644 --- a/src/react/docx/DocxView.tsx +++ b/src/react/docx/DocxView.tsx @@ -11,7 +11,7 @@ import { ZoomControl, type Zoom } from '../common/ZoomControl' import { ContextMenu, MOD, type MenuItem } from '../common/ContextMenu' import { PromptDialog, Toast, type PromptSpec } from '../common/Dialog' import { domFromPos, getSel, opInsert, posFromDom } from '../edit/input' -import { download, pickFiles } from '../edit/Toolbar' +import { download, Icon, pickFiles } from '../edit/Toolbar' import { runsText, type RRun } from '../edit/rich' import { DocxEditor, type CommentRange, type DocState, type FormatState } from './DocxEditor' import { markCss, useCommentsCtl, useCommentsState, type CommentHost, type PlacedThread } from '../comments/Comments' @@ -839,7 +839,7 @@ export function DocxView({ client, onStatus, initialZoom = 'fit' }: DocxViewProp
Outline
{headings.length ? ( @@ -886,7 +886,7 @@ export function DocxView({ client, onStatus, initialZoom = 'fit' }: DocxViewProp )} {!isEditing && done && ( )} {pageDlg && setPageDlg(false)} onOk={(set) => void editor.setPage(set).catch((e) => console.error('[office-engine] page setup failed', e))} />} diff --git a/src/react/docx/FindBar.tsx b/src/react/docx/FindBar.tsx index 090624a..b28b5a6 100644 --- a/src/react/docx/FindBar.tsx +++ b/src/react/docx/FindBar.tsx @@ -49,7 +49,7 @@ export function FindBar(props: { {props.query ? (props.count ? `${Math.min(props.index + 1, props.count)} / ${props.count}` : '0 / 0') : ''} props.onNav(-1)} disabled={!props.count} /> props.onNav(1)} disabled={!props.count} /> - {props.editable && setShowRep((v) => !v)} />} + {props.editable && setShowRep((v) => !v)} />}
{props.options &&
{props.options}
} diff --git a/src/react/edit/Toolbar.tsx b/src/react/edit/Toolbar.tsx index e49e36a..55ab133 100644 --- a/src/react/edit/Toolbar.tsx +++ b/src/react/edit/Toolbar.tsx @@ -1,9 +1,12 @@ import { Children, + createContext, Fragment, isValidElement, + useContext, useEffect, useLayoutEffect, + useMemo, useRef, useState, useSyncExternalStore, @@ -16,7 +19,7 @@ import { cssColor } from '../../core/color' const BORDER_GRID = -const P: Record = { +const P = { undo: , redo: , bold: , @@ -145,9 +148,54 @@ const P: Record = { info: , comment: , commentAdd: , + zoomIn: , + zoomOut: , + present: , + sheetAdd: , + replace: , +} satisfies Record + +/** the name of one of the viewer's icons */ +export type IconName = keyof typeof P + +/** the names of the viewer's icons, for a host's own set (``) */ +export const ICON_NAMES = Object.keys(P) as IconName[] + +/** + * A host's icons: a map by name, or a resolver called for every icon. A missing entry, or a resolver + * returning `undefined` / `null`, keeps the built-in icon. Host icons are sized by font-size (1em). + */ +export type Icons = Partial> | ((name: IconName, props: { size: number }) => ReactNode | undefined | null) + +type Resolve = (name: IconName, size: number) => ReactNode | undefined + +const IconsContext = createContext(null) + +/** the host's icon for `name`, or `undefined` for the built-in one */ +export function hostIcon(icons: Icons | undefined, name: IconName, size: number): ReactNode | undefined { + if (!icons) return undefined + const own = typeof icons === 'function' ? icons(name, { size }) : icons[name] + return own === null || own === false ? undefined : own +} + +/** + * Gives the views inside it the host's icons; DocViewer applies its `icons` prop through one. + * Nested providers fall back to the enclosing one for names they leave out. + */ +export function IconsProvider({ icons, children }: { icons?: Icons; children?: ReactNode }) { + const outer = useContext(IconsContext) + const resolve = useMemo(() => (icons ? (name, size) => hostIcon(icons, name, size) ?? outer?.(name, size) : outer), [icons, outer]) + return {children} } -export function Icon({ name, size = 18 }: { name: string; size?: number }) { +export function Icon({ name, size = 18 }: { name: IconName; size?: number }) { + const own = useContext(IconsContext)?.(name, size) + if (own !== undefined) + return ( + + {own} + + ) return ( {P[name]} @@ -191,7 +239,7 @@ export function useDismiss(ref: RefObject, open: boolean, cl }, [open, ref]) } -export function Btn(props: { icon?: string; label?: ReactNode; title: string; active?: boolean; disabled?: boolean; onClick: () => void; wide?: boolean }) { +export function Btn(props: { icon?: IconName; label?: ReactNode; title: string; active?: boolean; disabled?: boolean; onClick: () => void; wide?: boolean }) { return ( props.onInsert(v)} /> + props.onInsert(v)} /> {!viewer.editable && ( )} diff --git a/src/react/styles.css b/src/react/styles.css index 6b49dd5..511a60c 100644 --- a/src/react/styles.css +++ b/src/react/styles.css @@ -306,6 +306,9 @@ cursor: pointer; } .de-zoom button { + display: inline-flex; + align-items: center; + justify-content: center; width: 32px; height: 30px; font-size: 16px; @@ -684,6 +687,18 @@ padding: 6px; background: var(--de-ebar); } +/* a host's icon (`icons` prop / IconsProvider), sized by font-size */ +.de-icon { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + line-height: 1; +} +.de-icon > svg { + width: 1em; + height: 1em; +} .de-tb { appearance: none; position: relative; @@ -758,7 +773,7 @@ } .de-dd:hover:not(:disabled), .de-dd-open { background: var(--de-hover); } .de-dd:disabled { opacity: 0.38; cursor: default; } -.de-dd > svg { flex: none; color: var(--de-ink); } +.de-dd > svg, .de-dd > .de-icon { flex: none; color: var(--de-ink); } .de-dd-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; } .de-dd-ph { color: var(--de-muted); } .de-dd-pop { @@ -786,7 +801,7 @@ cursor: pointer; user-select: none; } -.de-dd-opt > svg { flex: none; color: var(--de-ink); } +.de-dd-opt > svg, .de-dd-opt > .de-icon { flex: none; color: var(--de-ink); } .de-dd-hi { background: var(--de-hover-solid); } .de-dd-dis { opacity: 0.4; cursor: default; } .de-dd-check { display: inline-flex; width: 16px; flex: none; color: var(--de-on-ink); } @@ -1001,7 +1016,7 @@ .de-ctx-sub { min-width: 200px; max-height: calc(100vh - 8px); overflow-y: auto; } .de-ctx-sep { height: 1px; margin: 6px 0; background: var(--de-sep); } .de-ctx-head { padding: 6px 16px 2px 38px; color: var(--de-muted); font-size: 11px; font-weight: 600; letter-spacing: 0.03em; text-transform: uppercase; } -.de-tab-add { font-size: 18px; padding: 0 10px; } +.de-tab-add { display: inline-flex; align-items: center; padding: 0 10px; } .de-tab-input { width: 120px; margin: 4px 2px; padding: 0 6px; border: 1px solid var(--de-accent); border-radius: var(--de-radius-sm); background: var(--de-input-bg); color: var(--de-text); font: inherit; } /* formula editing: reference boxes, colored references, autocomplete */ .de-fx { position: relative; } @@ -1107,6 +1122,9 @@ top: 12px; left: 12px; z-index: 6; + display: inline-flex; + align-items: center; + justify-content: center; width: 34px; height: 34px; border: 1px solid var(--de-border); diff --git a/src/react/xlsx/XlsxToolbar.tsx b/src/react/xlsx/XlsxToolbar.tsx index 6c6c9e9..145f968 100644 --- a/src/react/xlsx/XlsxToolbar.tsx +++ b/src/react/xlsx/XlsxToolbar.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import type { BorderKind, FmtPatch } from '../../core/xlsx/types' import { MOD } from '../common/ContextMenu' import { EditorBar } from '../common/Slots' -import { Btn, ColorBtn, Dropdown, fontName, fontOptions, Icon, Menu, Pop, Select, Sep, SizeBox } from '../edit/Toolbar' +import { Btn, ColorBtn, Dropdown, fontName, fontOptions, Icon, Menu, Pop, Select, Sep, SizeBox, type DropOption, type IconName } from '../edit/Toolbar' const FORMATS = [ { value: 'General', label: 'Automatic', hint: '1000.12' }, @@ -28,7 +28,7 @@ const FORMATS = [ { value: '[h]:mm:ss', label: 'Duration', hint: '24:01:00' }, ] const SIZES = [6, 7, 8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 36, 48] -const BORDERS: { k: BorderKind; icon: string; title: string }[] = [ +const BORDERS: { k: BorderKind; icon: IconName; title: string }[] = [ { k: 'all', icon: 'bAll', title: 'All borders' }, { k: 'inner', icon: 'bInner', title: 'Inner borders' }, { k: 'outer', icon: 'bOuter', title: 'Outer borders' }, @@ -63,7 +63,7 @@ const ROTATE = [ { value: 255, label: 'Stack vertically', icon: 'rotStack' }, { value: 90, label: 'Rotate up', icon: 'rotUp90' }, { value: -90, label: 'Rotate down', icon: 'rotDown90' }, -] +] satisfies DropOption[] const FUNCS = ['SUM', 'AVERAGE', 'COUNT', 'MAX', 'MIN'] export type MergeMode = 'all' | 'across' | 'down' | 'unmerge' diff --git a/src/react/xlsx/XlsxView.tsx b/src/react/xlsx/XlsxView.tsx index 7cc6335..957cb56 100644 --- a/src/react/xlsx/XlsxView.tsx +++ b/src/react/xlsx/XlsxView.tsx @@ -14,6 +14,7 @@ import { ContextMenu, MOD, type MenuItem } from '../common/ContextMenu' import { Toast } from '../common/Dialog' import { FindBar } from '../docx/FindBar' import { XlsxToolbar, type MergeMode } from './XlsxToolbar' +import { Icon } from '../edit/Toolbar' import { Axis } from './axis' import { fitNumber, fontPx } from './fit' import { sheetPrintable } from './print' @@ -374,7 +375,7 @@ export function XlsxView({ client, onStatus, initialSheet }: XlsxViewProps) {
{viewer.editable && ( )} {meta.sheets.filter((s) => !s.hidden).map((s) => diff --git a/test/icons.test.tsx b/test/icons.test.tsx new file mode 100644 index 0000000..e2a30eb --- /dev/null +++ b/test/icons.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'bun:test' +import { renderToStaticMarkup } from 'react-dom/server' +import { hostIcon, Icon, ICON_NAMES, IconsProvider, type IconName, type Icons } from '../src/react/edit/Toolbar' + +const html = (icons: Icons | undefined, name: IconName, size?: number) => + renderToStaticMarkup( + + + , + ) + +describe('icons', () => { + test('names cover the controls that had text glyphs', () => { + for (const n of ['zoomIn', 'zoomOut', 'outline', 'present', 'sheetAdd', 'replace', 'close'] as const) expect(ICON_NAMES).toContain(n) + }) + + test('built-in svg without host icons', () => { + const out = html(undefined, 'bold') + expect(out).toStartWith(' { + const icons = { bold: B } + expect(html(icons, 'bold', 20)).toBe('') + expect(html(icons, 'italic')).toStartWith(' { + const seen: [IconName, number][] = [] + const icons: Icons = (name, { size }) => { + seen.push([name, size]) + if (name === 'undo') return {size} + if (name === 'redo') return null + if (name === 'zoomIn') return false + return undefined + } + expect(html(icons, 'undo', 16)).toContain('16') + expect(html(icons, 'redo')).toStartWith(' { + expect(hostIcon({ bold: '' }, 'bold', 18)).toBe('') + expect(hostIcon(() => 0, 'bold', 18)).toBe(0) + }) + + test('nested providers: inner first, then the enclosing one, then built-in', () => { + const out = (name: IconName) => + renderToStaticMarkup( + outer}> + inner }}> + + + , + ) + expect(out('bold')).toContain('inner') + expect(out('italic')).toContain('outer') + const none = renderToStaticMarkup( + outer }}> + + + + , + ) + expect(none).toStartWith(' Date: Sat, 14 Nov 2026 10:45:07 +0100 Subject: [PATCH 3/4] test(viewer): icon test names and the false fallback describe the code Co-Authored-By: Claude Opus 5.5 --- src/react/edit/Toolbar.tsx | 2 +- test/icons.test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/react/edit/Toolbar.tsx b/src/react/edit/Toolbar.tsx index 55ab133..55240ec 100644 --- a/src/react/edit/Toolbar.tsx +++ b/src/react/edit/Toolbar.tsx @@ -171,7 +171,7 @@ type Resolve = (name: IconName, size: number) => ReactNode | undefined const IconsContext = createContext(null) -/** the host's icon for `name`, or `undefined` for the built-in one */ +/** the host's icon for `name`, or `undefined` for the built-in one; `false` counts as none, as a resolver written `name === 'x' && ` returns it */ export function hostIcon(icons: Icons | undefined, name: IconName, size: number): ReactNode | undefined { if (!icons) return undefined const own = typeof icons === 'function' ? icons(name, { size }) : icons[name] diff --git a/test/icons.test.tsx b/test/icons.test.tsx index e2a30eb..9e61931 100644 --- a/test/icons.test.tsx +++ b/test/icons.test.tsx @@ -10,7 +10,7 @@ const html = (icons: Icons | undefined, name: IconName, size?: number) => ) describe('icons', () => { - test('names cover the controls that had text glyphs', () => { + test('names exist for zoom, outline, Present, add sheet and Replace', () => { for (const n of ['zoomIn', 'zoomOut', 'outline', 'present', 'sheetAdd', 'replace', 'close'] as const) expect(ICON_NAMES).toContain(n) }) From 3fe433bd504e5978f613f4e2172d5c8736eadb32 Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 14 Nov 2026 10:52:13 +0100 Subject: [PATCH 4/4] test(viewer): the PPTX toolbar has fewer icons with nothing selected Co-Authored-By: Claude Opus 5.5 --- e2e/icons.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/icons.spec.ts b/e2e/icons.spec.ts index 14892ee..2b50a9c 100644 --- a/e2e/icons.spec.ts +++ b/e2e/icons.spec.ts @@ -54,9 +54,10 @@ describe('host icons', () => { expect(await v.page.locator('.de-present-btn').innerText()).toContain('Present') expect(await hostIcon(v, 'button[aria-label="Zoom in"]', 'zoomIn').count()).toBe(1) + // with nothing selected the PPTX toolbar shows only its slide and insert controls await v.edit() const bar = await icons(v, '.de-ebar') - expect(bar.host).toBeGreaterThan(20) + expect(bar.host).toBeGreaterThan(5) expect(bar.builtIn).toBe(0) expect(v.errors).toEqual([]) })