Skip to content
Open
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ import '@github/markdown-toolbar-element'

`<markdown-toolbar>` 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][].
Expand Down
66 changes: 66 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,62 @@ type Style = {
}

const styles = new WeakMap<Element, Style>()
const defaultTitles: Record<string, string> = {
'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: '## '},
Expand Down Expand Up @@ -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() {
Expand Down
172 changes: 172 additions & 0 deletions test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 =
'<span id="shadow-label" hidden aria-label="Fett"></span><md-bold aria-labelledby="shadow-label"></md-bold>'
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 = '<span hidden>Not a name</span>'
document.body.append(hiddenTextButton)
assert.equal(hiddenTextButton.getAttribute('title'), 'Bold')
document.body.removeChild(hiddenTextButton)

const ariaHiddenSvgButton = document.createElement('md-bold')
ariaHiddenSvgButton.innerHTML = '<svg aria-hidden="true"><title>Decorative</title></svg>'
document.body.append(ariaHiddenSvgButton)
assert.equal(ariaHiddenSvgButton.getAttribute('title'), 'Bold')

const ariaHiddenTextButton = document.createElement('md-bold')
ariaHiddenTextButton.innerHTML = '<span aria-hidden="true">Decorative</span>'
document.body.append(ariaHiddenTextButton)
assert.equal(ariaHiddenTextButton.getAttribute('title'), 'Bold')

const cssHiddenTextButton = document.createElement('md-bold')
cssHiddenTextButton.innerHTML =
'<span style="display:none">Decorative</span><span style="visibility:hidden">Decorative</span>'
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 = '<span aria-hidden="true">Decorative</span>'
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 = '<img alt="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 = `
<script>
class ParserButton extends HTMLElement {
connectedCallback() {
parent.MarkdownBoldButtonElement.prototype.connectedCallback.call(this)
}
}
customElements.define('md-bold', ParserButton)
document.write('<md-bold id="parser-name">Fett</md-bold>')
</script>`
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 () {
Expand Down