Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { defineComponent, ref, KeepAlive } from 'vue';
import { defineComponent, ref, nextTick, KeepAlive } from 'vue';
import { EventEmitter } from 'eventemitter3';
import Toolbar from './Toolbar.vue';

const ToolbarKeepAliveHost = defineComponent({
Expand All @@ -12,8 +13,10 @@
template: '<KeepAlive><Toolbar v-if="visible" /></KeepAlive>',
});

// The real SuperToolbar is an EventEmitter; model that so Toolbar.vue can subscribe to
// `toolbar-items-changed` (and tests can dispatch it).
function createMockToolbar() {
return {
return Object.assign(new EventEmitter(), {
config: {
toolbarGroups: ['left', 'center', 'right'],
toolbarButtonsExclude: [],
Expand All @@ -26,7 +29,7 @@
emitCommand: vi.fn(),
overflowItems: [],
activeEditor: null,
};
});
}

describe('Toolbar', () => {
Expand Down Expand Up @@ -203,4 +206,45 @@

expect(disconnect).toHaveBeenCalledTimes(1);
});

it('re-renders the toolbar DOM when SuperToolbar reports rebuilt items (toolbar-items-changed)', async () => {
// toolbarItems is a plain field SuperToolbar swaps on rebuild; only a re-render re-reads it. The mock
// returns whatever `center.items` currently holds, so a "rebuild" is just reassigning that array.
const center = { items: [{ name: { value: 'fontFamily' } }] };
const mockToolbar = createMockToolbar();
mockToolbar.config.toolbarGroups = ['center']; // render only the center group: one unambiguous ButtonGroup
mockToolbar.getToolbarItemByGroup = (position) => (position === 'center' ? center.items : []);

const ButtonGroupStub = defineComponent({
props: ['toolbarItems', 'overflowItems', 'compactSideGroups', 'uiFontFamily', 'position'],
template:
'<div><span class="bg-item" v-for="i in toolbarItems" :key="i.name.value">{{ i.name.value }}</span></div>',
});

const wrapper = mount(Toolbar, {
global: {
stubs: { ButtonGroup: ButtonGroupStub },
plugins: [
(app) => {
app.config.globalProperties.$toolbar = mockToolbar;
},
],
},
});

const renderedItems = () => wrapper.findAll('.bg-item').map((w) => w.text());
expect(renderedItems()).toEqual(['fontFamily']);

// A rebuild swaps in a new array (e.g. a document font resolved). The swap alone is not reactive...
center.items = [{ name: { value: 'fontFamily' } }, { name: { value: 'Aptos' } }];
await nextTick();
expect(renderedItems()).toEqual(['fontFamily']); // ...so the DOM still shows the previously-built items.

Check failure on line 241 in packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js

View workflow job for this annotation

GitHub Actions / unit-tests (super-editor-2, 2/4)

src/editors/v1/components/toolbar/Toolbar.test.js > Toolbar > re-renders the toolbar DOM when SuperToolbar reports rebuilt items (toolbar-items-changed)

AssertionError: expected [ 'fontFamily', 'Aptos' ] to deeply equal [ 'fontFamily' ] - Expected + Received [ "fontFamily", + "Aptos", ] ❯ src/editors/v1/components/toolbar/Toolbar.test.js:241:29

Check failure on line 241 in packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js

View workflow job for this annotation

GitHub Actions / unit-tests (super-editor-2, 2/4)

src/editors/v1/components/toolbar/Toolbar.test.js > Toolbar > re-renders the toolbar DOM when SuperToolbar reports rebuilt items (toolbar-items-changed)

AssertionError: expected [ 'fontFamily', 'Aptos' ] to deeply equal [ 'fontFamily' ] - Expected + Received [ "fontFamily", + "Aptos", ] ❯ src/editors/v1/components/toolbar/Toolbar.test.js:241:29

Check failure on line 241 in packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js

View workflow job for this annotation

GitHub Actions / unit-tests (super-editor-2, 2/4)

src/editors/v1/components/toolbar/Toolbar.test.js > Toolbar > re-renders the toolbar DOM when SuperToolbar reports rebuilt items (toolbar-items-changed)

AssertionError: expected [ 'fontFamily', 'Aptos' ] to deeply equal [ 'fontFamily' ] - Expected + Received [ "fontFamily", + "Aptos", ] ❯ src/editors/v1/components/toolbar/Toolbar.test.js:241:29

// The notify event forces the re-render that re-reads the rebuilt array - the actual fix.
mockToolbar.emit('toolbar-items-changed');
await nextTick();
expect(renderedItems()).toEqual(['fontFamily', 'Aptos']);

wrapper.unmount();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,29 @@ const onWindowResized = async () => {
};
const onResizeThrottled = throttle(onWindowResized, 300);

function teardownWindowListeners() {
/**
* Force a re-render when the toolbar's item arrays are rebuilt. `toolbarItems` / `overflowItems` are plain
* fields on the SuperToolbar instance, not a reactive source this component tracks, so a rebuild (a new
* active editor, or document fonts resolving via `fonts-changed`) is invisible until the render key changes.
* SuperToolbar emits `toolbar-items-changed` on rebuild; bumping the key re-reads the new items into the DOM.
*/
const onToolbarItemsChanged = () => {
toolbarKey.value += 1;
};

function teardownListeners() {
window.removeEventListener('resize', onResizeThrottled);
window.removeEventListener('keydown', onKeyDown);
proxy.$toolbar.off?.('toolbar-items-changed', onToolbarItemsChanged);
containerResizeObserver?.disconnect();
containerResizeObserver = null;
}

function setupWindowListeners() {
teardownWindowListeners();
function setupListeners() {
teardownListeners();
window.addEventListener('resize', onResizeThrottled);
window.addEventListener('keydown', onKeyDown);
proxy.$toolbar.on?.('toolbar-items-changed', onToolbarItemsChanged);
if (
typeof ResizeObserver !== 'undefined' &&
proxy.$toolbar.config?.responsiveToContainer &&
Expand All @@ -100,10 +112,10 @@ function setupWindowListeners() {
updateCompactSideGroups();
}

onMounted(setupWindowListeners);
onActivated(setupWindowListeners);
onDeactivated(teardownWindowListeners);
onBeforeUnmount(teardownWindowListeners);
onMounted(setupListeners);
onActivated(setupListeners);
onDeactivated(teardownListeners);
onBeforeUnmount(teardownListeners);

const handleCommand = ({ item, argument, option }) => {
proxy.$toolbar.emitCommand({ item, argument, option });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,42 @@ describe('ToolbarDropdown keyboard focus', () => {
expect(document.activeElement).toBe(trigger.element);
});
});

describe('ToolbarDropdown secondary label (document font support status)', () => {
it('renders the status separately from the pure label and folds it into the accessible name', async () => {
const Harness = defineComponent({
components: { ToolbarDropdown },
setup() {
const show = ref(false);
const options = [
{ key: 'Calibri', label: 'Calibri', props: {} }, // a plain default
{ key: 'Aptos', label: 'Aptos', secondaryLabel: 'Needs font', props: {} }, // a document font
];
return { options, show };
},
template: `
<ToolbarDropdown v-model:show="show" :options="options">
<template #trigger><button data-test="trigger" type="button">Font family</button></template>
</ToolbarDropdown>
`,
});

wrapper = mount(Harness, { attachTo: document.body });
wrapper.vm.show = true;
await nextTick();
await nextTick();

const options = document.body.querySelectorAll('.toolbar-dropdown-option');
expect(options).toHaveLength(2);

// Default: label only, no status span, no extra aria name.
expect(options[0].querySelector('.toolbar-dropdown-option__label').textContent.trim()).toBe('Calibri');
expect(options[0].querySelector('.toolbar-dropdown-option__secondary')).toBeNull();

// Document font: label stays pure, the status is a separate visible span AND in the accessible name.
const aptos = options[1];
expect(aptos.querySelector('.toolbar-dropdown-option__label').textContent.trim()).toBe('Aptos');
expect(aptos.querySelector('.toolbar-dropdown-option__secondary').textContent.trim()).toBe('Needs font');
expect(aptos.getAttribute('aria-label')).toBe('Aptos Needs font');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ const onOptionClick = (option) => {
const isRenderOption = (option) => option?.type === 'render';
const isOptionNavigable = (option) => !option?.disabled && option?.type !== 'render';
const hasIcon = (option) => typeof option?.icon === 'function' || Boolean(option?.icon);
// Fold the visible status (secondaryLabel) into the option's accessible name so screen readers don't
// miss it, while option.label stays pure for active-state matching. Undefined leaves the default name.
const ariaLabelFor = (option) => (option?.secondaryLabel ? `${option.label} ${option.secondaryLabel}` : undefined);
const renderIcon = (option) => {
if (typeof option?.icon === 'function') return option.icon(option);
return option?.icon || null;
Expand Down Expand Up @@ -403,13 +406,17 @@ onBeforeUnmount(() => {
tabindex="-1"
@click="onOptionClick(option)"
v-bind="{ ...option.props, ...getNodeProps(option) }"
:aria-label="ariaLabelFor(option)"
>
<RenderOption v-if="isRenderOption(option)" :option="option" />
<template v-else>
<span v-if="hasIcon(option)" class="toolbar-dropdown-option__icon">
<OptionIcon :option="option" />
</span>
<span class="toolbar-dropdown-option__label">{{ option.label }}</span>
<span v-if="option.secondaryLabel" class="toolbar-dropdown-option__secondary">{{
option.secondaryLabel
}}</span>
</template>
</div>
</div>
Expand Down Expand Up @@ -470,6 +477,15 @@ onBeforeUnmount(() => {
height: 12px;
}

/* Secondary annotation (e.g. a document font's support status). Pushed right, muted, never the label. */
.toolbar-dropdown-option__secondary {
margin-left: auto;
padding-left: 12px;
font-size: var(--sd-ui-font-size-300, 12px);
color: var(--sd-ui-dropdown-text-muted, #8a8b8d);
white-space: nowrap;
}

.toolbar-dropdown-option:hover {
background: var(--sd-ui-dropdown-hover-bg, #d8dee5);
color: var(--sd-ui-dropdown-hover-text, #47484a);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { getDefaultFontOfferings, fontOfferingStack, fontOfferingRenderStack } from '@superdoc/font-system';
import {
getDefaultFontOfferings,
fontOfferingStack,
fontOfferingRenderStack,
fontSupportStatusText,
} from '@superdoc/font-system';

/**
* Built-in toolbar font dropdown options, DERIVED from the shared font-offering registry
Expand All @@ -20,6 +25,47 @@ export const TOOLBAR_FONTS = getDefaultFontOfferings().map((offering) => ({
},
}));

/**
* The single seam that composes the font dropdown options: it turns the active document's
* {@link import('@superdoc/font-system').DocumentFontOption}s into toolbar font options and unions them
* with the bundled defaults. The toolbar only asks for the result; it does not know how a status becomes
* a `secondaryLabel`, or how a font previews.
*
* - A consumer-provided `configFonts` list is returned UNCHANGED (custom toolbars own their list).
* - With no document options, returns `undefined` so the caller keeps its fallback to {@link TOOLBAR_FONTS}.
* - Otherwise: the bundled defaults FIRST, then the document's own fonts appended, deduped by normalized
* logical family. `label`/`key` stay the pure logical family (active-state matching + the stored value),
* the preview renders in `previewFamily`, and a `secondaryLabel` is added only for a non-`available`
* status (`fontSupportStatusText` returns '' for available, so faithful fonts read as plain names).
*
* @param {ReadonlyArray<import('@superdoc/font-system').DocumentFontOption>} documentOptions
* @param {Array} [configFonts] - the consumer's `fonts` config, if any
* @returns {Array|undefined}
*/
export function composeToolbarFontOptions(documentOptions, configFonts) {
if (configFonts) return configFonts;
if (!documentOptions?.length) return undefined;
const seen = new Set(TOOLBAR_FONTS.map((option) => String(option.label).trim().toLowerCase()));
const appended = [];
for (const option of documentOptions) {
const dedupeKey = option.logicalFamily.trim().toLowerCase();
if (seen.has(dedupeKey)) continue; // already a bundled default (e.g. Calibri) -> not duplicated
seen.add(dedupeKey);
const statusText = fontSupportStatusText(option.status);
appended.push({
label: option.logicalFamily, // pure logical name: stored / exported + active-state matched
key: option.logicalFamily, // the logical family applied to the selection
fontWeight: 400,
...(statusText ? { secondaryLabel: statusText } : {}),
props: {
style: { fontFamily: option.previewFamily || option.logicalFamily }, // preview in what paints
'data-item': 'btn-fontFamily-option',
},
});
}
return appended.length ? [...TOOLBAR_FONTS, ...appended] : undefined;
}

export const TOOLBAR_FONT_SIZES = [
{ label: '8', key: '8pt', props: { 'data-item': 'btn-fontSize-option' } },
{ label: '9', key: '9pt', props: { 'data-item': 'btn-fontSize-option' } },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { describe, it, expect } from 'vitest';
import { TOOLBAR_FONTS } from './constants';
import { TOOLBAR_FONTS, composeToolbarFontOptions } from './constants';

describe('TOOLBAR_FONTS (built-in font dropdown, derived from the font-offering registry)', () => {
it('advertises only the metric-safe bundled defaults, in order', () => {
expect(TOOLBAR_FONTS.map((f) => f.label)).toEqual(['Calibri', 'Arial', 'Courier New', 'Times New Roman', 'Helvetica']);
expect(TOOLBAR_FONTS.map((f) => f.label)).toEqual([
'Calibri',
'Arial',
'Courier New',
'Times New Roman',
'Helvetica',
]);
});

it('does not leak non-bundled or qualified fonts into the default dropdown', () => {
Expand Down Expand Up @@ -32,3 +38,47 @@ describe('TOOLBAR_FONTS (built-in font dropdown, derived from the font-offering
}
});
});

describe('composeToolbarFontOptions (document fonts unioned with the bundled defaults)', () => {
const doc = (logicalFamily, status, previewFamily) => ({
logicalFamily,
status,
previewFamily: previewFamily ?? logicalFamily,
});

it('returns a consumer-provided fonts list unchanged (custom toolbars own their list)', () => {
const custom = [{ label: 'My Font', key: 'My Font' }];
expect(composeToolbarFontOptions([doc('Aptos', 'needs_font')], custom)).toBe(custom);
});

it('returns undefined with no document fonts, so the caller keeps the bundled defaults', () => {
expect(composeToolbarFontOptions([], undefined)).toBeUndefined();
expect(composeToolbarFontOptions(undefined, undefined)).toBeUndefined();
});

it('puts defaults first, appends document fonts, and dedupes one already in the defaults', () => {
const options = composeToolbarFontOptions(
[doc('Calibri', 'available', 'Carlito'), doc('Aptos', 'needs_font'), doc('Georgia', 'pending')],
undefined,
);
// Defaults in their order, then the NON-default document fonts; Calibri (a default) is not duplicated.
expect(options.map((o) => o.label)).toEqual([...TOOLBAR_FONTS.map((f) => f.label), 'Aptos', 'Georgia']);
expect(options.filter((o) => o.label === 'Calibri')).toHaveLength(1);
});

it('maps a document font: pure logical label/key, preview in previewFamily, status as secondaryLabel', () => {
const options = composeToolbarFontOptions([doc('Aptos', 'needs_font', 'Aptos')], undefined);
expect(options.at(-1)).toMatchObject({
label: 'Aptos', // pure logical name (active-state match + the stored/exported value)
key: 'Aptos',
secondaryLabel: 'Needs font',
props: { style: { fontFamily: 'Aptos' }, 'data-item': 'btn-fontFamily-option' },
});
});

it('omits secondaryLabel for an available document font (it reads as a plain name)', () => {
const options = composeToolbarFontOptions([doc('BrandSans', 'available', 'BrandSans')], undefined);
expect(options.at(-1).label).toBe('BrandSans');
expect(options.at(-1).secondaryLabel).toBeUndefined();
});
});
Loading
Loading