From 50fbb9053e6125e9aa1fddab9ef5c660981b33da Mon Sep 17 00:00:00 2001 From: Ehsan-hash Date: Thu, 15 Oct 2026 04:01:35 -0700 Subject: [PATCH 1/4] Give formatting buttons fallback accessible names --- README.md | 2 ++ src/index.ts | 19 +++++++++++++++++++ test/test.js | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/README.md b/README.md index e2a2716..3246dea 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ import '@github/markdown-toolbar-element' `` comes with focus management as advised in [WAI-ARIA Authoring Practices 1.1: Toolbar Design Pattern](https://www.w3.org/TR/wai-aria-practices-1.1/examples/toolbar/toolbar.html). The `md-*` buttons that ship with this package are automatically managed. Add a `data-md-button` attribute to any custom toolbar items to enroll them into focus management. +Built-in buttons receive an English default `title` when the attribute is absent. This provides a fallback accessible name and tooltip. Visible text, `aria-label`, and `aria-labelledby` still determine the accessible name. Set `title` to localize the tooltip, or `title=""` to suppress it. + ## Browser support Browsers without native [custom element support][support] require a [polyfill][]. diff --git a/src/index.ts b/src/index.ts index 73e088a..2385e76 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,6 +83,21 @@ type Style = { } const styles = new WeakMap() +const defaultTitles: Record = { + 'md-header': 'Heading', + 'md-bold': 'Bold', + 'md-italic': 'Italic', + 'md-quote': 'Quote', + 'md-code': 'Code', + 'md-link': 'Link', + 'md-image': 'Image', + 'md-unordered-list': 'Unordered list', + 'md-ordered-list': 'Ordered list', + 'md-task-list': 'Task list', + 'md-mention': 'Mention', + 'md-ref': 'Reference', + 'md-strikethrough': 'Strikethrough' +} const manualStyles = { 'header-1': {prefix: '# '}, 'header-2': {prefix: '## '}, @@ -134,6 +149,10 @@ class MarkdownButtonElement extends HTMLElement { if (!this.hasAttribute('role')) { this.setAttribute('role', 'button') } + if (!this.hasAttribute('title')) { + const title = defaultTitles[this.localName] + if (title) this.setAttribute('title', title) + } } click() { diff --git a/test/test.js b/test/test.js index 944e174..78d6fec 100644 --- a/test/test.js +++ b/test/test.js @@ -32,6 +32,22 @@ describe('markdown-toolbar-element', function () { 'md-strikethrough' ] + const defaultTitles = { + 'md-header': 'Heading', + 'md-bold': 'Bold', + 'md-italic': 'Italic', + 'md-quote': 'Quote', + 'md-code': 'Code', + 'md-link': 'Link', + 'md-image': 'Image', + 'md-unordered-list': 'Unordered list', + 'md-ordered-list': 'Ordered list', + 'md-task-list': 'Task list', + 'md-mention': 'Mention', + 'md-ref': 'Reference', + 'md-strikethrough': 'Strikethrough' + } + for (const tag of buttonTags) { it(`sets role="button" on <${tag}> when connected`, function () { const el = document.createElement(tag) @@ -46,6 +62,30 @@ describe('markdown-toolbar-element', function () { assert.equal(el.getAttribute('role'), 'none') }) } + + it('sets descriptive default titles on built-in buttons', function () { + for (const [tag, title] of Object.entries(defaultTitles)) { + const el = document.createElement(tag) + document.body.append(el) + assert.equal(el.getAttribute('title'), title) + } + }) + + it('preserves an explicit title when a button reconnects', function () { + const el = document.createElement('md-bold') + el.setAttribute('title', 'Emphasis') + document.body.append(el) + document.body.removeChild(el) + document.body.append(el) + assert.equal(el.getAttribute('title'), 'Emphasis') + }) + + it('preserves an explicit empty title', function () { + const el = document.createElement('md-bold') + el.setAttribute('title', '') + document.body.append(el) + assert.equal(el.getAttribute('title'), '') + }) }) describe('in shadow DOM', function () { From e5f76194ec047e8bf38932a4f455363ff013fd06 Mon Sep 17 00:00:00 2001 From: Ehsan-hash Date: Thu, 15 Oct 2026 11:33:12 -0700 Subject: [PATCH 2/4] docs: clarify default title accessibility and tooltip effects --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3246dea..07e5888 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,9 @@ import '@github/markdown-toolbar-element' `` comes with focus management as advised in [WAI-ARIA Authoring Practices 1.1: Toolbar Design Pattern](https://www.w3.org/TR/wai-aria-practices-1.1/examples/toolbar/toolbar.html). The `md-*` buttons that ship with this package are automatically managed. Add a `data-md-button` attribute to any custom toolbar items to enroll them into focus management. -Built-in buttons receive an English default `title` when the attribute is absent. This provides a fallback accessible name and tooltip. Visible text, `aria-label`, and `aria-labelledby` still determine the accessible name. Set `title` to localize the tooltip, or `title=""` to suppress it. +Built-in buttons receive an English default `title` when the attribute is absent. This provides a fallback accessible name and a native tooltip. Visible text, `aria-label`, and `aria-labelledby` still determine the accessible name, but the title can also be exposed as an accessible description. A localized ARIA name does not suppress the English title. Set `title` to localize it, or `title=""` to suppress it when providing a custom tooltip. + +Setting `title=""` also removes the fallback accessible name. An icon-only button must then have another accessible name, supplied through visible text, `aria-label`, or `aria-labelledby`. Applications with custom tooltips should suppress the native title to avoid displaying both tooltips. ## Browser support From d64eb6b66625db7b981fffd13504ab9454f52da8 Mon Sep 17 00:00:00 2001 From: Ehsan-hash Date: Thu, 15 Oct 2026 14:23:21 -0700 Subject: [PATCH 3/4] Limit fallback titles to unnamed formatting buttons --- README.md | 2 +- src/index.ts | 9 ++++++++- test/test.js | 43 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 07e5888..b3cf9e6 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ import '@github/markdown-toolbar-element' `` comes with focus management as advised in [WAI-ARIA Authoring Practices 1.1: Toolbar Design Pattern](https://www.w3.org/TR/wai-aria-practices-1.1/examples/toolbar/toolbar.html). The `md-*` buttons that ship with this package are automatically managed. Add a `data-md-button` attribute to any custom toolbar items to enroll them into focus management. -Built-in buttons receive an English default `title` when the attribute is absent. This provides a fallback accessible name and a native tooltip. Visible text, `aria-label`, and `aria-labelledby` still determine the accessible name, but the title can also be exposed as an accessible description. A localized ARIA name does not suppress the English title. Set `title` to localize it, or `title=""` to suppress it when providing a custom tooltip. +When built-in buttons connect, they receive an English default `title` only when the attribute is absent and their rendered visible text, `aria-label`, and `aria-labelledby` values are empty or whitespace. This provides a fallback accessible name and a native tooltip for icon-only buttons. Supply text or ARIA names before inserting the button; later changes do not remove an existing title. Set `title` to localize it, or `title=""` to suppress it when providing a custom tooltip. Setting `title=""` also removes the fallback accessible name. An icon-only button must then have another accessible name, supplied through visible text, `aria-label`, or `aria-labelledby`. Applications with custom tooltips should suppress the native title to avoid displaying both tooltips. diff --git a/src/index.ts b/src/index.ts index 2385e76..b18e394 100644 --- a/src/index.ts +++ b/src/index.ts @@ -149,7 +149,14 @@ class MarkdownButtonElement extends HTMLElement { if (!this.hasAttribute('role')) { this.setAttribute('role', 'button') } - if (!this.hasAttribute('title')) { + const hasAccessibleName = [ + // innerText excludes hidden descendants from rendered button text. + // eslint-disable-next-line github/no-innerText + this.innerText, + this.getAttribute('aria-label'), + this.getAttribute('aria-labelledby') + ].some(value => value?.trim()) + if (!this.hasAttribute('title') && !hasAccessibleName) { const title = defaultTitles[this.localName] if (title) this.setAttribute('title', title) } diff --git a/test/test.js b/test/test.js index 78d6fec..516600e 100644 --- a/test/test.js +++ b/test/test.js @@ -86,6 +86,49 @@ describe('markdown-toolbar-element', function () { document.body.append(el) assert.equal(el.getAttribute('title'), '') }) + + it('does not add a default title when a button has an accessible name', function () { + const namedButtons = [ + {textContent: 'Bold'}, + {attribute: ['aria-label', 'Emphasis']}, + {attribute: ['aria-labelledby', 'button-label']} + ] + + for (const namedButton of namedButtons) { + const el = document.createElement('md-bold') + if (namedButton.textContent) el.textContent = namedButton.textContent + if (namedButton.attribute) el.setAttribute(...namedButton.attribute) + document.body.append(el) + assert.isNull(el.getAttribute('title')) + document.body.removeChild(el) + } + }) + + it('adds a default title when supplied names contain only whitespace', function () { + const textButton = document.createElement('md-bold') + textButton.textContent = ' \n ' + document.body.append(textButton) + assert.equal(textButton.getAttribute('title'), 'Bold') + document.body.removeChild(textButton) + + const ariaButton = document.createElement('md-bold') + ariaButton.setAttribute('aria-label', ' ') + document.body.append(ariaButton) + assert.equal(ariaButton.getAttribute('title'), 'Bold') + }) + + it('ignores hidden descendant content when deciding whether text is visible', function () { + const hiddenTextButton = document.createElement('md-bold') + hiddenTextButton.innerHTML = '' + document.body.append(hiddenTextButton) + assert.equal(hiddenTextButton.getAttribute('title'), 'Bold') + document.body.removeChild(hiddenTextButton) + + const ariaHiddenSvgButton = document.createElement('md-bold') + ariaHiddenSvgButton.innerHTML = '' + document.body.append(ariaHiddenSvgButton) + assert.equal(ariaHiddenSvgButton.getAttribute('title'), 'Bold') + }) }) describe('in shadow DOM', function () { From 806508ce26c45f4c61c355f4b6649569509fe4bc Mon Sep 17 00:00:00 2001 From: Ehsan-hash Date: Fri, 16 Oct 2026 11:58:45 -0700 Subject: [PATCH 4/4] fix: preserve initial formatting button names during parsing --- README.md | 2 +- src/index.ts | 56 +++++++++++++++++++++++++++----- test/test.js | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 139 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b3cf9e6..bb4cad4 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ import '@github/markdown-toolbar-element' `` comes with focus management as advised in [WAI-ARIA Authoring Practices 1.1: Toolbar Design Pattern](https://www.w3.org/TR/wai-aria-practices-1.1/examples/toolbar/toolbar.html). The `md-*` buttons that ship with this package are automatically managed. Add a `data-md-button` attribute to any custom toolbar items to enroll them into focus management. -When built-in buttons connect, they receive an English default `title` only when the attribute is absent and their rendered visible text, `aria-label`, and `aria-labelledby` values are empty or whitespace. This provides a fallback accessible name and a native tooltip for icon-only buttons. Supply text or ARIA names before inserting the button; later changes do not remove an existing title. Set `title` to localize it, or `title=""` to suppress it when providing a custom tooltip. +When built-in buttons connect, they receive an English default `title` only when the attribute is absent and the button has no meaningful text, image `alt`, `aria-label`, or `aria-labelledby` reference. Label references are resolved in the button's document or shadow root and must point to content. Hidden or `aria-hidden` descendants do not count as ordinary button text. During document loading, parser-created children are checked after the document finishes loading. This provides a fallback accessible name and a native tooltip for icon-only buttons. Supply text or ARIA names before inserting the button; later changes do not remove an existing title. Set `title` to localize it, or `title=""` to suppress it when providing a custom tooltip. Setting `title=""` also removes the fallback accessible name. An icon-only button must then have another accessible name, supplied through visible text, `aria-label`, or `aria-labelledby`. Applications with custom tooltips should suppress the native title to avoid displaying both tooltips. diff --git a/src/index.ts b/src/index.ts index b18e394..d403305 100644 --- a/src/index.ts +++ b/src/index.ts @@ -98,6 +98,47 @@ const defaultTitles: Record = { 'md-ref': 'Reference', 'md-strikethrough': 'Strikethrough' } + +function hasNamingContent(element: Element, ignoreHidden: boolean, allowHiddenRoot = false, isRoot = true): boolean { + const style = element.ownerDocument.defaultView?.getComputedStyle(element) + const hiddenByStyle = !isRoot && (style?.display === 'none' || style?.visibility === 'hidden') + if ( + ignoreHidden && + ((!allowHiddenRoot && + (element.hasAttribute('hidden') || element.getAttribute('aria-hidden')?.toLowerCase() === 'true')) || + hiddenByStyle) + ) { + return false + } + if (element.getAttribute('aria-label')?.trim()) return true + if (element.localName === 'img') { + return Boolean(element.getAttribute('alt')?.trim()) + } + for (const child of element.childNodes) { + if (child.nodeType === 3 && child.textContent?.trim()) return true + if (child.nodeType === 1 && hasNamingContent(child as Element, ignoreHidden, false, false)) return true + } + return false +} + +function getReferencedElement(element: Element, id: string): Element | null { + const root = element.getRootNode() + if (root.nodeType !== 9 && root.nodeType !== 11) return null + return (root as Document).getElementById(id) +} + +function hasAccessibleName(element: Element): boolean { + if (element.getAttribute('aria-label')?.trim()) return true + const labelledBy = element.getAttribute('aria-labelledby')?.trim() + if (labelledBy) { + for (const id of labelledBy.split(/\s+/)) { + const label = getReferencedElement(element, id) + if (label && hasNamingContent(label, true, true)) return true + } + } + return hasNamingContent(element, true, true) +} + const manualStyles = { 'header-1': {prefix: '# '}, 'header-2': {prefix: '## '}, @@ -149,17 +190,16 @@ class MarkdownButtonElement extends HTMLElement { if (!this.hasAttribute('role')) { this.setAttribute('role', 'button') } - const hasAccessibleName = [ - // innerText excludes hidden descendants from rendered button text. - // eslint-disable-next-line github/no-innerText - this.innerText, - this.getAttribute('aria-label'), - this.getAttribute('aria-labelledby') - ].some(value => value?.trim()) - if (!this.hasAttribute('title') && !hasAccessibleName) { + const setDefaultTitle = () => { + if (!this.isConnected || this.hasAttribute('title') || hasAccessibleName(this)) return const title = defaultTitles[this.localName] if (title) this.setAttribute('title', title) } + if (this.ownerDocument.readyState === 'loading') { + this.ownerDocument.addEventListener('DOMContentLoaded', setDefaultTitle, {once: true}) + } else { + setDefaultTitle() + } } click() { diff --git a/test/test.js b/test/test.js index 516600e..86cb8c9 100644 --- a/test/test.js +++ b/test/test.js @@ -91,19 +91,42 @@ describe('markdown-toolbar-element', function () { const namedButtons = [ {textContent: 'Bold'}, {attribute: ['aria-label', 'Emphasis']}, - {attribute: ['aria-labelledby', 'button-label']} + {attribute: ['aria-labelledby', 'button-label'], labelText: 'Emphasis'} ] for (const namedButton of namedButtons) { const el = document.createElement('md-bold') if (namedButton.textContent) el.textContent = namedButton.textContent if (namedButton.attribute) el.setAttribute(...namedButton.attribute) + if (namedButton.labelText) { + const label = document.createElement('span') + label.id = 'button-label' + label.textContent = namedButton.labelText + document.body.append(label) + } document.body.append(el) assert.isNull(el.getAttribute('title')) document.body.removeChild(el) + document.querySelector('#button-label')?.remove() } }) + it('requires a referenced label to contain naming content', function () { + const button = document.createElement('md-bold') + button.setAttribute('aria-labelledby', 'missing-label') + document.body.append(button) + assert.equal(button.getAttribute('title'), 'Bold') + }) + + it('resolves referenced labels in the button root', function () { + const host = document.createElement('div') + const shadow = host.attachShadow({mode: 'open'}) + shadow.innerHTML = + '' + document.body.append(host) + assert.isNull(shadow.querySelector('md-bold').getAttribute('title')) + }) + it('adds a default title when supplied names contain only whitespace', function () { const textButton = document.createElement('md-bold') textButton.textContent = ' \n ' @@ -128,6 +151,72 @@ describe('markdown-toolbar-element', function () { ariaHiddenSvgButton.innerHTML = '' document.body.append(ariaHiddenSvgButton) assert.equal(ariaHiddenSvgButton.getAttribute('title'), 'Bold') + + const ariaHiddenTextButton = document.createElement('md-bold') + ariaHiddenTextButton.innerHTML = '' + document.body.append(ariaHiddenTextButton) + assert.equal(ariaHiddenTextButton.getAttribute('title'), 'Bold') + + const cssHiddenTextButton = document.createElement('md-bold') + cssHiddenTextButton.innerHTML = + 'DecorativeDecorative' + document.body.append(cssHiddenTextButton) + assert.equal(cssHiddenTextButton.getAttribute('title'), 'Bold') + }) + + it('ignores hidden decoration when the host starts display:none', function () { + const button = document.createElement('md-bold') + button.style.display = 'none' + button.innerHTML = '' + document.body.append(button) + button.style.display = '' + assert.equal(button.getAttribute('title'), 'Bold') + }) + + it('keeps text naming when a hidden host is revealed', function () { + const hiddenButton = document.createElement('md-bold') + hiddenButton.hidden = true + hiddenButton.textContent = 'Gras' + document.body.append(hiddenButton) + hiddenButton.hidden = false + assert.isNull(hiddenButton.getAttribute('title')) + + const ariaHiddenButton = document.createElement('md-bold') + ariaHiddenButton.setAttribute('aria-hidden', 'true') + ariaHiddenButton.textContent = 'Gras' + document.body.append(ariaHiddenButton) + ariaHiddenButton.removeAttribute('aria-hidden') + assert.isNull(ariaHiddenButton.getAttribute('title')) + }) + + it('recognizes image alternative text as an accessible name', function () { + const button = document.createElement('md-bold') + button.innerHTML = 'Fett' + document.body.append(button) + assert.isNull(button.getAttribute('title')) + }) + + it('waits for parser-created children before deciding the fallback title', function (done) { + const frame = document.createElement('iframe') + frame.srcdoc = ` + ` + frame.addEventListener('load', function () { + const button = frame.contentDocument.querySelector('#parser-name') + if (!button) return + assert.equal(button.textContent, 'Fett') + assert.isNull(button.getAttribute('title')) + frame.remove() + done() + }) + document.body.append(frame) }) })