From 801e65dcb75648269da50d69899ceac6ef04f1ca Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 17:10:02 -0300 Subject: [PATCH 1/2] feat(toolbar): auto-derive font-option style from label/key (SD-2611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumers passing { label, key } to modules.toolbar.fonts got a dropdown where every row rendered in the toolbar's UI font — rows only render in their own typeface when each option carries props.style.fontFamily, and the API silently expected callers to know that shape. This adds a small normalizer that fills in props.style.fontFamily (from key or label) and data-item, so the minimal { label, key } shape just works. --- .../v1/components/toolbar/defaultItems.js | 3 +- .../toolbar/helpers/font-options.js | 28 ++++++ .../toolbar/helpers/font-options.test.js | 93 +++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 packages/super-editor/src/editors/v1/components/toolbar/helpers/font-options.js create mode 100644 packages/super-editor/src/editors/v1/components/toolbar/helpers/font-options.test.js diff --git a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js index a16e4b00dc..e627e9115d 100644 --- a/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js +++ b/packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js @@ -1,6 +1,7 @@ import { h, ref } from 'vue'; import { sanitizeNumber } from './helpers'; +import { normalizeFontOption } from './helpers/font-options.js'; import { useToolbarItem } from './use-toolbar-item'; import AIWriter from './AIWriter.vue'; import AlignmentButtons from './AlignmentButtons.vue'; @@ -44,7 +45,7 @@ export const makeDefaultItems = ({ }); // font - const fontOptions = [...(toolbarFonts ? toolbarFonts : TOOLBAR_FONTS)]; + const fontOptions = (toolbarFonts ?? TOOLBAR_FONTS).map(normalizeFontOption); const fontButton = useToolbarItem({ type: 'dropdown', name: 'fontFamily', diff --git a/packages/super-editor/src/editors/v1/components/toolbar/helpers/font-options.js b/packages/super-editor/src/editors/v1/components/toolbar/helpers/font-options.js new file mode 100644 index 0000000000..6a3e938b99 --- /dev/null +++ b/packages/super-editor/src/editors/v1/components/toolbar/helpers/font-options.js @@ -0,0 +1,28 @@ +/** + * Normalize a font dropdown option so each row renders in its own typeface. + * + * Consumers can pass a minimal `{ label, key }` shape. The toolbar dropdown + * spreads `option.props` onto each rendered `
  • `, so without an inline + * `style.fontFamily` every row inherits the toolbar's UI font and the + * dropdown loses its visual font preview. This fills that in from + * `props.style.fontFamily` → `key` → `label` and keeps the existing + * `data-item` hook that e2e selectors rely on. + * + * Idempotent: if `props.style.fontFamily` and `data-item` are already set, + * the option is returned with those values preserved. + */ +export const normalizeFontOption = (option) => { + if (!option) return option; + const fontFamily = option.props?.style?.fontFamily ?? option.key ?? option.label; + return { + ...option, + props: { + ...option.props, + style: { + ...option.props?.style, + fontFamily, + }, + 'data-item': option.props?.['data-item'] ?? 'btn-fontFamily-option', + }, + }; +}; diff --git a/packages/super-editor/src/editors/v1/components/toolbar/helpers/font-options.test.js b/packages/super-editor/src/editors/v1/components/toolbar/helpers/font-options.test.js new file mode 100644 index 0000000000..d15c6f95c6 --- /dev/null +++ b/packages/super-editor/src/editors/v1/components/toolbar/helpers/font-options.test.js @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; + +import { makeDefaultItems } from '../defaultItems.js'; +import { normalizeFontOption } from './font-options.js'; + +describe('normalizeFontOption', () => { + it('derives props.style.fontFamily from key when props are missing', () => { + const result = normalizeFontOption({ label: 'Cambria', key: 'Cambria, serif' }); + expect(result.props.style.fontFamily).toBe('Cambria, serif'); + expect(result.props['data-item']).toBe('btn-fontFamily-option'); + }); + + it('falls back to label when key is absent', () => { + const result = normalizeFontOption({ label: 'Aptos' }); + expect(result.props.style.fontFamily).toBe('Aptos'); + }); + + it('preserves an explicitly-set props.style.fontFamily', () => { + const result = normalizeFontOption({ + label: 'Calibri', + key: 'Calibri', + props: { style: { fontFamily: 'Calibri, sans-serif' } }, + }); + expect(result.props.style.fontFamily).toBe('Calibri, sans-serif'); + }); + + it('preserves an explicitly-set data-item attribute', () => { + const result = normalizeFontOption({ + label: 'Arial', + key: 'Arial', + props: { 'data-item': 'custom-hook' }, + }); + expect(result.props['data-item']).toBe('custom-hook'); + }); + + it('does not lose unrelated option properties or props', () => { + const result = normalizeFontOption({ + label: 'Georgia', + key: 'Georgia, serif', + fontWeight: 400, + props: { style: { color: 'red' }, 'data-custom': 'x' }, + }); + expect(result.fontWeight).toBe(400); + expect(result.props.style.color).toBe('red'); + expect(result.props.style.fontFamily).toBe('Georgia, serif'); + expect(result.props['data-custom']).toBe('x'); + }); + + it('is idempotent', () => { + const input = { label: 'Verdana', key: 'Verdana, sans-serif' }; + const once = normalizeFontOption(input); + const twice = normalizeFontOption(once); + expect(twice).toEqual(once); + }); + + it('passes nullish entries through without throwing', () => { + expect(normalizeFontOption(null)).toBeNull(); + expect(normalizeFontOption(undefined)).toBeUndefined(); + }); +}); + +describe('makeDefaultItems font wiring', () => { + const stubProxy = new Proxy( + {}, + { + get: () => 'stub', + }, + ); + const superToolbar = { + config: { mode: 'docx' }, + activeEditor: null, + emitCommand: () => {}, + }; + + it('normalizes custom fonts passed via toolbarFonts', () => { + const { defaultItems, overflowItems } = makeDefaultItems({ + superToolbar, + toolbarIcons: stubProxy, + toolbarTexts: stubProxy, + toolbarFonts: [{ label: 'Inter', key: 'Inter, sans-serif' }], + hideButtons: false, + availableWidth: Infinity, + }); + + const allItems = [...defaultItems, ...overflowItems]; + const fontItem = allItems.find((i) => i.name.value === 'fontFamily'); + expect(fontItem).toBeDefined(); + + const inter = fontItem.nestedOptions.value.find((o) => o.label === 'Inter'); + expect(inter.props.style.fontFamily).toBe('Inter, sans-serif'); + expect(inter.props['data-item']).toBe('btn-fontFamily-option'); + }); +}); From deaa898a7d73e062737460a94de65df3fa6a2817 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 20 Apr 2026 17:29:12 -0300 Subject: [PATCH 2/2] docs(toolbar): align fonts API docs and FontConfig type with runtime behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs said `key` is 'font-family CSS value applied to text', but the runtime applies `option.label` — the font dropdown doesn't set `dropdownValueKey`, so ButtonGroup falls back to `option.label` when emitting setFontFamily. The sample `{ label: 'Times', key: 'Times New Roman' }` also violated the active-state matcher (which compares `label` to the first segment of the document's font-family), so the 'active' chip never lit up. Rewrites the field descriptions to match what each value actually does, fixes the example to follow the label-equals-first-key-segment convention, and surfaces the optional `props.style.fontFamily` preview override. --- apps/docs/modules/toolbar/built-in.mdx | 11 ++++++---- .../src/editors/v1/core/types/EditorConfig.ts | 21 +++++++++++++++---- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/apps/docs/modules/toolbar/built-in.mdx b/apps/docs/modules/toolbar/built-in.mdx index b99b12b8ed..2485f9d7a5 100644 --- a/apps/docs/modules/toolbar/built-in.mdx +++ b/apps/docs/modules/toolbar/built-in.mdx @@ -328,21 +328,24 @@ The toolbar dropdown lists only what you register in `fonts`. Imported documents - Display name shown in the dropdown + Display name shown in the dropdown, and the value applied to the selected text. Must match the first family name in `key` — otherwise the dropdown won't light up the current font when the cursor is inside a run that uses it. - Font-family CSS value applied to text + Stable identity for the option. Also used as the row's preview `font-family` so each row renders in its own typeface. Typically a full CSS stack (e.g. `'Cambria, serif'`). Font weight + + Optional. Overrides per-row rendering. Use `props.style.fontFamily` to preview the row in a different font than `key`. + ```javascript fonts: [ - { label: 'Arial', key: 'Arial' }, - { label: 'Times', key: 'Times New Roman' }, + { label: 'Arial', key: 'Arial, sans-serif' }, + { label: 'Times New Roman', key: 'Times New Roman, serif' }, { label: 'Brand Font', key: 'BrandFont, sans-serif', fontWeight: 400 } ] ``` diff --git a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts index 82c6be5ba1..97b75f527b 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorConfig.ts @@ -88,20 +88,33 @@ export type LinkPopoverResolver = (ctx: LinkPopoverContext) => LinkPopoverResolu * Configuration for a font option in the toolbar font picker. * * Each entry represents a selectable font that appears in the toolbar dropdown. - * The `props.style.fontFamily` value is applied to text when the font is selected. + * `label` is the value applied to the selected text and used for active-state + * matching, so it must equal the first family name in `key`. `key` is the + * stable option identity and drives the row's preview `font-family` so each + * row renders in its own typeface. */ export interface FontConfig { - /** Unique key identifying this font */ + /** + * Stable identity for the option. Used as the preview font-family for the + * dropdown row. Typically a full CSS stack (e.g. `'Cambria, serif'`). + */ key: string; - /** Display label shown in the font picker dropdown */ + /** + * Display name shown in the dropdown, and the value applied to the selected + * text. Must match the first family name in `key` for active-state tracking. + */ label: string; /** Font weight (e.g. 400 for normal, 700 for bold) */ fontWeight?: number; - /** CSS properties applied when this font is selected */ + /** + * Optional per-row render overrides. `props.style.fontFamily` overrides the + * row's preview font independently of `key`. + */ props?: { style?: { fontFamily?: string; }; + 'data-item'?: string; }; }