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/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');
+ });
+});
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;
};
}