Skip to content

Commit ff220a9

Browse files
authored
feat(toolbar): auto-derive font-option style from label/key (SD-2611) (#2874)
* feat(toolbar): auto-derive font-option style from label/key (SD-2611) 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. * docs(toolbar): align fonts API docs and FontConfig type with runtime behavior 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.
1 parent df44933 commit ff220a9

5 files changed

Lines changed: 147 additions & 9 deletions

File tree

apps/docs/modules/toolbar/built-in.mdx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -328,21 +328,24 @@ The toolbar dropdown lists only what you register in `fonts`. Imported documents
328328
<ParamField path="fonts" type="Array">
329329
<Expandable title="Font object properties">
330330
<ParamField path="label" type="string" required>
331-
Display name shown in the dropdown
331+
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.
332332
</ParamField>
333333
<ParamField path="key" type="string" required>
334-
Font-family CSS value applied to text
334+
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'`).
335335
</ParamField>
336336
<ParamField path="fontWeight" type="number">
337337
Font weight
338338
</ParamField>
339+
<ParamField path="props" type="object">
340+
Optional. Overrides per-row rendering. Use `props.style.fontFamily` to preview the row in a different font than `key`.
341+
</ParamField>
339342
</Expandable>
340343
</ParamField>
341344

342345
```javascript
343346
fonts: [
344-
{ label: 'Arial', key: 'Arial' },
345-
{ label: 'Times', key: 'Times New Roman' },
347+
{ label: 'Arial', key: 'Arial, sans-serif' },
348+
{ label: 'Times New Roman', key: 'Times New Roman, serif' },
346349
{ label: 'Brand Font', key: 'BrandFont, sans-serif', fontWeight: 400 }
347350
]
348351
```

packages/super-editor/src/editors/v1/components/toolbar/defaultItems.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { h, ref } from 'vue';
22

33
import { sanitizeNumber } from './helpers';
4+
import { normalizeFontOption } from './helpers/font-options.js';
45
import { useToolbarItem } from './use-toolbar-item';
56
import AIWriter from './AIWriter.vue';
67
import AlignmentButtons from './AlignmentButtons.vue';
@@ -44,7 +45,7 @@ export const makeDefaultItems = ({
4445
});
4546

4647
// font
47-
const fontOptions = [...(toolbarFonts ? toolbarFonts : TOOLBAR_FONTS)];
48+
const fontOptions = (toolbarFonts ?? TOOLBAR_FONTS).map(normalizeFontOption);
4849
const fontButton = useToolbarItem({
4950
type: 'dropdown',
5051
name: 'fontFamily',
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Normalize a font dropdown option so each row renders in its own typeface.
3+
*
4+
* Consumers can pass a minimal `{ label, key }` shape. The toolbar dropdown
5+
* spreads `option.props` onto each rendered `<li>`, so without an inline
6+
* `style.fontFamily` every row inherits the toolbar's UI font and the
7+
* dropdown loses its visual font preview. This fills that in from
8+
* `props.style.fontFamily` → `key` → `label` and keeps the existing
9+
* `data-item` hook that e2e selectors rely on.
10+
*
11+
* Idempotent: if `props.style.fontFamily` and `data-item` are already set,
12+
* the option is returned with those values preserved.
13+
*/
14+
export const normalizeFontOption = (option) => {
15+
if (!option) return option;
16+
const fontFamily = option.props?.style?.fontFamily ?? option.key ?? option.label;
17+
return {
18+
...option,
19+
props: {
20+
...option.props,
21+
style: {
22+
...option.props?.style,
23+
fontFamily,
24+
},
25+
'data-item': option.props?.['data-item'] ?? 'btn-fontFamily-option',
26+
},
27+
};
28+
};
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { makeDefaultItems } from '../defaultItems.js';
4+
import { normalizeFontOption } from './font-options.js';
5+
6+
describe('normalizeFontOption', () => {
7+
it('derives props.style.fontFamily from key when props are missing', () => {
8+
const result = normalizeFontOption({ label: 'Cambria', key: 'Cambria, serif' });
9+
expect(result.props.style.fontFamily).toBe('Cambria, serif');
10+
expect(result.props['data-item']).toBe('btn-fontFamily-option');
11+
});
12+
13+
it('falls back to label when key is absent', () => {
14+
const result = normalizeFontOption({ label: 'Aptos' });
15+
expect(result.props.style.fontFamily).toBe('Aptos');
16+
});
17+
18+
it('preserves an explicitly-set props.style.fontFamily', () => {
19+
const result = normalizeFontOption({
20+
label: 'Calibri',
21+
key: 'Calibri',
22+
props: { style: { fontFamily: 'Calibri, sans-serif' } },
23+
});
24+
expect(result.props.style.fontFamily).toBe('Calibri, sans-serif');
25+
});
26+
27+
it('preserves an explicitly-set data-item attribute', () => {
28+
const result = normalizeFontOption({
29+
label: 'Arial',
30+
key: 'Arial',
31+
props: { 'data-item': 'custom-hook' },
32+
});
33+
expect(result.props['data-item']).toBe('custom-hook');
34+
});
35+
36+
it('does not lose unrelated option properties or props', () => {
37+
const result = normalizeFontOption({
38+
label: 'Georgia',
39+
key: 'Georgia, serif',
40+
fontWeight: 400,
41+
props: { style: { color: 'red' }, 'data-custom': 'x' },
42+
});
43+
expect(result.fontWeight).toBe(400);
44+
expect(result.props.style.color).toBe('red');
45+
expect(result.props.style.fontFamily).toBe('Georgia, serif');
46+
expect(result.props['data-custom']).toBe('x');
47+
});
48+
49+
it('is idempotent', () => {
50+
const input = { label: 'Verdana', key: 'Verdana, sans-serif' };
51+
const once = normalizeFontOption(input);
52+
const twice = normalizeFontOption(once);
53+
expect(twice).toEqual(once);
54+
});
55+
56+
it('passes nullish entries through without throwing', () => {
57+
expect(normalizeFontOption(null)).toBeNull();
58+
expect(normalizeFontOption(undefined)).toBeUndefined();
59+
});
60+
});
61+
62+
describe('makeDefaultItems font wiring', () => {
63+
const stubProxy = new Proxy(
64+
{},
65+
{
66+
get: () => 'stub',
67+
},
68+
);
69+
const superToolbar = {
70+
config: { mode: 'docx' },
71+
activeEditor: null,
72+
emitCommand: () => {},
73+
};
74+
75+
it('normalizes custom fonts passed via toolbarFonts', () => {
76+
const { defaultItems, overflowItems } = makeDefaultItems({
77+
superToolbar,
78+
toolbarIcons: stubProxy,
79+
toolbarTexts: stubProxy,
80+
toolbarFonts: [{ label: 'Inter', key: 'Inter, sans-serif' }],
81+
hideButtons: false,
82+
availableWidth: Infinity,
83+
});
84+
85+
const allItems = [...defaultItems, ...overflowItems];
86+
const fontItem = allItems.find((i) => i.name.value === 'fontFamily');
87+
expect(fontItem).toBeDefined();
88+
89+
const inter = fontItem.nestedOptions.value.find((o) => o.label === 'Inter');
90+
expect(inter.props.style.fontFamily).toBe('Inter, sans-serif');
91+
expect(inter.props['data-item']).toBe('btn-fontFamily-option');
92+
});
93+
});

packages/super-editor/src/editors/v1/core/types/EditorConfig.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,20 +88,33 @@ export type LinkPopoverResolver = (ctx: LinkPopoverContext) => LinkPopoverResolu
8888
* Configuration for a font option in the toolbar font picker.
8989
*
9090
* Each entry represents a selectable font that appears in the toolbar dropdown.
91-
* The `props.style.fontFamily` value is applied to text when the font is selected.
91+
* `label` is the value applied to the selected text and used for active-state
92+
* matching, so it must equal the first family name in `key`. `key` is the
93+
* stable option identity and drives the row's preview `font-family` so each
94+
* row renders in its own typeface.
9295
*/
9396
export interface FontConfig {
94-
/** Unique key identifying this font */
97+
/**
98+
* Stable identity for the option. Used as the preview font-family for the
99+
* dropdown row. Typically a full CSS stack (e.g. `'Cambria, serif'`).
100+
*/
95101
key: string;
96-
/** Display label shown in the font picker dropdown */
102+
/**
103+
* Display name shown in the dropdown, and the value applied to the selected
104+
* text. Must match the first family name in `key` for active-state tracking.
105+
*/
97106
label: string;
98107
/** Font weight (e.g. 400 for normal, 700 for bold) */
99108
fontWeight?: number;
100-
/** CSS properties applied when this font is selected */
109+
/**
110+
* Optional per-row render overrides. `props.style.fontFamily` overrides the
111+
* row's preview font independently of `key`.
112+
*/
101113
props?: {
102114
style?: {
103115
fontFamily?: string;
104116
};
117+
'data-item'?: string;
105118
};
106119
}
107120

0 commit comments

Comments
 (0)