diff --git a/README.md b/README.md index e2a2716..bb4cad4 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,10 @@ 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 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. + ## Browser support Browsers without native [custom element support][support] require a [polyfill][]. diff --git a/src/index.ts b/src/index.ts index 73e088a..d403305 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,6 +83,62 @@ 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' +} + +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: '## '}, @@ -134,6 +190,16 @@ class MarkdownButtonElement extends HTMLElement { if (!this.hasAttribute('role')) { this.setAttribute('role', 'button') } + 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 944e174..86cb8c9 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,162 @@ 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'), '') + }) + + 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'], 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 ' + 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') + + 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) + }) }) describe('in shadow DOM', function () {