-
-
Save renoirb/c14050700e634099646823abead68c8f to your computer and use it in GitHub Desktop.
aria utilities
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// Creates a menu button that opens a menu of links | |
// | |
// TODO continue refactoring WAI's example for reusability | |
// | |
// Source: https://www.w3.org/TR/wai-aria-practices/examples/menu-button/menu-button-links.html | |
// | |
export interface IFocusBy<T> { | |
readonly firstChars: ReadonlySet<string> | |
setFocus(by: T) | |
} | |
export const isPrintableCharacter = (str: string): boolean => { | |
const isTextualString = str.match(/\S/) !== null | |
return str.length === 1 && isTextualString; | |
} | |
export class FocusByKey implements IFocusBy<string> { | |
private _host: HTMLElement | |
get firstChars(): ReadonlySet<string> { | |
const data: ReadonlySet<string> = new Set([...this._firstChars.keys()]) | |
return data | |
} | |
private _firstChars = new Set<string>() | |
constructor(domNode: HTMLElement) { | |
this._host = domNode | |
var nodes: NodeListOf<HTMLElement> = domNode.querySelectorAll('[role="menuitem"]'); | |
for (var i = 0; i < nodes.length; i++) { | |
var menuitem = nodes[i]; | |
const fc = menuitem.textContent.trim()[0].toLowerCase() | |
this._firstChars.add(fc); | |
menuitem.dataset.focusKey = fc | |
} | |
} | |
setFocus(by: string) { | |
if (this.firstChars.has(by)) { | |
const detail = { | |
eventName: 'key', | |
data: by, | |
} | |
const event = new CustomEvent('focus-by', { bubbles: true, composed: true, cancelable: true, detail }) | |
this._host.dispatchEvent(event) | |
} | |
} | |
} | |
Utility: take an ECMAScript class, get its name, return slugified version of its name
Implementation
// file: class-name.ts
export const appendDashToCapitalizedLetter = (letter: string, index: number): string => {
const isFirstCharacter = index === 0
const isCapitalized = /[A-Z]/.test(letter)
const outcome = isFirstCharacter === false && isCapitalized === true ? `-${letter}` : letter
return outcome
}
/**
* Make kebab-case a PascalCasedString (e.g. FooBar => foo-bar)
*
* @public
* {@see extractClassNameFromComponent}
*
* @example
* ```yaml
* For example:
* - input: MyAppListViewComponent
* steps:
* - ListView
* - List-View
* - list-view
* - input: MyAppSearchResultsListViewComponent
* steps:
* - SearchResultsListView
* - Search-Results-List-View
* - search-results-list-view
* ```
*/
export const kebabCaseFromPascalCase = (input: string): string =>
input
.replace(/(myapp|component)/gi, '' /* remove repetitive words common in component object class names */)
.split('')
.map(appendDashToCapitalizedLetter)
.join('')
.toLowerCase()
/**
* Transform an ECMAScript class, extract its name and transform into a CSS class name string.
*
* @public
* {@see kebabCaseFromPascalCase}
*
* @example
* ```ts
* class MySpecialViewComponent {
* // Should become my-special-view
* example: string = ''
* constructor() {
* this.example = extractClassNameFromComponent(this)
* }
* }
* ```
*/
export const extractClassNameFromComponent = (input: { readonly constructor: { readonly name: string } }): string => {
const message = `Invalid input we expected a constructor function`
if (!Reflect.has(input, 'constructor')) {
throw new Error(message)
}
const name = Reflect.has(input?.constructor, 'name') ? Reflect.get(input?.constructor, 'name') : ''
if (name !== '') {
return kebabCaseFromPascalCase(name)
}
throw new Error('Unexpected argument ' + message)
}
Tests
// file: class-name.spec.ts
import { kebabCaseFromPascalCase, extractClassNameFromComponent } from './class-name'
interface DataModel {
readonly constructorName: string
}
abstract class BaseConstructor implements DataModel {
readonly constructorName = this.constructor.name
}
describe('common/helpers/class-name', () => {
describe('kebabCaseFromPascalCase', () => {
it.each`
input | asExpected
${'FooBar'} | ${'foo-bar'}
${'fooBar'} | ${'foo-bar'}
${'FooBarB'} | ${'foo-bar-b'}
${'\tFoo\tBar\t'} | ${'\t-foo\t-bar\t' /* because we do not transform */}
`('Input "$input" should return "$asExpected"', ({ input, asExpected }: Record<string, string>) => {
const subject = kebabCaseFromPascalCase(input)
expect(subject).toBe(asExpected)
})
})
describe('extractClassNameFromComponent', () => {
it.each`
SubjectedComponent | expectedConstructorName | asExpected
${class MyAppFooBarComponent extends BaseConstructor {}} | ${'MyAppFooBarComponent'} | ${'foo-bar'}
${class myAppFooBar extends BaseConstructor {}} | ${'myAppFooBar'} | ${'foo-bar'}
${class MyappFooBar extends BaseConstructor {}} | ${'MyappFooBar'} | ${'foo-bar'}
${class FooBarComponent extends BaseConstructor {}} | ${'FooBarComponent'} | ${'foo-bar'}
${class FooBar extends BaseConstructor {}} | ${'FooBar'} | ${'foo-bar'}
`('Should return "$asExpected" as string', ({ SubjectedComponent, expectedConstructorName, asExpected }) => {
const subject = new SubjectedComponent()
expect(subject).toHaveProperty('constructorName', expectedConstructorName)
const normalizedCssClassName = extractClassNameFromComponent(subject)
expect(normalizedCssClassName).toBe(asExpected)
})
// // UNFINISHED maybe there's a simpler way
// describe('When we pass invalid arguments', () => {
// it('Passing null', () => {
// // @ts-nocheck
// expect(() => extractClassNameFromComponent(null)).toThrow(/Reflect.has called on non-object/)
// })
// it('Passing some random function', () => {
// // @ts-nocheck
// expect(() => extractClassNameFromComponent(() => 1)).toThrow(/wat/)
// })
// })
})
})
Screen Reader Only
import { html, LitElement, css, property } from 'lit-element'
import { classMap } from 'lit-html/directives/class-map'
import { customElement } from '../custom-element'
import { t } from '../directives/translate'
const TAG_NAME = 'screen-reader-only-span'
@customElement(TAG_NAME)
export class ScreenReaderOnlySpan extends LitElement {
/**
* Bookmark:
* - https://gist.github.com/ffoodd/000b59f431e3e64e4ce1a24d5bb36034
*/
static styles = [
/**
* Improved screen reader only CSS class
* @author Gaël Poupard
* @note Based on Yahoo!'s technique
* @author Thierry Koblentz
* @see https://developer.yahoo.com/blogs/ydn/clip-hidden-content-better-accessibility-53456.html
* * 1.
* @note `clip` is deprecated but works everywhere
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/clip
* * 2.
* @note `clip-path` is the future-proof version, but not very well supported yet
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/clip-path
* @see http://caniuse.com/#search=clip-path
* @author Yvain Liechti
* @see https://twitter.com/ryuran78/status/778943389819604992
* * 3.
* @note preventing text to be condensed
* author J. Renée Beach
* @see https://medium.com/@jessebeach/beware-smushed-off-screen-accessible-text-5952a4c2cbfe
* @note Drupal 8 goes with word-wrap: normal instead
* @see https://www.drupal.org/node/2045151
* @see http://cgit.drupalcode.org/drupal/commit/?id=5b847ea
* * 4.
* @note !important is important
* @note Obviously you wanna hide something
* @author Harry Roberts
* @see https://csswizardry.com/2016/05/the-importance-of-important/
**/
css`
.sr-only {
border: 0 !important;
clip: rect(1px, 1px, 1px, 1px) !important; /* 1 */
-webkit-clip-path: inset(50%) !important;
clip-path: inset(50%) !important; /* 2 */
height: 1px !important;
margin: -1px !important;
overflow: hidden !important;
padding: 0 !important;
position: absolute !important;
width: 1px !important;
white-space: nowrap !important; /* 3 */
}
`,
/**
* Use in conjunction with .sr-only to only display content when it's focused.
* @note Useful for skip links
* @see http://www.w3.org/TR/2013/NOTE-WCAG20-TECHS-20130905/G1
* @note Based on a HTML5 Boilerplate technique, included in Bootstrap
* @note Fixed a bug with position: static on iOS 10.0.2 + VoiceOver
* @author Sylvain Pigeard
* @see https://github.com/twbs/bootstrap/issues/20732
*/
css`
.sr-only-focusable:focus,
.sr-only-focusable:active {
clip: auto !important;
-webkit-clip-path: none !important;
clip-path: none !important;
height: auto !important;
margin: auto !important;
overflow: visible !important;
width: auto !important;
white-space: normal !important;
}
`,
]
@property({ type: String, attribute: 'data-translate-from' }) translateFrom = ''
@property({ type: Boolean, attribute: 'data-focusable' }) isFocusable = false
render() {
return html`<!-- -->
<span
class=${classMap({
'sr-only': true,
'sr-only-focusable': this.isFocusable,
})}
>
${t(this.translateFrom)}
</span>
<!-- -->`
}
}
declare global {
interface HTMLElementTagNameMap {
readonly [TAG_NAME]: ScreenReaderOnlySpan
}
}
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
TODO review notes
DOM Utilities in context of CustomElement and Slots