diff --git a/demo/scripts/controlsV2/demoButtons/tableTitleButton.ts b/demo/scripts/controlsV2/demoButtons/tableTitleButton.ts new file mode 100644 index 00000000000..8632bb1fcc2 --- /dev/null +++ b/demo/scripts/controlsV2/demoButtons/tableTitleButton.ts @@ -0,0 +1,56 @@ +import { getFirstSelectedTable, mutateBlock } from 'roosterjs-content-model-dom'; +import { IEditor } from 'roosterjs-content-model-types'; +import { RibbonButton, showInputDialog } from 'roosterjs-react'; + +/** + * @internal + * "Image Border Style" button on the format ribbon + */ +export const tableTitleButton: RibbonButton<'buttonNameTableTitle'> = { + key: 'buttonNameTableTitle', + unlocalizedText: 'Table Title', + iconName: 'TableComputed', + isDisabled: formatState => !formatState.isInTable, + onClick: (editor, _, strings, uiUtilities) => { + const items = { + title: { + autoFocus: true, + labelKey: 'buttonNameTableTitle' as const, + unlocalizedLabel: 'Title', + initValue: '', + }, + }; + + showInputDialog( + uiUtilities, + 'buttonNameTableTitle', + 'Insert Table', + items, + strings, + (itemName, newValue, values) => { + if (itemName == 'title') { + values.title = newValue; + return values; + } else { + return null; + } + } + ).then(result => { + editor.focus(); + if (result && result.title) { + insertTableTitle(editor, result.title); + } + }); + }, +}; + +const insertTableTitle = (editor: IEditor, title: string) => { + editor.formatContentModel(model => { + const table = getFirstSelectedTable(model)[0]; + if (table) { + mutateBlock(table).format.title = title; + return true; + } + return false; + }); +}; diff --git a/demo/scripts/controlsV2/tabs/ribbonButtons.ts b/demo/scripts/controlsV2/tabs/ribbonButtons.ts index 6911f0aa9c5..af669dffd00 100644 --- a/demo/scripts/controlsV2/tabs/ribbonButtons.ts +++ b/demo/scripts/controlsV2/tabs/ribbonButtons.ts @@ -21,6 +21,7 @@ import { tableBorderColorButton } from '../demoButtons/tableBorderColorButton'; import { tableBorderStyleButton } from '../demoButtons/tableBorderStyleButton'; import { tableBorderWidthButton } from '../demoButtons/tableBorderWidthButton'; import { tableOptionsButton } from '../demoButtons/tableOptionsButton'; +import { tableTitleButton } from '../demoButtons/tableTitleButton'; import { tabNames } from './getTabs'; import { tableAlignCellButton, @@ -83,6 +84,7 @@ const tableButtons: RibbonButton[] = [ insertTableButton, formatTableButton, setTableCellShadeButton, + tableTitleButton, tableOptionsButton, tableInsertButton, tableDeleteButton, @@ -178,6 +180,7 @@ const allButtons: RibbonButton[] = [ tableDeleteButton, tableMergeButton, tableSplitButton, + tableTitleButton, tableAlignCellButton, tableAlignTableButton, tableBorderApplyButton, diff --git a/packages/roosterjs-content-model-dom/lib/formatHandlers/common/ariaFormatHandler.ts b/packages/roosterjs-content-model-dom/lib/formatHandlers/common/ariaFormatHandler.ts new file mode 100644 index 00000000000..a612375bbdc --- /dev/null +++ b/packages/roosterjs-content-model-dom/lib/formatHandlers/common/ariaFormatHandler.ts @@ -0,0 +1,26 @@ +import type { AriaFormat } from 'roosterjs-content-model-types'; +import type { FormatHandler } from '../FormatHandler'; + +/** + * @internal + */ +export const ariaFormatHandler: FormatHandler = { + parse: (format, element) => { + const ariaDescribedBy = element.getAttribute('aria-describedby'); + const title = element.getAttribute('title'); + if (ariaDescribedBy) { + format.ariaDescribedBy = ariaDescribedBy; + } + if (title) { + format.title = title; + } + }, + apply: (format, element) => { + if (format.ariaDescribedBy) { + element.setAttribute('aria-describedby', format.ariaDescribedBy); + } + if (format.title) { + element.setAttribute('title', format.title); + } + }, +}; diff --git a/packages/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts b/packages/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts index 4d2ac993d31..d90edb198cf 100644 --- a/packages/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts +++ b/packages/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts @@ -1,3 +1,4 @@ +import { ariaFormatHandler } from './common/ariaFormatHandler'; import { backgroundColorFormatHandler } from './common/backgroundColorFormatHandler'; import { boldFormatHandler } from './segment/boldFormatHandler'; import { borderBoxFormatHandler } from './common/borderBoxFormatHandler'; @@ -51,6 +52,7 @@ type FormatHandlers = { }; const defaultFormatHandlerMap: FormatHandlers = { + aria: ariaFormatHandler, backgroundColor: backgroundColorFormatHandler, bold: boldFormatHandler, border: borderFormatHandler, @@ -162,6 +164,7 @@ export const defaultFormatKeysPerCategory: { tableRow: ['backgroundColor'], tableColumn: ['size'], table: [ + 'aria', 'id', 'border', 'backgroundColor', diff --git a/packages/roosterjs-content-model-dom/test/formatHandlers/common/ariaFormatHandlerTest.ts b/packages/roosterjs-content-model-dom/test/formatHandlers/common/ariaFormatHandlerTest.ts new file mode 100644 index 00000000000..e7412e82601 --- /dev/null +++ b/packages/roosterjs-content-model-dom/test/formatHandlers/common/ariaFormatHandlerTest.ts @@ -0,0 +1,81 @@ +import { AriaFormat, DomToModelContext, ModelToDomContext } from 'roosterjs-content-model-types'; +import { ariaFormatHandler } from '../../../lib/formatHandlers/common/ariaFormatHandler'; +import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; +import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; + +describe('ariaFormatHandler.parse', () => { + let div: HTMLElement; + let format: AriaFormat; + let context: DomToModelContext; + + beforeEach(() => { + div = document.createElement('div'); + format = {}; + context = createDomToModelContext(); + }); + + it('No title and describedby', () => { + ariaFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({}); + }); + + it('has title and describedby', () => { + div.setAttribute('title', 'test'); + div.setAttribute('aria-describedby', 'test'); + ariaFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({ + title: 'test', + ariaDescribedBy: 'test', + }); + }); + + it('has title and no describedby', () => { + div.setAttribute('title', 'test'); + ariaFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({ + title: 'test', + }); + }); + + it('no title and has describedby', () => { + div.setAttribute('aria-describedby', 'test'); + ariaFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({ ariaDescribedBy: 'test' }); + }); +}); + +describe('idFormatHandler.apply', () => { + let div: HTMLElement; + let format: AriaFormat; + let context: ModelToDomContext; + + beforeEach(() => { + div = document.createElement('div'); + format = {}; + context = createModelToDomContext(); + }); + + it('No title and no describedby', () => { + ariaFormatHandler.apply(format, div, context); + expect(div.outerHTML).toBe('
'); + }); + + it('Has title and has describedby', () => { + format.title = 'test'; + format.ariaDescribedBy = 'test'; + ariaFormatHandler.apply(format, div, context); + expect(div.outerHTML).toBe('
'); + }); + + it('No title and has describedby', () => { + format.ariaDescribedBy = 'test'; + ariaFormatHandler.apply(format, div, context); + expect(div.outerHTML).toBe('
'); + }); + + it('Has title and no describedby', () => { + format.title = 'test'; + ariaFormatHandler.apply(format, div, context); + expect(div.outerHTML).toBe('
'); + }); +}); diff --git a/packages/roosterjs-content-model-types/lib/contentModel/format/ContentModelTableFormat.ts b/packages/roosterjs-content-model-types/lib/contentModel/format/ContentModelTableFormat.ts index 7d84b3fe376..fe9a27187e3 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/format/ContentModelTableFormat.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/format/ContentModelTableFormat.ts @@ -1,3 +1,4 @@ +import type { AriaFormat } from './formatParts/AriaFormat'; import type { BorderBoxFormat } from './formatParts/BorderBoxFormat'; import type { BorderFormat } from './formatParts/BorderFormat'; import type { ContentModelBlockFormat } from './ContentModelBlockFormat'; @@ -13,6 +14,7 @@ import type { SizeFormat } from './formatParts/SizeFormat'; */ export type ContentModelTableFormat = ContentModelBlockFormat & IdFormat & + AriaFormat & BorderFormat & BorderBoxFormat & SpacingFormat & diff --git a/packages/roosterjs-content-model-types/lib/contentModel/format/FormatHandlerTypeMap.ts b/packages/roosterjs-content-model-types/lib/contentModel/format/FormatHandlerTypeMap.ts index d8c851be670..463b5001a36 100644 --- a/packages/roosterjs-content-model-types/lib/contentModel/format/FormatHandlerTypeMap.ts +++ b/packages/roosterjs-content-model-types/lib/contentModel/format/FormatHandlerTypeMap.ts @@ -1,3 +1,4 @@ +import type { AriaFormat } from './formatParts/AriaFormat'; import type { BackgroundColorFormat } from './formatParts/BackgroundColorFormat'; import type { BoldFormat } from './formatParts/BoldFormat'; import type { BorderBoxFormat } from './formatParts/BorderBoxFormat'; @@ -37,6 +38,11 @@ import type { WordBreakFormat } from './formatParts/WordBreakFormat'; * Represents a record of all format handlers */ export interface FormatHandlerTypeMap { + /** + * Format for AriaFormat + */ + aria: AriaFormat; + /** * Format for BackgroundColorFormat */ diff --git a/packages/roosterjs-content-model-types/lib/contentModel/format/formatParts/AriaFormat.ts b/packages/roosterjs-content-model-types/lib/contentModel/format/formatParts/AriaFormat.ts new file mode 100644 index 00000000000..b30c08e0243 --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/contentModel/format/formatParts/AriaFormat.ts @@ -0,0 +1,14 @@ +/** + * Format of background color + */ +export type AriaFormat = { + /** + * Aria-describedby attribute + */ + ariaDescribedBy?: string; + + /** + * Title attribute + */ + title?: string; +}; diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index 06720540376..a2ddd2d4fe3 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -23,6 +23,7 @@ export { ContentModelImageFormat } from './contentModel/format/ContentModelImage export { ContentModelEntityFormat } from './contentModel/format/ContentModelEntityFormat'; export { FormatHandlerTypeMap, FormatKey } from './contentModel/format/FormatHandlerTypeMap'; +export { AriaFormat } from './contentModel/format/formatParts/AriaFormat'; export { BackgroundColorFormat } from './contentModel/format/formatParts/BackgroundColorFormat'; export { BoldFormat } from './contentModel/format/formatParts/BoldFormat'; export { FontFamilyFormat } from './contentModel/format/formatParts/FontFamilyFormat';