├── web_modules └── import-map.json ├── legacy ├── assets │ └── legacy-logo.png ├── styles.css ├── GenericAlert.jsx ├── GenericVisuallyHidden.jsx ├── GenericSkiplink.jsx ├── GenericSpinner.jsx ├── GenericDialog.jsx ├── GenericDisclosure.jsx ├── GenericAccordion.jsx ├── GenericSwitch.jsx ├── GenericListbox.jsx ├── GenericRadio.jsx ├── GenericTabs.jsx └── index.html ├── generic-spinner ├── README.md ├── test │ └── generic-spinner.test.js ├── GenericSpinner.js └── demo │ └── index.html ├── tabs.js ├── alert.js ├── radio.js ├── dialog.js ├── switch.js ├── listbox.js ├── spinner.js ├── skiplink.js ├── accordion.js ├── disclosure.js ├── generic-alert ├── README.md ├── test │ └── generic-alert.test.js ├── GenericAlert.js └── demo │ └── index.html ├── generic-skiplink ├── README.md ├── skiplink.js ├── test │ └── generic-skiplink.test.js ├── GenericSkiplink.js ├── skiplink.css └── demo │ └── index.html ├── generic-switch ├── README.md ├── GenericSwitch.js └── test │ └── generic-switch.test.js ├── generic-tabs ├── README.md └── GenericTabs.js ├── generic-listbox ├── README.md ├── GenericListbox.js ├── test │ └── generic-listbox.test.js └── demo │ └── index.html ├── generic-radio ├── README.md └── GenericRadio.js ├── generic-accordion ├── README.md ├── GenericAccordion.js ├── test │ └── generic-accordion.test.js └── demo │ └── index.html ├── visually-hidden.js ├── generic-visually-hidden ├── README.md ├── visually-hidden.js ├── test │ └── generic-visually-hidden.test.js ├── GenericVisuallyHidden.js ├── visually-hidden.css └── demo │ └── index.html ├── utils ├── keycodes.js ├── EventTargetShim.js ├── visually-hidden.js ├── BatchingElement.js ├── test │ └── utils.test.js └── SelectedMixin.js ├── generic-dialog ├── README.md ├── GenericDialog.js ├── generic-dialog-overlay.js ├── dialog.js └── test │ └── generic-dialog.test.js ├── generic-disclosure ├── README.md ├── GenericDisclosure.js ├── test │ └── generic-disclosure.test.js └── demo │ └── index.html ├── .gitignore ├── .prettierignore ├── custom-elements-manifest.config.js ├── .github └── workflows │ └── test.yml ├── LICENSE ├── index.js ├── package.json ├── index.html ├── README.md ├── demo ├── styles.css ├── demo-app.css └── demo-app.html └── cem-plugin-reactify.js /web_modules/import-map.json: -------------------------------------------------------------------------------- 1 | { 2 | "imports": { 3 | "@a11y/focus-trap": "./@a11y/focus-trap.js" 4 | } 5 | } -------------------------------------------------------------------------------- /legacy/assets/legacy-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thepassle/generic-components/HEAD/legacy/assets/legacy-logo.png -------------------------------------------------------------------------------- /generic-spinner/README.md: -------------------------------------------------------------------------------- 1 | # generic-spinner 2 | 3 | [demo](https://genericcomponents.netlify.app/generic-spinner/demo/index.html) -------------------------------------------------------------------------------- /tabs.js: -------------------------------------------------------------------------------- 1 | import { GenericTabs } from './generic-tabs/GenericTabs.js'; 2 | 3 | customElements.define(GenericTabs.is, GenericTabs); 4 | -------------------------------------------------------------------------------- /legacy/styles.css: -------------------------------------------------------------------------------- 1 | #legacyLogo { 2 | margin-left: auto; 3 | margin-right: auto; 4 | width: 250px; 5 | display: block; 6 | } 7 | -------------------------------------------------------------------------------- /alert.js: -------------------------------------------------------------------------------- 1 | import { GenericAlert } from './generic-alert/GenericAlert.js'; 2 | 3 | customElements.define(GenericAlert.is, GenericAlert); 4 | -------------------------------------------------------------------------------- /radio.js: -------------------------------------------------------------------------------- 1 | import { GenericRadio } from './generic-radio/GenericRadio.js'; 2 | 3 | customElements.define(GenericRadio.is, GenericRadio); 4 | -------------------------------------------------------------------------------- /dialog.js: -------------------------------------------------------------------------------- 1 | import { GenericDialog } from './generic-dialog/GenericDialog.js'; 2 | 3 | customElements.define(GenericDialog.is, GenericDialog); 4 | -------------------------------------------------------------------------------- /switch.js: -------------------------------------------------------------------------------- 1 | import { GenericSwitch } from './generic-switch/GenericSwitch.js'; 2 | 3 | customElements.define(GenericSwitch.is, GenericSwitch); 4 | -------------------------------------------------------------------------------- /listbox.js: -------------------------------------------------------------------------------- 1 | import { GenericListbox } from './generic-listbox/GenericListbox.js'; 2 | 3 | customElements.define(GenericListbox.is, GenericListbox); 4 | -------------------------------------------------------------------------------- /spinner.js: -------------------------------------------------------------------------------- 1 | import { GenericSpinner } from './generic-spinner/GenericSpinner.js'; 2 | 3 | customElements.define(GenericSpinner.is, GenericSpinner); 4 | -------------------------------------------------------------------------------- /skiplink.js: -------------------------------------------------------------------------------- 1 | import { GenericSkiplink } from './generic-skiplink/GenericSkiplink.js'; 2 | 3 | customElements.define(GenericSkiplink.is, GenericSkiplink); 4 | -------------------------------------------------------------------------------- /accordion.js: -------------------------------------------------------------------------------- 1 | import { GenericAccordion } from './generic-accordion/GenericAccordion.js'; 2 | 3 | customElements.define(GenericAccordion.is, GenericAccordion); 4 | -------------------------------------------------------------------------------- /disclosure.js: -------------------------------------------------------------------------------- 1 | import { GenericDisclosure } from './generic-disclosure/GenericDisclosure.js'; 2 | 3 | customElements.define(GenericDisclosure.is, GenericDisclosure); 4 | -------------------------------------------------------------------------------- /generic-alert/README.md: -------------------------------------------------------------------------------- 1 | # generic-alert 2 | 3 | [demo](https://genericcomponents.netlify.app/generic-alert/demo/index.html) | [spec](https://www.w3.org/TR/wai-aria-practices/#alert) -------------------------------------------------------------------------------- /generic-skiplink/README.md: -------------------------------------------------------------------------------- 1 | # generic-skiplink 2 | 3 | [demo](https://genericcomponents.netlify.app/generic-skiplink/demo/index.html) | [spec](https://webaim.org/techniques/skipnav/) -------------------------------------------------------------------------------- /generic-switch/README.md: -------------------------------------------------------------------------------- 1 | # generic-switch 2 | 3 | [demo](https://genericcomponents.netlify.app/generic-switch/demo/index.html) | [spec](https://www.w3.org/TR/wai-aria-1.1/#switch) -------------------------------------------------------------------------------- /generic-tabs/README.md: -------------------------------------------------------------------------------- 1 | # generic-tabs 2 | 3 | [demo](https://genericcomponents.netlify.app/generic-tabs/demo/index.html) | [spec](https://www.w3.org/TR/wai-aria-practices/#tabpanel) 4 | -------------------------------------------------------------------------------- /generic-listbox/README.md: -------------------------------------------------------------------------------- 1 | # generic-listbox 2 | 3 | [demo](https://genericcomponents.netlify.app/generic-listbox/demo/index.html) | [spec](https://www.w3.org/TR/wai-aria-practices/#Listbox) -------------------------------------------------------------------------------- /generic-radio/README.md: -------------------------------------------------------------------------------- 1 | # generic-radio 2 | 3 | [demo](https://genericcomponents.netlify.app/generic-radio/demo/index.html) | [spec](https://www.w3.org/TR/wai-aria-practices-1.1/#radiobutton) -------------------------------------------------------------------------------- /generic-accordion/README.md: -------------------------------------------------------------------------------- 1 | # generic-accordion 2 | 3 | [demo](https://genericcomponents.netlify.app/generic-accordion/demo/index.html) | [spec](https://www.w3.org/TR/wai-aria-practices/#accordion) -------------------------------------------------------------------------------- /visually-hidden.js: -------------------------------------------------------------------------------- 1 | import { GenericVisuallyHidden } from './generic-visually-hidden/GenericVisuallyHidden.js'; 2 | 3 | customElements.define(GenericVisuallyHidden.is, GenericVisuallyHidden); 4 | -------------------------------------------------------------------------------- /generic-visually-hidden/README.md: -------------------------------------------------------------------------------- 1 | # generic-visually-hidden 2 | 3 | [demo](https://genericcomponents.netlify.app/generic-visually-hidden/demo/index.html) | [spec](https://webaim.org/techniques/css/invisiblecontent/) -------------------------------------------------------------------------------- /legacy/GenericAlert.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "@generic-components/components/alert.js"; 3 | 4 | export function GenericAlert({ children }) { 5 | return {children}; 6 | } 7 | -------------------------------------------------------------------------------- /utils/keycodes.js: -------------------------------------------------------------------------------- 1 | export const KEYCODES = { 2 | TAB: 9, 3 | ENTER: 13, 4 | SHIFT: 16, 5 | ESC: 27, 6 | SPACE: 32, 7 | END: 35, 8 | HOME: 36, 9 | LEFT: 37, 10 | UP: 38, 11 | RIGHT: 39, 12 | DOWN: 40, 13 | }; 14 | -------------------------------------------------------------------------------- /generic-dialog/README.md: -------------------------------------------------------------------------------- 1 | # generic-dialog 2 | 3 | [demo](https://genericcomponents.netlify.app/generic-dialog/demo/index.html) | [spec](https://www.w3.org/TR/wai-aria-practices/#dialog_modal) 4 | 5 | ## To do 6 | 7 | - Clean up imperative code -------------------------------------------------------------------------------- /generic-disclosure/README.md: -------------------------------------------------------------------------------- 1 | # generic-disclosure 2 | 3 | [demo](https://genericcomponents.netlify.app/generic-disclosure/demo/index.html) | [spec](https://www.w3.org/TR/wai-aria-practices/#disclosure) 4 | 5 | ## To do 6 | 7 | - Optional svg, slot -------------------------------------------------------------------------------- /legacy/GenericVisuallyHidden.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "@generic-components/components/visually-hidden.js"; 3 | 4 | export function GenericVisuallyHidden({ children }) { 5 | return {children}; 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## editors 2 | /.idea 3 | /.vscode 4 | 5 | ## system files 6 | .DS_Store 7 | 8 | ## npm 9 | /node_modules/ 10 | /npm-debug.log 11 | 12 | ## testing 13 | /coverage/ 14 | 15 | ## temp folders 16 | /.tmp/ 17 | 18 | # build 19 | /_site/ 20 | /dist/ 21 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | ## editors 2 | /.idea 3 | /.vscode 4 | 5 | ## system files 6 | .DS_Store 7 | 8 | ## npm 9 | /node_modules/ 10 | /npm-debug.log 11 | 12 | ## testing 13 | /coverage/ 14 | 15 | ## temp folders 16 | /.tmp/ 17 | 18 | # build 19 | /_site/ 20 | /dist/ 21 | 22 | /**/*.html -------------------------------------------------------------------------------- /utils/EventTargetShim.js: -------------------------------------------------------------------------------- 1 | export class EventTargetShim { 2 | constructor() { 3 | const delegate = document.createDocumentFragment(); 4 | this.addEventListener = delegate.addEventListener.bind(delegate); 5 | this.dispatchEvent = delegate.dispatchEvent.bind(delegate); 6 | this.removeEventListener = delegate.removeEventListener.bind(delegate); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /custom-elements-manifest.config.js: -------------------------------------------------------------------------------- 1 | import reactify from './cem-plugin-reactify.js'; 2 | 3 | export default { 4 | exclude: ['coverage/**/*', 'cem-plugin-reactify.js'], 5 | plugins: [ 6 | reactify({ 7 | exclude: ['BatchingElement', 'FocusTrap', 'GenericDialogOverlay'], 8 | attributeMapping: { 9 | for: '_for', 10 | }, 11 | }), 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /generic-visually-hidden/visually-hidden.js: -------------------------------------------------------------------------------- 1 | import { _visuallyHidden } from '../utils/visually-hidden.js'; 2 | 3 | /** For usage as constructible stylesheet */ 4 | export const visuallyHidden = ` 5 | [visually-hidden] { 6 | ${_visuallyHidden} 7 | } 8 | `; 9 | 10 | /** For usage inside a web component */ 11 | export const hostVisuallyHidden = ` 12 | :host { 13 | ${_visuallyHidden} 14 | } 15 | `; 16 | -------------------------------------------------------------------------------- /generic-visually-hidden/test/generic-visually-hidden.test.js: -------------------------------------------------------------------------------- 1 | import { html, fixture, expect } from '@open-wc/testing'; 2 | import '../../visually-hidden.js'; 3 | 4 | describe('generic-visually-hidden', () => { 5 | it('a11y', async () => { 6 | const el = await fixture(html` 7 | 8 | `); 9 | 10 | await expect(el).to.be.accessible(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /generic-skiplink/skiplink.js: -------------------------------------------------------------------------------- 1 | import { _visuallyHidden } from '../utils/visually-hidden.js'; 2 | 3 | export const skiplink = ` 4 | a[skiplink] { 5 | ${_visuallyHidden} 6 | } 7 | 8 | a[skiplink]:focus { 9 | position: absolute; 10 | top: 0px; 11 | left: 0px; 12 | height: auto; 13 | width: auto; 14 | margin: auto; 15 | opacity: 1; 16 | pointer-events: auto; 17 | background-color: white; 18 | } 19 | `; 20 | -------------------------------------------------------------------------------- /generic-visually-hidden/GenericVisuallyHidden.js: -------------------------------------------------------------------------------- 1 | import { hostVisuallyHidden } from './visually-hidden.js'; 2 | 3 | export class GenericVisuallyHidden extends HTMLElement { 4 | static is = 'generic-visually-hidden'; 5 | 6 | constructor() { 7 | super(); 8 | this.attachShadow({ mode: 'open' }); 9 | } 10 | 11 | connectedCallback() { 12 | this.removeAttribute('hidden'); 13 | this.shadowRoot.innerHTML = ` 14 | 17 | 18 | 19 | `; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /generic-spinner/test/generic-spinner.test.js: -------------------------------------------------------------------------------- 1 | import { html, fixture, expect } from '@open-wc/testing'; 2 | import '../../spinner.js'; 3 | 4 | describe('generic-spinner', () => { 5 | it('sets aria label when label attr is provided', async () => { 6 | const el = await fixture(html` 7 | 8 | `); 9 | 10 | expect(el.getAttribute('aria-label')).to.equal('foo'); 11 | }); 12 | 13 | it('a11y', async () => { 14 | const el = await fixture(html` 15 | 16 | `); 17 | 18 | await expect(el).to.be.accessible(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /utils/visually-hidden.js: -------------------------------------------------------------------------------- 1 | export const _visuallyHidden = ` 2 | position: fixed; 3 | /* keep it on viewport */ 4 | top: 0px; 5 | left: 0px; 6 | /* give it non-zero size, VoiceOver on Safari requires at least 2 pixels 7 | before allowing buttons to be activated. */ 8 | width: 4px; 9 | height: 4px; 10 | /* visually hide it with overflow and opacity */ 11 | opacity: 0; 12 | overflow: hidden; 13 | /* remove any margin or padding */ 14 | border: none; 15 | margin: 0; 16 | padding: 0; 17 | /* ensure no other style sets display to none */ 18 | display: block; 19 | visibility: visible; 20 | pointer-events: none; 21 | `; 22 | -------------------------------------------------------------------------------- /legacy/GenericSkiplink.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import "@generic-components/components/skiplink.js"; 3 | 4 | export function GenericSkiplink({ children, _for }) { 5 | const ref = useRef(null); 6 | 7 | /** Attributes - run whenever an attr has changed */ 8 | 9 | useEffect(() => { 10 | if ( 11 | _for !== undefined && 12 | ref.current.getAttribute("for") !== String(_for) 13 | ) { 14 | ref.current.setAttribute("for", _for); 15 | } 16 | }, [_for]); 17 | 18 | return ( 19 | 20 | {children} 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /legacy/GenericSpinner.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import "@generic-components/components/spinner.js"; 3 | 4 | export function GenericSpinner({ children, label }) { 5 | const ref = useRef(null); 6 | 7 | /** Attributes - run whenever an attr has changed */ 8 | 9 | useEffect(() => { 10 | if ( 11 | label !== undefined && 12 | ref.current.getAttribute("label") !== String(label) 13 | ) { 14 | ref.current.setAttribute("label", label); 15 | } 16 | }, [label]); 17 | 18 | return ( 19 | 20 | {children} 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /generic-skiplink/test/generic-skiplink.test.js: -------------------------------------------------------------------------------- 1 | import { html, fixture, expect } from '@open-wc/testing'; 2 | import '../../skiplink.js'; 3 | 4 | describe('generic-skiplink', () => { 5 | it('a11y', async () => { 6 | const el = await fixture(html` 7 | 8 | `); 9 | 10 | await expect(el).to.be.accessible(); 11 | }); 12 | 13 | it('correctly renders the `for` attribute', async () => { 14 | const el = await fixture(html` 15 | 16 | `); 17 | 18 | expect(el.shadowRoot.querySelector('a').getAttribute('href')).to.equal('#main'); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /generic-alert/test/generic-alert.test.js: -------------------------------------------------------------------------------- 1 | import { html, fixture, expect } from '@open-wc/testing'; 2 | import '../../alert.js'; 3 | 4 | describe('generic-alert', () => { 5 | it('a11y', async () => { 6 | const el = await fixture(html` 7 | 8 | `); 9 | 10 | await expect(el).to.be.accessible(); 11 | }); 12 | 13 | it('has correct aria attributes', async () => { 14 | const el = await fixture(html` 15 | 16 | `); 17 | 18 | expect(el.getAttribute('role')).to.equal('alert'); 19 | expect(el.getAttribute('aria-live')).to.equal('assertive'); 20 | expect(el.getAttribute('aria-atomic')).to.equal('true'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /generic-visually-hidden/visually-hidden.css: -------------------------------------------------------------------------------- 1 | [visually-hidden] { 2 | position: fixed !important; 3 | /* keep it on viewport */ 4 | top: 0px !important; 5 | left: 0px !important; 6 | /* give it non-zero size, VoiceOver on Safari requires at least 2 pixels 7 | before allowing buttons to be activated. */ 8 | width: 4px !important; 9 | height: 4px !important; 10 | /* visually hide it with overflow and opacity */ 11 | opacity: 0 !important; 12 | overflow: hidden !important; 13 | /* remove any margin or padding */ 14 | border: none !important; 15 | margin: 0 !important; 16 | padding: 0 !important; 17 | /* ensure no other style sets display to none */ 18 | display: block !important; 19 | visibility: visible !important; 20 | pointer-events: none !important; 21 | } -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Test 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | test: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm install 28 | - run: npm test 29 | -------------------------------------------------------------------------------- /generic-skiplink/GenericSkiplink.js: -------------------------------------------------------------------------------- 1 | import { skiplink } from './skiplink.js'; 2 | 3 | /** 4 | * @element generic-skiplink 5 | * 6 | * @csspart anchor 7 | */ 8 | export class GenericSkiplink extends HTMLElement { 9 | static is = 'generic-skiplink'; 10 | 11 | constructor() { 12 | super(); 13 | this.attachShadow({ mode: 'open' }); 14 | } 15 | 16 | connectedCallback() { 17 | this.render(); 18 | } 19 | 20 | static get observedAttributes() { 21 | return ['for']; 22 | } 23 | 24 | attributeChangedCallback(name) { 25 | if (name === 'for') { 26 | this.render(); 27 | } 28 | } 29 | 30 | render() { 31 | this.shadowRoot.innerHTML = ` 32 | 35 | 36 | 43 | `; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /utils/BatchingElement.js: -------------------------------------------------------------------------------- 1 | export class BatchingElement extends HTMLElement { 2 | constructor() { 3 | super(); 4 | this.updateComplete = this.__resolver(); 5 | this.__uuid = BatchingElement.uuid++; // eslint-disable-line 6 | } 7 | 8 | update() {} 9 | 10 | async requestUpdate(dispatchEvent) { 11 | if (!this.__renderRequest) { 12 | this.__renderRequest = true; 13 | await 0; 14 | this.update(); 15 | if (dispatchEvent) { 16 | if (this.constructor.config.disabled && this.hasAttribute('disabled')) { 17 | /** noop */ 18 | } else { 19 | this.__dispatch(); 20 | } 21 | } 22 | 23 | this.__res(); 24 | this.updateComplete = this.__resolver(); 25 | this.__renderRequest = false; 26 | } 27 | } 28 | 29 | __dispatch() {} // eslint-disable-line 30 | 31 | __resolver() { 32 | return new Promise(res => { 33 | this.__res = res; 34 | }); 35 | } 36 | } 37 | 38 | BatchingElement.uuid = 0; 39 | -------------------------------------------------------------------------------- /legacy/GenericDialog.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import "@generic-components/components/dialog.js"; 3 | 4 | export function GenericDialog({ 5 | children, 6 | onDialogOpened, 7 | onDialogClosed, 8 | _connected 9 | }) { 10 | const ref = useRef(null); 11 | 12 | /** Event listeners - run once */ 13 | 14 | useEffect(() => { 15 | if (onDialogOpened !== undefined) { 16 | ref.current.addEventListener("dialog-opened", onDialogOpened); 17 | } 18 | }, []); 19 | 20 | useEffect(() => { 21 | if (onDialogClosed !== undefined) { 22 | ref.current.addEventListener("dialog-closed", onDialogClosed); 23 | } 24 | }, []); 25 | 26 | /** Properties - run whenever a property has changed */ 27 | 28 | useEffect(() => { 29 | if (_connected !== undefined && ref.current._connected !== _connected) { 30 | ref.current._connected = _connected; 31 | } 32 | }, [_connected]); 33 | 34 | return {children}; 35 | } 36 | -------------------------------------------------------------------------------- /generic-alert/GenericAlert.js: -------------------------------------------------------------------------------- 1 | const template = document.createElement('template'); 2 | template.innerHTML = ` 3 | 13 |
14 | 15 |
16 | `; 17 | 18 | export class GenericAlert extends HTMLElement { 19 | static is = 'generic-alert'; 20 | 21 | constructor() { 22 | super(); 23 | this.attachShadow({ mode: 'open' }); 24 | this.shadowRoot.appendChild(template.content.cloneNode(true)); 25 | } 26 | 27 | connectedCallback() { 28 | if (!this.hasAttribute('role')) { 29 | this.setAttribute('role', 'alert'); 30 | } 31 | if (!this.hasAttribute('aria-live')) { 32 | this.setAttribute('aria-live', 'assertive'); 33 | } 34 | if (!this.hasAttribute('aria-atomic')) { 35 | this.setAttribute('aria-atomic', 'true'); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /legacy/GenericDisclosure.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import "@generic-components/components/disclosure.js"; 3 | 4 | export function GenericDisclosure({ 5 | children, 6 | onOpenedChanged, 7 | expanded, 8 | __expanded 9 | }) { 10 | const ref = useRef(null); 11 | 12 | /** Event listeners - run once */ 13 | 14 | useEffect(() => { 15 | if (onOpenedChanged !== undefined) { 16 | ref.current.addEventListener("opened-changed", onOpenedChanged); 17 | } 18 | }, []); 19 | 20 | /** Properties - run whenever a property has changed */ 21 | 22 | useEffect(() => { 23 | if (expanded !== undefined && ref.current.expanded !== expanded) { 24 | ref.current.expanded = expanded; 25 | } 26 | }, [expanded]); 27 | 28 | useEffect(() => { 29 | if (__expanded !== undefined && ref.current.__expanded !== __expanded) { 30 | ref.current.__expanded = __expanded; 31 | } 32 | }, [__expanded]); 33 | 34 | return {children}; 35 | } 36 | -------------------------------------------------------------------------------- /generic-skiplink/skiplink.css: -------------------------------------------------------------------------------- 1 | a[skiplink] { 2 | position: fixed !important; 3 | /* keep it on viewport */ 4 | top: 0px !important; 5 | left: 0px !important; 6 | /* give it non-zero size, VoiceOver on Safari requires at least 2 pixels 7 | before allowing buttons to be activated. */ 8 | width: 4px !important; 9 | height: 4px !important; 10 | /* visually hide it with overflow and opacity */ 11 | opacity: 0 !important; 12 | overflow: hidden !important; 13 | /* remove any margin or padding */ 14 | border: none !important; 15 | margin: 0 !important; 16 | padding: 0 !important; 17 | /* ensure no other style sets display to none */ 18 | display: block !important; 19 | visibility: visible !important; 20 | pointer-events: none !important; 21 | } 22 | 23 | a[skiplink]:focus { 24 | position: absolute !important; 25 | top: 0px !important; 26 | left: 0px !important; 27 | height: auto !important; 28 | width: auto !important; 29 | margin: auto !important; 30 | opacity: 1 !important; 31 | pointer-events: auto !important; 32 | background-color: white; 33 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 pwa-install-button 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export { GenericAccordion } from './generic-accordion/GenericAccordion.js'; 2 | export { GenericAlert } from './generic-alert/GenericAlert.js'; 3 | export { GenericDialog } from './generic-dialog/GenericDialog.js'; 4 | export { dialog } from './generic-dialog/dialog.js'; 5 | export { GenericDisclosure } from './generic-disclosure/GenericDisclosure.js'; 6 | export { GenericListbox } from './generic-listbox/GenericListbox.js'; 7 | export { GenericRadio } from './generic-radio/GenericRadio.js'; 8 | export { GenericSkiplink } from './generic-skiplink/GenericSkiplink.js'; 9 | export { skiplink } from './generic-skiplink/skiplink.js'; 10 | export { GenericSpinner } from './generic-spinner/GenericSpinner.js'; 11 | export { GenericSwitch } from './generic-switch/GenericSwitch.js'; 12 | export { GenericTabs } from './generic-tabs/GenericTabs.js'; 13 | export { GenericVisuallyHidden } from './generic-visually-hidden/GenericVisuallyHidden.js'; 14 | export { visuallyHidden } from './generic-visually-hidden/visually-hidden.js'; 15 | // utils 16 | export { EventTargetShim } from './utils/EventTargetShim.js'; 17 | export { SelectedMixin } from './utils/SelectedMixin.js'; 18 | export { BatchingElement } from './utils/BatchingElement.js'; 19 | export { KEYCODES } from './utils/keycodes.js'; 20 | -------------------------------------------------------------------------------- /legacy/GenericAccordion.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import "@generic-components/components/accordion.js"; 3 | 4 | export function GenericAccordion({ 5 | children, 6 | onSelectedChanged, 7 | selected, 8 | updateComplete, 9 | __uuid 10 | }) { 11 | const ref = useRef(null); 12 | 13 | /** Event listeners - run once */ 14 | 15 | useEffect(() => { 16 | if (onSelectedChanged !== undefined) { 17 | ref.current.addEventListener("selected-changed", onSelectedChanged); 18 | } 19 | }, []); 20 | 21 | /** Properties - run whenever a property has changed */ 22 | 23 | useEffect(() => { 24 | if (selected !== undefined && ref.current.selected !== selected) { 25 | ref.current.selected = selected; 26 | } 27 | }, [selected]); 28 | 29 | useEffect(() => { 30 | if ( 31 | updateComplete !== undefined && 32 | ref.current.updateComplete !== updateComplete 33 | ) { 34 | ref.current.updateComplete = updateComplete; 35 | } 36 | }, [updateComplete]); 37 | 38 | useEffect(() => { 39 | if (__uuid !== undefined && ref.current.__uuid !== __uuid) { 40 | ref.current.__uuid = __uuid; 41 | } 42 | }, [__uuid]); 43 | 44 | return {children}; 45 | } 46 | -------------------------------------------------------------------------------- /legacy/GenericSwitch.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import "@generic-components/components/switch.js"; 3 | 4 | export function GenericSwitch({ 5 | children, 6 | onCheckedChanged, 7 | checked, 8 | disabled, 9 | label 10 | }) { 11 | const ref = useRef(null); 12 | 13 | /** Event listeners - run once */ 14 | 15 | useEffect(() => { 16 | if (onCheckedChanged !== undefined) { 17 | ref.current.addEventListener("checked-changed", onCheckedChanged); 18 | } 19 | }, []); 20 | 21 | /** Boolean attributes - run whenever an attr has changed */ 22 | 23 | useEffect(() => { 24 | if (disabled !== undefined) { 25 | if (disabled) { 26 | ref.current.setAttribute("disabled", ""); 27 | } else { 28 | ref.current.removeAttribute("disabled"); 29 | } 30 | } 31 | }, [disabled]); 32 | 33 | /** Attributes - run whenever an attr has changed */ 34 | 35 | useEffect(() => { 36 | if ( 37 | label !== undefined && 38 | ref.current.getAttribute("label") !== String(label) 39 | ) { 40 | ref.current.setAttribute("label", label); 41 | } 42 | }, [label]); 43 | 44 | /** Properties - run whenever a property has changed */ 45 | 46 | useEffect(() => { 47 | if (checked !== undefined && ref.current.checked !== checked) { 48 | ref.current.checked = checked; 49 | } 50 | }, [checked]); 51 | 52 | return ( 53 | 54 | {children} 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /utils/test/utils.test.js: -------------------------------------------------------------------------------- 1 | import { fixtureSync, expect, defineCE } from '@open-wc/testing'; // eslint-disable-line 2 | import { stub } from 'sinon'; 3 | import { BatchingElement } from '../BatchingElement.js'; 4 | 5 | describe('BatchingElement', () => { 6 | it('batches updates', async () => { 7 | const tag = defineCE( 8 | class TestClass extends BatchingElement { 9 | set foo(_) { 10 | this.requestUpdate(false); 11 | } 12 | }, 13 | ); 14 | const el = await fixtureSync(`<${tag}>`); 15 | const updateStub = stub(el, 'update'); 16 | el.foo = 1; 17 | el.foo = 2; 18 | el.foo = 3; 19 | await el.updateComplete; 20 | expect(updateStub).callCount(1); 21 | 22 | el.foo = 4; 23 | await el.updateComplete; 24 | expect(updateStub).callCount(2); 25 | 26 | updateStub.restore(); 27 | }); 28 | 29 | it('dispatches an event', async () => { 30 | const tag = defineCE( 31 | class TestClass extends BatchingElement { 32 | set foo(_) { 33 | this.requestUpdate(true); 34 | } 35 | 36 | static get config() { 37 | return { 38 | disabled: false, 39 | }; 40 | } 41 | }, 42 | ); 43 | const el = await fixtureSync(`<${tag}>`); 44 | const dispatchStub = stub(el, '__dispatch'); 45 | el.foo = 1; 46 | await el.updateComplete; 47 | expect(dispatchStub).callCount(1); 48 | dispatchStub.restore(); 49 | }); 50 | 51 | it('increases the uuid', () => { 52 | expect(BatchingElement.uuid).to.equal(2); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /legacy/GenericListbox.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import "@generic-components/components/listbox.js"; 3 | 4 | export function GenericListbox({ 5 | children, 6 | onSelectedChanged, 7 | selected, 8 | updateComplete, 9 | __uuid, 10 | label 11 | }) { 12 | const ref = useRef(null); 13 | 14 | /** Event listeners - run once */ 15 | 16 | useEffect(() => { 17 | if (onSelectedChanged !== undefined) { 18 | ref.current.addEventListener("selected-changed", onSelectedChanged); 19 | } 20 | }, []); 21 | 22 | /** Attributes - run whenever an attr has changed */ 23 | 24 | useEffect(() => { 25 | if ( 26 | label !== undefined && 27 | ref.current.getAttribute("label") !== String(label) 28 | ) { 29 | ref.current.setAttribute("label", label); 30 | } 31 | }, [label]); 32 | 33 | /** Properties - run whenever a property has changed */ 34 | 35 | useEffect(() => { 36 | if (selected !== undefined && ref.current.selected !== selected) { 37 | ref.current.selected = selected; 38 | } 39 | }, [selected]); 40 | 41 | useEffect(() => { 42 | if ( 43 | updateComplete !== undefined && 44 | ref.current.updateComplete !== updateComplete 45 | ) { 46 | ref.current.updateComplete = updateComplete; 47 | } 48 | }, [updateComplete]); 49 | 50 | useEffect(() => { 51 | if (__uuid !== undefined && ref.current.__uuid !== __uuid) { 52 | ref.current.__uuid = __uuid; 53 | } 54 | }, [__uuid]); 55 | 56 | return ( 57 | 58 | {children} 59 | 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /legacy/GenericRadio.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import "@generic-components/components/radio.js"; 3 | 4 | export function GenericRadio({ 5 | children, 6 | onSelectedChanged, 7 | selected, 8 | updateComplete, 9 | __uuid, 10 | vertical, 11 | disabled 12 | }) { 13 | const ref = useRef(null); 14 | 15 | /** Event listeners - run once */ 16 | 17 | useEffect(() => { 18 | if (onSelectedChanged !== undefined) { 19 | ref.current.addEventListener("selected-changed", onSelectedChanged); 20 | } 21 | }, []); 22 | 23 | /** Boolean attributes - run whenever an attr has changed */ 24 | 25 | useEffect(() => { 26 | if (vertical !== undefined) { 27 | if (vertical) { 28 | ref.current.setAttribute("vertical", ""); 29 | } else { 30 | ref.current.removeAttribute("vertical"); 31 | } 32 | } 33 | }, [vertical]); 34 | 35 | useEffect(() => { 36 | if (disabled !== undefined) { 37 | if (disabled) { 38 | ref.current.setAttribute("disabled", ""); 39 | } else { 40 | ref.current.removeAttribute("disabled"); 41 | } 42 | } 43 | }, [disabled]); 44 | 45 | /** Properties - run whenever a property has changed */ 46 | 47 | useEffect(() => { 48 | if (selected !== undefined && ref.current.selected !== selected) { 49 | ref.current.selected = selected; 50 | } 51 | }, [selected]); 52 | 53 | useEffect(() => { 54 | if ( 55 | updateComplete !== undefined && 56 | ref.current.updateComplete !== updateComplete 57 | ) { 58 | ref.current.updateComplete = updateComplete; 59 | } 60 | }, [updateComplete]); 61 | 62 | useEffect(() => { 63 | if (__uuid !== undefined && ref.current.__uuid !== __uuid) { 64 | ref.current.__uuid = __uuid; 65 | } 66 | }, [__uuid]); 67 | 68 | return ( 69 | 70 | {children} 71 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /legacy/GenericTabs.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import "@generic-components/components/tabs.js"; 3 | 4 | export function GenericTabs({ 5 | children, 6 | onSelectedChanged, 7 | selected, 8 | updateComplete, 9 | __uuid, 10 | vertical, 11 | label 12 | }) { 13 | const ref = useRef(null); 14 | 15 | /** Event listeners - run once */ 16 | 17 | useEffect(() => { 18 | if (onSelectedChanged !== undefined) { 19 | ref.current.addEventListener("selected-changed", onSelectedChanged); 20 | } 21 | }, []); 22 | 23 | /** Boolean attributes - run whenever an attr has changed */ 24 | 25 | useEffect(() => { 26 | if (vertical !== undefined) { 27 | if (vertical) { 28 | ref.current.setAttribute("vertical", ""); 29 | } else { 30 | ref.current.removeAttribute("vertical"); 31 | } 32 | } 33 | }, [vertical]); 34 | 35 | /** Attributes - run whenever an attr has changed */ 36 | 37 | useEffect(() => { 38 | if ( 39 | label !== undefined && 40 | ref.current.getAttribute("label") !== String(label) 41 | ) { 42 | ref.current.setAttribute("label", label); 43 | } 44 | }, [label]); 45 | 46 | /** Properties - run whenever a property has changed */ 47 | 48 | useEffect(() => { 49 | if (selected !== undefined && ref.current.selected !== selected) { 50 | ref.current.selected = selected; 51 | } 52 | }, [selected]); 53 | 54 | useEffect(() => { 55 | if ( 56 | updateComplete !== undefined && 57 | ref.current.updateComplete !== updateComplete 58 | ) { 59 | ref.current.updateComplete = updateComplete; 60 | } 61 | }, [updateComplete]); 62 | 63 | useEffect(() => { 64 | if (__uuid !== undefined && ref.current.__uuid !== __uuid) { 65 | ref.current.__uuid = __uuid; 66 | } 67 | }, [__uuid]); 68 | 69 | return ( 70 | 71 | {children} 72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /generic-dialog/GenericDialog.js: -------------------------------------------------------------------------------- 1 | import { dialog } from './dialog.js'; 2 | 3 | const template = document.createElement('template'); 4 | template.innerHTML = ` 5 | 6 | 7 | 8 | 9 | 11 | `; 12 | 13 | export class GenericDialog extends HTMLElement { 14 | static is = 'generic-dialog'; 15 | 16 | constructor() { 17 | super(); 18 | this.attachShadow({ mode: 'open' }); 19 | 20 | this._connected = false; 21 | } 22 | 23 | close() { 24 | this.content.forEach(element => { 25 | element.setAttribute('hidden', ''); 26 | element.setAttribute('slot', 'content'); 27 | this.append(element); 28 | }); 29 | dialog.close(); 30 | } 31 | 32 | connectedCallback() { 33 | if (this._connected) { 34 | return; 35 | } 36 | 37 | this._connected = true; 38 | 39 | this.shadowRoot.appendChild(template.content.cloneNode(true)); 40 | 41 | const invoker = this.shadowRoot.querySelector('slot[name="invoker"]'); 42 | this.content = this.shadowRoot.querySelector('slot[name="content"]').assignedNodes(); 43 | 44 | invoker.addEventListener('click', e => { 45 | dialog.open({ 46 | invokerNode: e.target, 47 | closeOnEscape: this.hasAttribute('close-on-escape'), 48 | closeOnOutsideClick: this.hasAttribute('close-on-outside-click'), 49 | content: dialogNode => { 50 | this.content.forEach(element => { 51 | element.removeAttribute('hidden'); 52 | element.removeAttribute('slot'); 53 | dialogNode.append(element); 54 | }); 55 | }, 56 | }); 57 | }); 58 | 59 | dialog.addEventListener('dialog-opened', () => { 60 | this.dispatchEvent(new CustomEvent('dialog-opened', { detail: true })); 61 | }); 62 | 63 | dialog.addEventListener('dialog-closed', () => { 64 | this.dispatchEvent(new CustomEvent('dialog-closed', { detail: true })); 65 | }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /generic-accordion/GenericAccordion.js: -------------------------------------------------------------------------------- 1 | import { BatchingElement } from '../utils/BatchingElement.js'; 2 | import { SelectedMixin } from '../utils/SelectedMixin.js'; 3 | 4 | const template = document.createElement('template'); 5 | template.innerHTML = ` 6 | 16 | 17 | 18 | `; 19 | 20 | export class GenericAccordion extends SelectedMixin(BatchingElement) { 21 | static is = 'generic-accordion'; 22 | 23 | static get config() { 24 | return { 25 | selectors: { 26 | buttons: { 27 | selector: el => el.querySelectorAll('button'), 28 | focusTarget: true, 29 | }, 30 | regions: { 31 | selector: el => el.querySelectorAll('generic-accordion > *:not(button)'), 32 | }, 33 | }, 34 | multiDirectional: false, 35 | orientation: 'vertical', 36 | shouldFocus: true, 37 | activateOnKeydown: false, 38 | disabled: false, 39 | }; 40 | } 41 | 42 | constructor() { 43 | super(); 44 | this.attachShadow({ mode: 'open' }); 45 | this.shadowRoot.appendChild(template.content.cloneNode(true)); 46 | } 47 | 48 | update() { 49 | const { buttons, regions } = this.getElements(); 50 | 51 | buttons.forEach((_, i) => { 52 | if (i === this.selected) { 53 | this.requestUpdate(true); 54 | buttons[i].setAttribute('selected', ''); 55 | buttons[i].setAttribute('aria-expanded', 'true'); 56 | buttons[i].setAttribute('aria-disabled', 'true'); 57 | regions[i].hidden = false; 58 | this.value = buttons[i].textContent.trim(); 59 | } else { 60 | buttons[i].setAttribute('aria-expanded', 'false'); 61 | buttons[i].removeAttribute('aria-disabled'); 62 | buttons[i].removeAttribute('selected'); 63 | regions[i].hidden = true; 64 | } 65 | 66 | if (!buttons[i].id.startsWith('generic-accordion-')) { 67 | buttons[i].id = `generic-accordion-${this.__uuid}-${i}`; 68 | regions[i].setAttribute('aria-labelledby', `generic-accordion-${this.__uuid}-${i}`); 69 | regions[i].setAttribute('role', 'region'); 70 | } 71 | }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@generic-components/components", 3 | "version": "1.1.8", 4 | "description": "Set of generic, accessible, zero dependency components", 5 | "author": "Pascal Schilp", 6 | "license": "MIT", 7 | "type": "module", 8 | "main": "index.js", 9 | "module": "index.js", 10 | "homepage": "https://genericcomponents.netlify.app/", 11 | "repository": "https://github.com/thepassle/generic-components", 12 | "scripts": { 13 | "analyze": "custom-elements-manifest analyze", 14 | "start": "es-dev-server --node-resolve --open --watch", 15 | "lint:eslint": "eslint --ext .js . --ignore-path .gitignore --ignore-pattern web_modules/**/*.*", 16 | "format:eslint": "eslint --ext .js,.html . --fix --ignore-path .gitignore --ignore-pattern web_modules/**/*.*", 17 | "lint:prettier": "prettier \"**/*.js\" --check --ignore-path .gitignore", 18 | "format:prettier": "prettier \"**/*.js\" --write --ignore-path .prettierignore", 19 | "lint": "npm run lint:eslint && npm run lint:prettier", 20 | "format": "npm run format:eslint && npm run format:prettier", 21 | "test": "web-test-runner **/*/*.test.js --coverage --node-resolve", 22 | "test:watch": "web-test-runner **/*/*.test.js --coverage --node-resolve --watch" 23 | }, 24 | "devDependencies": { 25 | "@a11y/focus-trap": "^1.0.5", 26 | "@custom-elements-manifest/analyzer": "^0.3.12", 27 | "@open-wc/eslint-config": "^1.0.0", 28 | "@open-wc/prettier-config": "^0.1.10", 29 | "@open-wc/testing": "^2.5.17", 30 | "@web/test-runner": "^0.7.21", 31 | "es-dev-server": "^1.57.2", 32 | "eslint": "^6.1.0", 33 | "husky": "^1.0.0", 34 | "lint-staged": "^8.0.0", 35 | "lit-html": "1.1.1", 36 | "sinon": "^7.5.0" 37 | }, 38 | "eslintConfig": { 39 | "extends": [ 40 | "@open-wc/eslint-config", 41 | "eslint-config-prettier" 42 | ], 43 | "rules": { 44 | "wc/no-constructor-attributes": "off" 45 | } 46 | }, 47 | "prettier": "@open-wc/prettier-config", 48 | "husky": { 49 | "hooks": { 50 | "pre-commit": "lint-staged" 51 | } 52 | }, 53 | "lint-staged": { 54 | "*.js": [ 55 | "eslint --fix --ignore-pattern web_modules/**/*.*", 56 | "prettier --write", 57 | "git add" 58 | ] 59 | }, 60 | "customElements": "custom-elements.json" 61 | } 62 | -------------------------------------------------------------------------------- /generic-dialog/generic-dialog-overlay.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | import { dialog } from './dialog.js'; 3 | import '../web_modules/@a11y/focus-trap.js'; 4 | 5 | const template = document.createElement('template'); 6 | template.innerHTML = ` 7 | 25 |
26 | 27 | 28 | 29 |
30 | `; 31 | 32 | export class GenericDialogOverlay extends HTMLElement { 33 | static is = 'generic-dialog-overlay'; 34 | 35 | constructor() { 36 | super(); 37 | this.attachShadow({ mode: 'open' }); 38 | this.shadowRoot.appendChild(template.content.cloneNode(true)); 39 | 40 | this.__onClick = this.__onClick.bind(this); 41 | this.__onFocusIn = this.__onFocusIn.bind(this); 42 | } 43 | 44 | connectedCallback() { 45 | if (this.hasAttribute('close-on-outside-click')) { 46 | this.addEventListener('mousedown', this.__onClick, true); 47 | } 48 | 49 | this.dialog = this.shadowRoot.querySelector("[role='dialog']"); 50 | this.dialog.setAttribute('tabindex', '-1'); 51 | this.dialog.focus(); 52 | 53 | ['mousedown', 'blur'].forEach(event => { 54 | this.dialog.addEventListener(event, () => { 55 | this.dialog.removeAttribute('tabindex'); 56 | }); 57 | }); 58 | 59 | window.addEventListener('focusin', this.__onFocusIn); 60 | } 61 | 62 | disconnectedCallback() { 63 | window.removeEventListener('focusin', this.__onFocusIn); 64 | } 65 | 66 | __onFocusIn() { 67 | if (dialog.__dialogOpen) { 68 | if (!this.contains(document.activeElement)) { 69 | this.dialog.setAttribute('tabindex', '-1'); 70 | this.dialog.focus(); 71 | } 72 | } 73 | } 74 | 75 | __onClick(e) { 76 | if ( 77 | !e.composedPath().includes(this.dialog) && 78 | dialog.__dialogOpen && 79 | dialog.__closeOnOutsideClick 80 | ) { 81 | dialog.close(); 82 | } 83 | } 84 | } 85 | 86 | customElements.define(GenericDialogOverlay.is, GenericDialogOverlay); 87 | -------------------------------------------------------------------------------- /generic-dialog/dialog.js: -------------------------------------------------------------------------------- 1 | import { EventTargetShim } from '../utils/EventTargetShim.js'; 2 | import { KEYCODES } from '../utils/keycodes.js'; 3 | // eslint-disable-next-line 4 | import './generic-dialog-overlay.js'; 5 | 6 | export class Dialog extends EventTargetShim { 7 | open({ closeOnEscape = true, closeOnOutsideClick = true, invokerNode, content }) { 8 | this.__dialogOpen = true; 9 | this.__invokerNode = invokerNode; 10 | this.__closeOnEscape = closeOnEscape; 11 | this.__closeOnOutsideClick = closeOnOutsideClick; 12 | 13 | if (this.__closeOnEscape) { 14 | window.addEventListener('keydown', this.__onKeyDown.bind(this), true); 15 | } 16 | 17 | [...document.body.children].forEach(node => { 18 | if (node.localName !== 'script') { 19 | if (!node.hasAttribute('aria-hidden')) { 20 | node.setAttribute('dialog-disabled', ''); 21 | node.setAttribute('aria-hidden', 'true'); 22 | node.setAttribute('inert', ''); 23 | } 24 | } 25 | }); 26 | 27 | const dialogOverlayNode = document.createElement('generic-dialog-overlay'); 28 | const dialogNode = dialogOverlayNode.shadowRoot.querySelector('[role="dialog"]'); 29 | 30 | this.__dialogOverlay = dialogOverlayNode; 31 | if (this.__closeOnOutsideClick) { 32 | dialogOverlayNode.setAttribute('close-on-outside-click', ''); 33 | } 34 | document.body.appendChild(dialogOverlayNode); 35 | 36 | content(dialogOverlayNode, dialogNode); 37 | this.dispatchEvent(new Event('dialog-opened')); 38 | } 39 | 40 | // eslint-disable-next-line 41 | close() { 42 | this.__dialogOpen = false; 43 | 44 | [...document.body.children].forEach(node => { 45 | if (node.localName !== 'script') { 46 | if (node.hasAttribute('dialog-disabled')) { 47 | node.removeAttribute('dialog-disabled'); 48 | node.removeAttribute('aria-hidden'); 49 | node.removeAttribute('inert'); 50 | } 51 | } 52 | }); 53 | 54 | document.querySelector('generic-dialog-overlay').remove(); 55 | 56 | this.__invokerNode.focus(); 57 | this.__invokerNode = null; 58 | 59 | this.dispatchEvent(new Event('dialog-closed')); 60 | } 61 | 62 | __onKeyDown(e) { 63 | if (e.keyCode === KEYCODES.ESC && this.__dialogOpen && this.__closeOnEscape) { 64 | this.close(); 65 | window.removeEventListener('keydown', this.__onKeyDown.bind(this), true); 66 | } 67 | } 68 | } 69 | 70 | export const dialog = new Dialog(); 71 | -------------------------------------------------------------------------------- /generic-listbox/GenericListbox.js: -------------------------------------------------------------------------------- 1 | import { BatchingElement } from '../utils/BatchingElement.js'; 2 | import { SelectedMixin } from '../utils/SelectedMixin.js'; 3 | 4 | const template = document.createElement('template'); 5 | template.innerHTML = ` 6 | 7 | 8 | `; 9 | 10 | /** 11 | * @attr label 12 | */ 13 | export class GenericListbox extends SelectedMixin(BatchingElement) { 14 | static is = 'generic-listbox'; 15 | 16 | static get config() { 17 | return { 18 | selectors: { 19 | ul: { 20 | selector: el => el.querySelector('ul'), 21 | }, 22 | li: { 23 | selector: el => el.querySelectorAll('ul li'), 24 | focusTarget: true, 25 | }, 26 | }, 27 | multiDirectional: false, 28 | orientation: 'vertical', 29 | shouldFocus: false, 30 | activateOnKeydown: true, 31 | disabled: false, 32 | }; 33 | } 34 | 35 | constructor() { 36 | super(); 37 | this.attachShadow({ mode: 'open' }); 38 | this.shadowRoot.appendChild(template.content.cloneNode(true)); 39 | } 40 | 41 | connectedCallback() { 42 | super.connectedCallback(); 43 | const { ul } = this.getElements(); 44 | 45 | ul.setAttribute('tabindex', '0'); 46 | ul.setAttribute('role', 'listbox'); 47 | ul.setAttribute('aria-label', this.getAttribute('label') || 'listbox'); 48 | } 49 | 50 | update() { 51 | const { ul, li } = this.getElements(); 52 | 53 | li.forEach((el, i) => { 54 | if (!li[i].id.startsWith('generic-listbox-')) { 55 | li[i].id = `generic-listbox-${this.__uuid}-${i}`; 56 | li[i].setAttribute('role', 'option'); 57 | } 58 | 59 | if (i === this.selected) { 60 | this.requestUpdate(true); 61 | li[i].setAttribute('aria-selected', 'true'); 62 | li[i].setAttribute('selected', ''); 63 | ul.setAttribute('aria-activedescendant', li[i].id); 64 | this.__scrollIntoView(li[i]); 65 | this.value = li[i].textContent.trim(); 66 | } else { 67 | li[i].removeAttribute('aria-selected'); 68 | li[i].removeAttribute('selected'); 69 | } 70 | }); 71 | } 72 | 73 | __scrollIntoView(li) { 74 | const { ul } = this.getElements(); 75 | if (ul.scrollHeight > ul.clientHeight) { 76 | const elOffsetBottom = li.offsetTop - ul.offsetTop + li.clientHeight; 77 | const elOffsetTop = li.offsetTop - ul.offsetTop; 78 | 79 | if (elOffsetTop < ul.scrollTop) { 80 | ul.scrollTop = elOffsetTop; 81 | } 82 | 83 | if (elOffsetBottom > ul.scrollTop + ul.clientHeight) { 84 | if (ul.clientHeight - elOffsetTop < 0) { 85 | ul.scrollTop = elOffsetBottom - ul.clientHeight; 86 | } else { 87 | ul.scrollTop = ul.clientHeight - elOffsetTop; 88 | } 89 | } 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /generic-disclosure/GenericDisclosure.js: -------------------------------------------------------------------------------- 1 | const template = document.createElement('template'); 2 | template.innerHTML = ` 3 | 39 | 40 | 44 | 45 | 46 | 47 | `; 48 | 49 | export class GenericDisclosure extends HTMLElement { 50 | static is = 'generic-disclosure'; 51 | 52 | constructor() { 53 | super(); 54 | this.attachShadow({ mode: 'open' }); 55 | this.shadowRoot.appendChild(template.content.cloneNode(true)); 56 | this.__expanded = false; 57 | } 58 | 59 | connectedCallback() { 60 | this.__button = this.querySelector('button[slot="toggle"]'); 61 | this.__detail = this.querySelector('[slot="detail"]'); 62 | 63 | this.__button.addEventListener('click', () => { 64 | if (this.hasAttribute('expanded')) { 65 | this.removeAttribute('expanded'); 66 | this.__expanded = false; 67 | } else { 68 | this.setAttribute('expanded', ''); 69 | this.__expanded = true; 70 | } 71 | }); 72 | 73 | if (this.hasAttribute('expanded')) { 74 | this.__open(false); 75 | } 76 | } 77 | 78 | static get observedAttributes() { 79 | return ['expanded']; 80 | } 81 | 82 | attributeChangedCallback(name, newVal, oldVal) { 83 | if (!this.__button) return; 84 | if (name === 'expanded') { 85 | if (newVal !== oldVal) { 86 | if (this.hasAttribute('expanded')) { 87 | this.__expanded = true; 88 | this.__open(true); 89 | } else { 90 | this.__expanded = false; 91 | this.__close(true); 92 | } 93 | } 94 | } 95 | } 96 | 97 | __open(dispatch) { 98 | if (dispatch) { 99 | this.dispatchEvent( 100 | new CustomEvent('opened-changed', { 101 | detail: true, 102 | }), 103 | ); 104 | } 105 | this.__button.setAttribute('aria-expanded', 'true'); 106 | } 107 | 108 | __close() { 109 | this.dispatchEvent( 110 | new CustomEvent('opened-changed', { 111 | detail: false, 112 | }), 113 | ); 114 | this.__button.setAttribute('aria-expanded', 'false'); 115 | } 116 | 117 | /** 118 | * @attr 119 | * @type {boolean} 120 | */ 121 | get expanded() { 122 | return this.__expanded; 123 | } 124 | 125 | set expanded(val) { 126 | if (val) { 127 | this.setAttribute('expanded', ''); 128 | } else { 129 | this.removeAttribute('expanded'); 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /generic-spinner/GenericSpinner.js: -------------------------------------------------------------------------------- 1 | const template = document.createElement('template'); 2 | template.innerHTML = ` 3 | 74 | 75 | 76 | 77 | `; 78 | 79 | /** 80 | * @cssproperty --generic-spinner-width - Controls the width of the spinner 81 | * @cssproperty --generic-spinner-height - Controls the height of the spinner 82 | * @cssproperty --generic-spinner-color - Controls the color of the spinner 83 | * @cssproperty --generic-spinner-stroke-width - Controls the width of the stroke 84 | * 85 | * @csspart spinner - Style the spinner SVG 86 | * @csspart circle - Style the circle SVG 87 | */ 88 | export class GenericSpinner extends HTMLElement { 89 | static is = 'generic-spinner'; 90 | 91 | constructor() { 92 | super(); 93 | this.attachShadow({ mode: 'open' }); 94 | this.shadowRoot.appendChild(template.content.cloneNode(true)); 95 | } 96 | 97 | connectedCallback() { 98 | this.setAttribute('role', 'status'); 99 | this.setAttribute('aria-live', 'polite'); 100 | this.setAttribute('aria-label', 'loading'); 101 | this.handleAttributes(); 102 | } 103 | 104 | static get observedAttributes() { 105 | return ['label']; 106 | } 107 | 108 | attributeChangedCallback() { 109 | this.handleAttributes(); 110 | } 111 | 112 | handleAttributes() { 113 | if (this.hasAttribute('label')) { 114 | this.setAttribute('aria-label', this.getAttribute('label')); 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /generic-tabs/GenericTabs.js: -------------------------------------------------------------------------------- 1 | import { BatchingElement } from '../utils/BatchingElement.js'; 2 | import { SelectedMixin } from '../utils/SelectedMixin.js'; 3 | 4 | const template = document.createElement('template'); 5 | template.innerHTML = ` 6 | 24 | 25 | 26 | 27 |
28 | 29 |
30 | 31 |
32 | 33 |
34 | `; 35 | 36 | /** 37 | * @attr label 38 | * @attr {boolean} vertical 39 | */ 40 | export class GenericTabs extends SelectedMixin(BatchingElement) { 41 | static is = 'generic-tabs'; 42 | 43 | static get config() { 44 | return { 45 | selectors: { 46 | tabs: { 47 | selector: el => 48 | Array.from(el.children).filter(node => 49 | node.matches('h1, h2, h3, h4, h5, h6, [slot="tab"]'), 50 | ), 51 | focusTarget: true, 52 | }, 53 | panels: { 54 | selector: el => 55 | Array.from(el.children).filter( 56 | node => 57 | node.matches('h1 ~ *, h2 ~ *, h3 ~ *, h4 ~ *, h5 ~ *, h6 ~ *, [slot="panel"]') && 58 | !node.matches('h1, h2, h3, h4, h5, h6, [slot="tab"]'), 59 | ), 60 | }, 61 | }, 62 | multiDirectional: true, 63 | orientation: 'horizontal', 64 | shouldFocus: true, 65 | activateOnKeydown: true, 66 | disabled: false, 67 | }; 68 | } 69 | 70 | static get observedAttributes() { 71 | return [...super.observedAttributes, 'vertical']; 72 | } 73 | 74 | attributeChangedCallback(name, old, val) { 75 | super.attributeChangedCallback(name, old, val); 76 | if (name === 'vertical') { 77 | this.requestUpdate(false); 78 | } 79 | } 80 | 81 | connectedCallback() { 82 | super.connectedCallback(); 83 | this.shadowRoot 84 | .querySelector('[role="tablist"]') 85 | .setAttribute('aria-label', this.getAttribute('label') || 'tablist'); 86 | } 87 | 88 | constructor() { 89 | super(); 90 | this.attachShadow({ mode: 'open' }); 91 | this.shadowRoot.appendChild(template.content.cloneNode(true)); 92 | } 93 | 94 | update() { 95 | const { tabs, panels } = this.getElements(); 96 | tabs.forEach((_, i) => { 97 | tabs[i].slot = 'tab'; 98 | if (i === this.selected) { 99 | tabs[i].setAttribute('selected', ''); 100 | tabs[i].setAttribute('aria-selected', 'true'); 101 | tabs[i].setAttribute('tabindex', '0'); 102 | panels[i].removeAttribute('hidden'); 103 | this.value = tabs[i].textContent.trim(); 104 | } else { 105 | tabs[i].removeAttribute('selected'); 106 | tabs[i].setAttribute('aria-selected', 'false'); 107 | tabs[i].setAttribute('tabindex', '-1'); 108 | panels[i].setAttribute('hidden', ''); 109 | } 110 | 111 | if (!tabs[i].id.startsWith('generic-tab-')) { 112 | tabs[i].setAttribute('role', 'tab'); 113 | panels[i].setAttribute('role', 'tabpanel'); 114 | 115 | tabs[i].id = `generic-tab-${this.__uuid}-${i}`; 116 | tabs[i].setAttribute('aria-controls', `generic-tab-${this.__uuid}-${i}`); 117 | panels[i].setAttribute('aria-labelledby', `generic-tab-${this.__uuid}-${i}`); 118 | } 119 | }); 120 | panels.forEach((_, i) => { 121 | panels[i].slot = 'panel'; 122 | }); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /generic-disclosure/test/generic-disclosure.test.js: -------------------------------------------------------------------------------- 1 | import { html, fixture, expect, oneEvent } from '@open-wc/testing'; 2 | import { stub } from 'sinon'; 3 | import '../../disclosure.js'; 4 | 5 | describe('generic-disclosure', () => { 6 | it('a11y', async () => { 7 | const el = await fixture(html` 8 | 11 | `); 12 | 13 | await expect(el).to.be.accessible(); 14 | }); 15 | 16 | it('opens and closes on click', async () => { 17 | const el = await fixture(html` 18 | 21 | `); 22 | 23 | const btn = el.querySelector('button'); 24 | btn.click(); 25 | 26 | expect(el.hasAttribute('expanded')).to.equal(true); 27 | expect(btn.getAttribute('aria-expanded')).to.equal('true'); 28 | 29 | btn.click(); 30 | 31 | expect(el.hasAttribute('expanded')).to.equal(false); 32 | expect(btn.getAttribute('aria-expanded')).to.equal('false'); 33 | }); 34 | 35 | it('reacts to attribute changes', async () => { 36 | const el = await fixture(html` 37 | 40 | `); 41 | const btn = el.querySelector('button'); 42 | 43 | el.setAttribute('expanded', ''); 44 | 45 | expect(el.hasAttribute('expanded')).to.equal(true); 46 | expect(btn.getAttribute('aria-expanded')).to.equal('true'); 47 | 48 | el.removeAttribute('expanded'); 49 | 50 | expect(el.hasAttribute('expanded')).to.equal(false); 51 | expect(btn.getAttribute('aria-expanded')).to.equal('false'); 52 | }); 53 | 54 | it('reacts to property changes', async () => { 55 | const el = await fixture(html` 56 | 59 | `); 60 | const btn = el.querySelector('button'); 61 | 62 | el.expanded = true; 63 | 64 | expect(el.hasAttribute('expanded')).to.equal(true); 65 | expect(btn.getAttribute('aria-expanded')).to.equal('true'); 66 | 67 | el.expanded = false; 68 | 69 | expect(el.hasAttribute('expanded')).to.equal(false); 70 | expect(btn.getAttribute('aria-expanded')).to.equal('false'); 71 | }); 72 | 73 | it('fires a opened-changed event - on open', async () => { 74 | const el = await fixture(html` 75 | 78 | `); 79 | 80 | const listener = oneEvent(el, 'opened-changed'); 81 | 82 | el.expanded = true; 83 | 84 | const { detail } = await listener; 85 | expect(detail).to.equal(true); 86 | }); 87 | 88 | it('fires a opened-changed event - on close', async () => { 89 | const el = await fixture(html` 90 | 93 | `); 94 | 95 | const listener = oneEvent(el, 'opened-changed'); 96 | 97 | el.expanded = false; 98 | 99 | const { detail } = await listener; 100 | expect(detail).to.equal(false); 101 | }); 102 | 103 | it('doesnt fire an event on first update', async () => { 104 | const el = await fixture(html` 105 | 106 | 107 | 108 | `); 109 | const dispatchStub = stub(el, 'dispatchEvent'); 110 | el.connectedCallback(); 111 | expect(dispatchStub).callCount(0); 112 | dispatchStub.restore(); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | generic-components 14 | 15 | 16 | 17 |
18 | 64 |
65 |

generic-components

66 |

A collection of generic web components with a focus on:

67 | 68 | 73 | 74 |

Goal

75 |

The goal of this project is to create a common library of generic web components, that are accessible, 76 | framework agnostic, easy to style, and easy to consume.

77 |

All components in these repo extend from HTMLElement and dont use any libraries or framework.

78 |

You can think of these components like using a native `button` element, you get all the functionality, and 79 | accessibility, keyboard nav, etc for free, you just have to style the button to your liking.

80 |

You can use these components to build an app, or compose them and build your own components with them.

81 |

Usage

82 | 83 |

Via npm

84 |

Components can be installed via npm

85 |
npm i --save @generic-components/components
86 |

And import in your code via ES imports:

87 |
import '@generic-components/components/switch.js';
88 | 89 |

Via CDN

90 |

Alternatively you can load the components from a CDN and drop them in your HTML file as a script tag

91 |
92 | Use the component in your HTML file: 93 |
94 | 95 |

Legacy

96 |

There are also React wrappers for these components, in case you want to use these components in your legacy projects. For more info, go here.

97 |
98 | 99 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /generic-radio/GenericRadio.js: -------------------------------------------------------------------------------- 1 | import { BatchingElement } from '../utils/BatchingElement.js'; 2 | import { SelectedMixin } from '../utils/SelectedMixin.js'; 3 | 4 | const template = document.createElement('template'); 5 | template.innerHTML = ` 6 | 73 | 74 |
75 | 76 |
77 | `; 78 | 79 | /** 80 | * @attr {boolean} vertical 81 | * @attr {boolean} disabled 82 | */ 83 | export class GenericRadio extends SelectedMixin(BatchingElement) { 84 | static is = 'generic-radio'; 85 | 86 | static get config() { 87 | return { 88 | selectors: { 89 | radios: { 90 | selector: el => el.querySelectorAll('*'), 91 | focusTarget: true, 92 | }, 93 | }, 94 | multiDirectional: true, 95 | orientation: 'horizontal', 96 | shouldFocus: true, 97 | activateOnKeydown: true, 98 | disabled: true, 99 | }; 100 | } 101 | 102 | static get observedAttributes() { 103 | return [...super.observedAttributes, 'vertical', 'disabled']; 104 | } 105 | 106 | attributeChangedCallback(name, old, val) { 107 | super.attributeChangedCallback(name, old, val); 108 | if (name === 'vertical' || name === 'disabled') { 109 | this.requestUpdate(false); 110 | } 111 | } 112 | 113 | connectedCallback() { 114 | super.connectedCallback(); 115 | this.shadowRoot 116 | .querySelector('.group') 117 | .setAttribute('aria-label', this.getAttribute('label') || 'radiogroup'); 118 | } 119 | 120 | constructor() { 121 | super(); 122 | this.attachShadow({ mode: 'open' }); 123 | this.shadowRoot.appendChild(template.content.cloneNode(true)); 124 | } 125 | 126 | update() { 127 | const { radios } = this.getElements(); 128 | if (this.selected === null) { 129 | this.selected = 0; 130 | } 131 | 132 | radios.forEach((_, i) => { 133 | if (i === this.selected && !this.hasAttribute('disabled')) { 134 | radios[i].setAttribute('selected', ''); 135 | radios[i].setAttribute('aria-checked', 'true'); 136 | radios[i].setAttribute('tabindex', '0'); 137 | this.value = radios[i].textContent.trim(); 138 | } else { 139 | radios[i].removeAttribute('selected'); 140 | radios[i].setAttribute('aria-checked', 'false'); 141 | radios[i].setAttribute('tabindex', '-1'); 142 | } 143 | 144 | if (this.hasAttribute('disabled')) { 145 | radios[i].removeAttribute('tabindex'); 146 | this.removeAttribute('selected'); 147 | this.selected = null; 148 | } 149 | 150 | if (!radios[i].id.startsWith('generic-radio-')) { 151 | radios[i].setAttribute('role', 'radio'); 152 | radios[i].id = `generic-radio-${this.__uuid}-${i}`; 153 | } 154 | }); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # generic-components 2 | 3 | A collection of generic web components with a focus on: 4 | 5 | - 🚹 Accessibility 6 | - 🏗 Easy to use 7 | - 🎨 Easy to style 8 | 9 | ## Goal 10 | 11 | The goal of this project is to create a common library of generic web components, that are accessible, framework agnostic, easy to style, and easy to consume. 12 | 13 | All components in these repo extend from HTMLElement and dont use any libraries or framework. 14 | 15 | You can think of these components like using a native ` 90 |
91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 |
namedescription
defaultProvide a content node as lightdom
105 |
106 | 107 | 108 | 109 |

Usage:

110 |

Simply drop the element in your page.

111 | 112 |
113 |

Default:

114 |
115 | Show code 116 |
117 |
118 |
119 | 120 | This is an alert 121 | 122 |
123 |
124 |
125 |

Custom style:

126 |
127 | Show code 128 |
136 |
137 |
138 | 139 | Here is some info 140 | 141 |
142 |

143 | 144 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | -------------------------------------------------------------------------------- /generic-visually-hidden/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | generic-components 21 | 22 | 23 | 24 |
25 | 71 |
72 |

generic-visually-hidden

73 | WAI ARIA Practices 74 | 75 |

There are occasional instances where content should be made available to screen reader 76 | users, but hidden from sighted users.

77 |

API:

78 |
79 | 80 | 81 |
82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 |
namedescription
defaultProvide the text to be visually hidden as lightdom
96 |
97 |
98 |
99 |

Usage:

100 |

Simply drop the component in your markup.

101 | 102 |
103 |

Default

104 | 105 |

Under here is some visually hidden text:

106 |
107 | Show code 108 |
109 |
110 |
111 | 112 |
113 | 114 |
115 |

Import as CSS

116 | 117 |

Alternatively you can import the global CSS file, which allows you to use a visually-hidden attribute on any arbitrary element.

118 |
Show code
121 |
122 |
123 |
I'm hidden!
124 |
125 | 126 |
127 |

Import as JS

128 | 129 |

Alternatively you can import the visually-hidden styles as JS string, this can be useful for usage in shadow roots.

130 |
Show code
138 |
139 |
140 |
141 | 142 | 143 |
144 |
145 | 146 | 147 | 148 | -------------------------------------------------------------------------------- /generic-switch/GenericSwitch.js: -------------------------------------------------------------------------------- 1 | import { KEYCODES } from '../utils/keycodes.js'; 2 | 3 | const template = document.createElement('template'); 4 | template.innerHTML = ` 5 | 59 | 60 |
61 |
62 |
63 |
64 | `; 65 | 66 | let __count = 0; 67 | 68 | /** 69 | * @element generic-switch 70 | * 71 | * @cssprop --generic-switch-focus - Customizes the focus styles of the thumb 72 | * 73 | * @csspart label 74 | * @csspart thumb 75 | * @csspart track 76 | * @csspart button 77 | * 78 | * @attr {boolean} disabled 79 | */ 80 | export class GenericSwitch extends HTMLElement { 81 | static is = 'generic-switch'; 82 | 83 | constructor() { 84 | super(); 85 | this.attachShadow({ mode: 'open' }); 86 | this.shadowRoot.appendChild(template.content.cloneNode(true)); 87 | this.__onClick = this.__onClick.bind(this); 88 | this.__onKeyDown = this.__onKeyDown.bind(this); 89 | } 90 | 91 | static get observedAttributes() { 92 | return ['disabled', 'checked', 'label']; 93 | } 94 | 95 | connectedCallback() { 96 | this.__label = this.shadowRoot.querySelector('[part="label"]'); 97 | this.__button = this.shadowRoot.querySelector('[part="button"]'); 98 | this.__track = this.shadowRoot.querySelector('[part="track"]'); 99 | this.__thumb = this.shadowRoot.querySelector('[part="thumb"]'); 100 | 101 | this.__label.id = `label-${__count}`; 102 | this.__button.id = `button-${__count}`; 103 | this.__track.id = `track-${__count}`; 104 | this.__thumb.id = `thumb-${__count}`; 105 | 106 | this.addEventListener('click', this.__onClick); 107 | this.addEventListener('keydown', this.__onKeyDown); 108 | this.__button.setAttribute('role', 'switch'); 109 | 110 | if (!this.hasAttribute('label')) { 111 | this.__button.setAttribute('aria-labelledby', `label-${__count}`); 112 | this.__button.setAttribute('aria-describedby', `label-${__count}`); 113 | this.__label.style.marginRight = '10px'; 114 | } else { 115 | this.__button.setAttribute('aria-label', this.getAttribute('label')); 116 | } 117 | 118 | this.__checked = this.hasAttribute('checked') || false; 119 | 120 | this.__update(false); 121 | this.__handleDisabled(); 122 | __count++; // eslint-disable-line 123 | } 124 | 125 | disconnectedCallback() { 126 | this.__button.removeEventListener('click', this.__onClick); 127 | this.__button.removeEventListener('keydown', this.__onKeyDown); 128 | } 129 | 130 | __handleDisabled() { 131 | if (this.hasAttribute('disabled')) { 132 | this.setAttribute('disabled', ''); 133 | this.__button.setAttribute('aria-disabled', 'true'); 134 | this.__button.removeAttribute('tabindex'); 135 | } else { 136 | this.removeAttribute('disabled'); 137 | this.__button.removeAttribute('aria-disabled'); 138 | this.__button.setAttribute('tabindex', '0'); 139 | } 140 | } 141 | 142 | __onClick() { 143 | if (!this.hasAttribute('disabled')) { 144 | if (this.hasAttribute('checked')) { 145 | this.removeAttribute('checked'); 146 | } else { 147 | this.setAttribute('checked', ''); 148 | } 149 | } 150 | } 151 | 152 | __onKeyDown(event) { 153 | switch (event.keyCode) { 154 | case KEYCODES.SPACE: 155 | case KEYCODES.ENTER: 156 | event.preventDefault(); 157 | if (this.hasAttribute('checked')) { 158 | this.removeAttribute('checked'); 159 | } else { 160 | this.setAttribute('checked', ''); 161 | } 162 | break; 163 | default: 164 | break; 165 | } 166 | } 167 | 168 | __update(dispatch) { 169 | if (this.__checked && !this.hasAttribute('disabled')) { 170 | this.__button.setAttribute('aria-checked', 'true'); 171 | this.__button.setAttribute('checked', ''); 172 | } else { 173 | this.__button.setAttribute('aria-checked', 'false'); 174 | this.__button.removeAttribute('checked'); 175 | } 176 | 177 | if (dispatch) { 178 | const { __checked } = this; 179 | this.dispatchEvent(new CustomEvent('checked-changed', { detail: __checked })); 180 | } 181 | } 182 | 183 | /** 184 | * @attr 185 | * @type {boolean} 186 | */ 187 | set checked(val) { 188 | if (val) { 189 | this.setAttribute('checked', ''); 190 | } else { 191 | this.removeAttribute('checked'); 192 | } 193 | } 194 | 195 | get checked() { 196 | return this.__checked; 197 | } 198 | 199 | attributeChangedCallback(name, oldVal, newVal) { 200 | if (!this.__button) return; 201 | if (newVal !== oldVal) { 202 | switch (name) { 203 | case 'disabled': 204 | this.__disabled = !this.__disabled; 205 | this.__handleDisabled(); 206 | break; 207 | case 'checked': 208 | this.__checked = !this.__checked; 209 | this.__update(true); 210 | break; 211 | case 'label': 212 | this.__button.setAttribute('aria-label', newVal); 213 | break; 214 | default: 215 | break; 216 | } 217 | } 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /demo/demo-app.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --col-dark: #2f3136; 3 | --col-darker: #26272b; 4 | --col-light: #36393e; 5 | --col-active: #83fbc3; 6 | --switch-track: #73767d; 7 | --switch-thumb: white; 8 | } 9 | 10 | html, 11 | body { 12 | margin: 0; 13 | padding: 0; 14 | background-color: var(--col-dark); 15 | } 16 | 17 | *:focus { 18 | outline: 0; 19 | border-radius: 5px; 20 | box-shadow: 0 0 0 3px var(--col-active)!important; 21 | transition: box-shadow .1s ease-in-out; 22 | } 23 | 24 | * { 25 | font-family: 'Montserrat', sans-serif; 26 | } 27 | 28 | .app { 29 | width: 100vw; 30 | height: 100vh; 31 | display: flex; 32 | } 33 | 34 | .banner { 35 | position: fixed; 36 | width: 100%; 37 | display: flex; 38 | align-items: center; 39 | justify-content: center; 40 | height: 60px; 41 | background-color: var(--col-dark); 42 | box-shadow: 0px 5px 5px 0px rgba(0,0,0,0.30); 43 | } 44 | 45 | .banner h1 { 46 | margin: 0; 47 | font-size: 30px; 48 | color: white; 49 | } 50 | 51 | .banner h1 a { 52 | color: white; 53 | text-decoration: none; 54 | } 55 | 56 | .banner h1 a:visited { 57 | color: white; 58 | } 59 | 60 | .content { 61 | color: white; 62 | padding: 80px; 63 | width: calc(100% - 160px); 64 | height: calc(100% - 160px); 65 | background-color: var(--col-light); 66 | } 67 | 68 | .content p { 69 | font-size: 14px; 70 | } 71 | 72 | .sidebar { 73 | flex-shrink: 0; 74 | padding-left: 20px; 75 | padding-right: 20px; 76 | padding-top: 80px; 77 | width: calc(400px - 40px); 78 | background-color: var(--col-dark); 79 | height: calc(100% - 80px); 80 | } 81 | 82 | .panel-button { 83 | flex: 1; 84 | border-radius: 5px; 85 | color: white; 86 | font-family: 'Montserrat',sans-serif; 87 | border: 0; 88 | margin: 0; 89 | padding: 0 15px 0 15px; 90 | width: auto; 91 | overflow: visible; 92 | background: transparent; 93 | font-weight: 700; 94 | font-size: 13px; 95 | padding-top: 13px; 96 | padding-bottom: 13px; 97 | text-transform: uppercase; 98 | } 99 | 100 | button[selected] { 101 | color: var(--col-active); 102 | text-decoration: underline; 103 | } 104 | 105 | div[slot="panel"] { 106 | font-size: 14px; 107 | color: white; 108 | } 109 | 110 | .panel-button:hover { 111 | background-color: var(--col-darker); 112 | } 113 | 114 | generic-disclosure button { 115 | color: white; 116 | font-family: 'Montserrat',sans-serif; 117 | border: 0; 118 | margin: 0; 119 | padding: 0; 120 | width: 100%; 121 | overflow: visible; 122 | background: transparent; 123 | font-weight: 700; 124 | font-size: 21px; 125 | padding-top: 15px; 126 | padding-bottom: 15px; 127 | } 128 | 129 | generic-disclosure button:focus { 130 | outline: 0; 131 | border-radius: 5px; 132 | box-shadow: 0 0 0 3px var(--col-active)!important; 133 | transition: box-shadow .1s ease-in-out; 134 | } 135 | 136 | generic-disclosure button:hover { 137 | background-color: var(--col-darker); 138 | } 139 | 140 | .dialog-button { 141 | background-color: #8ea2ea; 142 | color: black; 143 | border: 0; 144 | border-radius: 5px; 145 | font-weight: bold; 146 | display: block; 147 | padding: 10px 20px; 148 | } 149 | 150 | 151 | generic-dialog-overlay::part(dialog) { 152 | width: 400px; 153 | height: 400px; 154 | background-color: var(--col-dark); 155 | color: white; 156 | border-radius: 10px; 157 | padding: 20px; 158 | } 159 | 160 | generic-dialog-overlay::part(dialog) .dialog-button { 161 | background-color: #8ea2ea; 162 | color: black; 163 | border: 0; 164 | border-radius: 5px; 165 | font-weight: bold; 166 | display: block; 167 | padding: 10px 20px; 168 | } 169 | 170 | generic-dialog-overlay::part(dialog) .dialog-button:hover { 171 | background-color: #768ad4; 172 | } 173 | 174 | .centered { 175 | margin-left: auto; 176 | margin-top: 60px; 177 | margin-bottom: 60px; 178 | margin-right: auto; 179 | } 180 | 181 | .dialog-button:hover { 182 | background-color: #768ad4; 183 | } 184 | 185 | generic-alert { 186 | padding: 10px; 187 | border: 1px solid black; 188 | border-radius: 4px; 189 | background: var(--col-darker); 190 | } 191 | 192 | generic-switch { 193 | display: flex; 194 | margin-top:20px; 195 | margin-bottom:20px; 196 | --generic-switch-focus: var(--col-active); 197 | } 198 | 199 | a { 200 | color: var(--col-active); 201 | } 202 | 203 | generic-skiplink::part(anchor) { 204 | background-color: var(--col-light); 205 | color: var(--col-active); 206 | top: 4px; 207 | } 208 | 209 | generic-skiplink::part(anchor):focus { 210 | outline: 0; 211 | border-radius: 5px; 212 | box-shadow: 0 0 0 3px var(--col-active)!important; 213 | transition: box-shadow .1s ease-in-out; 214 | left: 2px; 215 | } 216 | 217 | generic-switch::part(track) { 218 | border-top-left-radius: 8px; 219 | height: 100%; 220 | background-color: var(--switch-track); 221 | border-top-right-radius: 8px; 222 | border-bottom-right-radius: 8px; 223 | border-bottom-left-radius: 8px; 224 | } 225 | 226 | generic-switch::part(thumb){ 227 | margin-top: -2px; 228 | border-radius: 50%; 229 | background-color: var(--switch-thumb); 230 | width: 16px; 231 | border: solid 2px var(--col-dark); 232 | } 233 | 234 | generic-switch[checked]::part(thumb) { 235 | right: -3px; 236 | } 237 | 238 | generic-switch::part(button):focus { 239 | outline: 0; 240 | border-radius: 5px; 241 | box-shadow: 0 0 0 3px var(--col-active)!important; 242 | transition: box-shadow .1s ease-in-out; 243 | } 244 | /* 245 | generic-switch::part(label) { 246 | padding-right: 20px; 247 | } */ 248 | 249 | generic-accordion { 250 | margin-top: 40px; 251 | } 252 | 253 | generic-accordion button { 254 | font-weight: 700; 255 | font-size: 16px; 256 | color:black; 257 | 258 | border-radius: 5px; 259 | border: 0; 260 | margin: 0; 261 | overflow: visible; 262 | background: transparent; 263 | padding-top: 13px; 264 | padding-bottom: 13px; 265 | background-color: #8ea2ea; 266 | margin-bottom: 5px; 267 | } 268 | 269 | generic-accordion button:hover, generic-accordion button:focus { 270 | background-color: #768ad4; 271 | } 272 | 273 | generic-accordion[aria-expanded="true"] button { 274 | color: var(--col-active); 275 | } 276 | 277 | generic-accordion div[role="region"] { 278 | background-color: var(--col-light); 279 | padding: 20px; 280 | border: solid 1px black; 281 | } 282 | 283 | generic-accordion div[role="region"] p { 284 | margin-top: 0; 285 | margin-bottom: 0; 286 | } 287 | 288 | #switchAlert { 289 | display: none; 290 | } 291 | 292 | generic-accordion a { 293 | display: block; 294 | margin-top: 15px; 295 | margin-bottom: 15px; 296 | } 297 | 298 | 299 | @media (max-width: 768px) { 300 | .app { 301 | flex-direction: column; 302 | } 303 | 304 | .sidebar { 305 | width: calc(100% - 40px); 306 | height: 100%; 307 | padding-bottom: 40px; 308 | } 309 | } -------------------------------------------------------------------------------- /demo/demo-app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 28 | 29 | generic-components 30 | 31 | 32 | 33 |
34 | 38 | 120 | 121 |
122 |

Here's some content!

123 |

This is a simple showcase app built with generic-components.

124 |

The goal of this project is to create a common library of generic web components, that are accessible, 125 | framework agnostic, easy to style, and easy to consume.

126 |

The components in this repo are based on WAI Aria 127 | practices.

128 | Built with generic-components 129 |
130 |
131 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | -------------------------------------------------------------------------------- /legacy/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 17 | 18 | generic-components 19 | 20 | 21 | 22 |
23 | 66 |
67 | 68 |

generic-react-components

69 |

A library of non-reusable, but accessible components, for your legacy projects

70 | 71 |

Usage

72 |

Via npm

73 |

Make sure you've installed all your required dependencies

74 |
npm i @babel/core babel-loader @babel/preset-env @babel/preset-react webpack webpack-cli react react-dom redux react-redux html-webpack-plugin are-you-tired-yet html-loader webpack-dev-server
75 |

Then install generic components

76 |
npm i --save @generic-components/components
77 |

And import in your code via ES imports:

78 |
import { GenericSwitch } from '@generic-components/components/legacy/GenericSwitch.jsx';
79 | 80 |

Examples

81 |

GenericSwitch

82 | Toggle me 83 |
84 |

85 | 86 | 87 |
93 | 94 |

GenericDisclosure

95 | 96 | 97 |
Hello world
98 |
99 |
104 | 105 | 106 |

GenericTabs

107 | 108 | 111 |
112 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

113 |
114 | 115 | 118 |
119 |

Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.

120 |
121 | 122 | 125 |
126 |

Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur.

127 |
128 |
129 |
156 | 157 |

Fuckery aside, these components were automatically generated by a @custom-elements-manifest/analyzer plugin, which is pretty cool.

158 |
159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | -------------------------------------------------------------------------------- /generic-spinner/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 28 | 29 | generic-components 30 | 31 | 32 | 33 |
34 | 80 |
81 |

generic-spinner

82 | 83 |

API:

84 |
85 | 86 | 87 |
88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 |
nametype
labelstring
102 |
103 |
104 | 105 | 106 |
107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 |
namedescription
spinner
circle
125 |
126 |
127 | 128 | 129 |
130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 |
namedescription
--generic-spinner-widthControls the width of the spinner
--generic-spinner-heightControls the height of the spinner
--generic-spinner-colorControls the color of the spinner
--generic-spinner-stroke-widthControls the stroke width of the spinner
156 |
157 |
158 |
159 |

Usage:

160 |

Simply drop the component in your markup.

161 | 162 |
163 |

Default

164 | 165 |

166 |
167 | Show code 168 |
169 |
170 |
171 | 172 |
173 | 174 |
175 |

Custom label

176 | 177 |

178 |
179 | Show code 180 |
181 |
182 |
183 | 184 |
185 | 186 |
187 |

Using CSS Parts

188 | 189 |

190 |
191 | Show code 192 |
198 |
199 |
200 | 201 |
202 | 203 |
204 |

Using CSS Custom Properties

205 | 206 |

207 |
208 | Show code 209 |
217 |
218 |
219 | 220 |
221 | 222 | 223 |
224 |
225 | 226 | 227 | 228 | -------------------------------------------------------------------------------- /generic-disclosure/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 31 | 32 | generic-components 33 | 34 | 35 | 36 |
37 | 83 |
84 |

generic-disclosure

85 | WAI ARIA Practices 86 | 87 |

A disclosure is a button that controls visibility of a section of content.

88 | 89 | 90 |

API:

91 |
92 | 93 | 94 |
95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 |
namedetail
opened-changedboolean
109 |
110 |
111 | 112 | 113 |
114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 |
nametype
expandedboolean
128 |
129 |
130 | 131 | 132 |
133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 |
nametype
expandedboolean
147 |
148 |
149 | 150 | 151 |
152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 |
namedescription
toggleProvide a button element as lightdom
detailProvide a content node as lightdom
170 |
171 |
172 |
173 | 174 |

Usage:

175 |

The disclosure component requires a button child with a slot="toggle" for the trigger, and a 176 | generic content node slot="detail" for the content.

177 | 178 |
179 |

Default:

180 |
181 | Show code 182 |
186 |
187 |
188 | 189 | 190 |

i am content

191 |
192 |
193 | 194 |
195 |

Expanded:

196 |
197 | Show code 198 |
202 |
203 |
204 | 205 | 206 |

i am expanded!

207 |
208 |
209 |
210 | 211 | 227 | 228 | 229 |
230 |
231 | 232 | 233 | 234 | -------------------------------------------------------------------------------- /generic-skiplink/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 33 | 34 | generic-components 35 | 36 | 37 | 38 | 39 | Go to main content 40 | Continue to main, used with global CSS 41 |
42 | 43 |
44 | 90 |
91 |

generic-skiplink

92 | WebAIM 93 | 94 | 95 |

Skiplinks allow users to quickly reach the main content of a page.

96 |

API:

97 |
98 | 99 | 100 |
101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 |
nametype
forstring
115 |
116 |
117 | 118 | 119 |
120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 |
namedescription
defaultProvide the text for the skiplink as lightdom
134 |
135 |
136 | 137 | 138 |
139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 |
namedescription
anchor
153 |
154 |
155 |
156 |

Usage:

157 |

The skiplink component requires a for="id" attribute, this id should correspond to the id of the 158 | element you want to skip to. See the examples for more information.

159 |

You can then place the component at the top of your body.

160 | 161 |
162 |

Default:

163 |

Try tabbing from the browser URL bar into the page, and the skiplink will show up.

164 |
165 |
166 | Show code 167 |
169 |
170 |
171 | 172 |
173 |

Custom styles:

174 |

You can override styles like so:

175 |
176 |
177 | Show code 178 |
186 |
187 |
188 | 189 |
190 |

Import as CSS

191 |

Alternatively you can import the global CSS file, which allows you to use a skiplink attribute on an anchor tag.

192 |
193 |
Show code
196 |
197 |
198 | 199 |
200 |

Import as JS

201 |

Alternatively you can import the skiplink styles as JS string, this can be useful for usage in shadow roots.

202 |
203 |
Show code
210 |
211 |
212 | 213 | 219 | 220 | 221 |
222 |
223 | 224 | 225 | 226 | -------------------------------------------------------------------------------- /generic-accordion/test/generic-accordion.test.js: -------------------------------------------------------------------------------- 1 | import { html, fixture, expect, fixtureSync } from '@open-wc/testing'; 2 | import { stub } from 'sinon'; 3 | import '../../accordion.js'; 4 | 5 | const defaultFixture = html` 6 | 7 | 8 |
9 |

foo

10 | asdfa 11 |

foo

12 |
13 | 14 | 15 |
Hi
16 | 17 | 18 |
Bar
19 |
20 | `; 21 | 22 | describe('generic-accordion', () => { 23 | it('a11y', async () => { 24 | const el = await fixture(defaultFixture); 25 | 26 | await expect(el).to.be.accessible(); 27 | }); 28 | 29 | it('has the required aria attributes', async () => { 30 | const el = await fixture(defaultFixture); 31 | const btns = el.querySelectorAll('button'); 32 | const regions = el.querySelectorAll('[role="region"]'); 33 | 34 | expect(regions[0].hasAttribute('aria-labelledby')).to.equal(true); 35 | expect(btns[0].id.startsWith('generic')).to.equal(true); 36 | expect(btns[0].getAttribute('aria-expanded')).to.equal('true'); 37 | expect(btns[0].getAttribute('aria-disabled')).to.equal('true'); 38 | expect(btns[1].getAttribute('aria-expanded')).to.equal('false'); 39 | }); 40 | 41 | it('has the required aria attributes', async () => { 42 | const el = await fixture(defaultFixture); 43 | const btns = el.querySelectorAll('button'); 44 | const regions = el.querySelectorAll('[role="region"]'); 45 | 46 | expect(regions[0].hasAttribute('aria-labelledby')).to.equal(true); 47 | expect(regions[1].hasAttribute('hidden')).to.equal(true); 48 | expect(btns[0].getAttribute('aria-expanded')).to.equal('true'); 49 | expect(btns[1].getAttribute('aria-expanded')).to.equal('false'); 50 | }); 51 | 52 | it('reacts to click', async () => { 53 | const el = await fixture(defaultFixture); 54 | const btns = el.querySelectorAll('button'); 55 | const regions = el.querySelectorAll('[role="region"]'); 56 | 57 | btns[1].click(); 58 | await el.updateComplete; 59 | 60 | expect(btns[1].getAttribute('aria-expanded')).to.equal('true'); 61 | expect(regions[1].hasAttribute('hidden')).to.equal(false); 62 | }); 63 | 64 | it('still works if moved around in the dom', async () => { 65 | const el = await fixture(defaultFixture); 66 | const btns = el.querySelectorAll('button'); 67 | const regions = el.querySelectorAll('[role="region"]'); 68 | 69 | btns[1].click(); 70 | await el.updateComplete; 71 | 72 | expect(btns[1].getAttribute('aria-expanded')).to.equal('true'); 73 | expect(regions[1].hasAttribute('hidden')).to.equal(false); 74 | 75 | // still works after moving element around in the dom 76 | const wrapper = await fixture( 77 | html` 78 |
79 | `, 80 | ); 81 | wrapper.appendChild(el); 82 | 83 | btns[2].click(); 84 | await el.updateComplete; 85 | 86 | expect(btns[2].getAttribute('aria-expanded')).to.equal('true'); 87 | expect(regions[2].hasAttribute('hidden')).to.equal(false); 88 | }); 89 | 90 | it('reacts to slotchanged', async () => { 91 | const el = await fixture(defaultFixture); 92 | const btns = el.querySelectorAll('button'); 93 | const regions = el.querySelectorAll('[role="region"]'); 94 | 95 | btns[1].click(); 96 | await el.updateComplete; 97 | 98 | expect(btns[1].getAttribute('aria-expanded')).to.equal('true'); 99 | expect(regions[1].hasAttribute('hidden')).to.equal(false); 100 | 101 | const div = document.createElement('div'); 102 | const btn = document.createElement('button'); 103 | div.setAttribute('role', 'region'); 104 | el.append(btn); 105 | el.append(div); 106 | 107 | await el.updateComplete; 108 | 109 | expect(el.querySelectorAll('button')[3].getAttribute('aria-expanded')).to.equal('false'); 110 | expect(el.querySelectorAll('[role="region"]')[3].hasAttribute('hidden')).to.equal(true); 111 | }); 112 | 113 | it('reacts to selected property change', async () => { 114 | const el = await fixture(defaultFixture); 115 | const btns = el.querySelectorAll('button'); 116 | const regions = el.querySelectorAll('[role="region"]'); 117 | 118 | el.selected = 1; 119 | await el.updateComplete; 120 | 121 | expect(btns[1].getAttribute('aria-expanded')).to.equal('true'); 122 | expect(regions[1].hasAttribute('hidden')).to.equal(false); 123 | }); 124 | 125 | describe('keycodes', () => { 126 | it('down', async () => { 127 | const el = await fixture(defaultFixture); 128 | const btns = el.querySelectorAll('button'); 129 | 130 | el.__onKeyDown({ preventDefault: () => {}, keyCode: 40 }); 131 | 132 | expect(btns[1]).to.equal(document.activeElement); 133 | }); 134 | 135 | it('double down', async () => { 136 | const el = await fixture(defaultFixture); 137 | const btns = el.querySelectorAll('button'); 138 | 139 | el.__onKeyDown({ preventDefault: () => {}, keyCode: 40 }); 140 | el.__onKeyDown({ preventDefault: () => {}, keyCode: 40 }); 141 | 142 | expect(btns[2]).to.equal(document.activeElement); 143 | }); 144 | 145 | it('up', async () => { 146 | const el = await fixture(defaultFixture); 147 | const btns = el.querySelectorAll('button'); 148 | 149 | el.__onKeyDown({ preventDefault: () => {}, keyCode: 38 }); 150 | 151 | expect(btns[2]).to.equal(document.activeElement); 152 | }); 153 | 154 | it('double up', async () => { 155 | const el = await fixture(defaultFixture); 156 | const btns = el.querySelectorAll('button'); 157 | 158 | el.__onKeyDown({ preventDefault: () => {}, keyCode: 38 }); 159 | el.__onKeyDown({ preventDefault: () => {}, keyCode: 38 }); 160 | 161 | expect(btns[1]).to.equal(document.activeElement); 162 | }); 163 | 164 | it('home', async () => { 165 | const el = await fixture(defaultFixture); 166 | const btns = el.querySelectorAll('button'); 167 | 168 | el.__onKeyDown({ preventDefault: () => {}, keyCode: 36 }); 169 | 170 | expect(btns[0]).to.equal(document.activeElement); 171 | }); 172 | 173 | it('end', async () => { 174 | const el = await fixture(defaultFixture); 175 | const btns = el.querySelectorAll('button'); 176 | 177 | el.__onKeyDown({ preventDefault: () => {}, keyCode: 35 }); 178 | 179 | expect(btns[2]).to.equal(document.activeElement); 180 | }); 181 | }); 182 | 183 | describe('events', () => { 184 | it('doesnt dispatch on first update', async () => { 185 | const el = fixtureSync(defaultFixture); 186 | const dispatchStub = stub(el, '__dispatch'); 187 | await el.updateComplete; 188 | expect(dispatchStub).callCount(0); 189 | dispatchStub.restore(); 190 | }); 191 | 192 | it('doesnt dispatch on slotchange', async () => { 193 | const el = await fixture(defaultFixture); 194 | const dispatchStub = stub(el, '__dispatch'); 195 | 196 | const div = document.createElement('div'); 197 | const btn = document.createElement('button'); 198 | div.setAttribute('role', 'region'); 199 | el.append(btn); 200 | el.append(div); 201 | 202 | await el.updateComplete; 203 | expect(dispatchStub).callCount(0); 204 | dispatchStub.restore(); 205 | }); 206 | 207 | it('doesnt dispatch on keydown', async () => { 208 | const el = await fixture(defaultFixture); 209 | const dispatchStub = stub(el, '__dispatch'); 210 | 211 | el.__onKeyDown({ preventDefault: () => {}, keyCode: 40 }); 212 | expect(dispatchStub).callCount(0); 213 | dispatchStub.restore(); 214 | }); 215 | 216 | it('fires event on attr change', async () => { 217 | const el = await fixture(defaultFixture); 218 | const dispatchStub = stub(el, '__dispatch'); 219 | el.setAttribute('selected', '1'); 220 | await el.updateComplete; 221 | expect(dispatchStub).callCount(1); 222 | dispatchStub.restore(); 223 | }); 224 | 225 | it('fires event on prop change', async () => { 226 | const el = await fixture(defaultFixture); 227 | const dispatchStub = stub(el, '__dispatch'); 228 | el.selected = 1; 229 | await el.updateComplete; 230 | expect(dispatchStub).callCount(1); 231 | dispatchStub.restore(); 232 | }); 233 | 234 | it('fires event on click', async () => { 235 | const el = await fixture(defaultFixture); 236 | const dispatchStub = stub(el, '__dispatch'); 237 | el.querySelectorAll('button')[1].click(); 238 | await el.updateComplete; 239 | expect(dispatchStub).callCount(1); 240 | dispatchStub.restore(); 241 | }); 242 | }); 243 | }); 244 | -------------------------------------------------------------------------------- /generic-listbox/test/generic-listbox.test.js: -------------------------------------------------------------------------------- 1 | import { html, fixture, expect, fixtureSync } from '@open-wc/testing'; 2 | import { stub } from 'sinon'; 3 | import '../../listbox.js'; 4 | 5 | const defaultFixture = html` 6 | 7 | 26 | 27 | `; 28 | 29 | describe('generic-listbox', () => { 30 | it('a11y', async () => { 31 | const el = await fixture(defaultFixture); 32 | 33 | await expect(el).to.be.accessible(); 34 | }); 35 | 36 | it('has the required aria attributes', async () => { 37 | const el = await fixture(defaultFixture); 38 | 39 | const ul = el.querySelector('ul'); 40 | const firstLi = el.querySelectorAll('li')[0]; 41 | const secondLi = el.querySelectorAll('li')[1]; 42 | 43 | // ul 44 | expect(ul.getAttribute('role')).to.equal('listbox'); 45 | expect(ul.getAttribute('tabindex')).to.equal('0'); 46 | expect(ul.getAttribute('aria-label')).to.equal('list of items'); 47 | expect(ul.getAttribute('aria-activedescendant')).to.equal('generic-listbox-1-0'); 48 | 49 | // 1st list item 50 | expect(firstLi.id).to.equal('generic-listbox-1-0'); 51 | expect(firstLi.getAttribute('role')).to.equal('option'); 52 | expect(firstLi.getAttribute('aria-selected')).to.equal('true'); 53 | expect(firstLi.hasAttribute('selected')).to.equal(true); 54 | 55 | // 2nd list item 56 | expect(secondLi.id).to.equal('generic-listbox-1-1'); 57 | expect(secondLi.getAttribute('role')).to.equal('option'); 58 | expect(secondLi.hasAttribute('aria-selected')).to.equal(false); 59 | expect(secondLi.hasAttribute('selected')).to.equal(false); 60 | }); 61 | 62 | it('changes selected on click', async () => { 63 | const el = await fixture(defaultFixture); 64 | 65 | const ul = el.querySelector('ul'); 66 | const firstLi = el.querySelectorAll('li')[1]; 67 | 68 | expect(ul.getAttribute('aria-activedescendant')).to.equal('generic-listbox-2-0'); 69 | expect(firstLi.hasAttribute('aria-selected')).to.equal(false); 70 | expect(firstLi.hasAttribute('selected')).to.equal(false); 71 | 72 | firstLi.click(); 73 | await el.updateComplete; 74 | 75 | expect(ul.getAttribute('aria-activedescendant')).to.equal('generic-listbox-2-1'); 76 | expect(firstLi.getAttribute('aria-selected')).to.equal('true'); 77 | expect(firstLi.hasAttribute('selected')).to.equal(true); 78 | }); 79 | 80 | it('changes selected on click', async () => { 81 | const el = await fixture(defaultFixture); 82 | 83 | const ul = el.querySelector('ul'); 84 | const listItems = el.querySelectorAll('li'); 85 | 86 | expect(ul.getAttribute('aria-activedescendant')).to.equal('generic-listbox-3-0'); 87 | expect(listItems[1].hasAttribute('aria-selected')).to.equal(false); 88 | expect(listItems[1].hasAttribute('selected')).to.equal(false); 89 | 90 | listItems[1].click(); 91 | await el.updateComplete; 92 | 93 | expect(ul.getAttribute('aria-activedescendant')).to.equal('generic-listbox-3-1'); 94 | expect(listItems[1].getAttribute('aria-selected')).to.equal('true'); 95 | expect(listItems[1].hasAttribute('selected')).to.equal(true); 96 | 97 | // still works after moving element around in the dom 98 | const wrapper = await fixture( 99 | html` 100 |
101 | `, 102 | ); 103 | wrapper.appendChild(el); 104 | 105 | listItems[2].click(); 106 | await el.updateComplete; 107 | 108 | expect(ul.getAttribute('aria-activedescendant')).to.equal('generic-listbox-3-2'); 109 | expect(listItems[2].getAttribute('aria-selected')).to.equal('true'); 110 | expect(listItems[2].hasAttribute('selected')).to.equal(true); 111 | }); 112 | 113 | it('reacts to selected property changed', async () => { 114 | const el = await fixture(defaultFixture); 115 | 116 | const ul = el.querySelector('ul'); 117 | const firstLi = el.querySelectorAll('li')[1]; 118 | 119 | expect(ul.getAttribute('aria-activedescendant')).to.equal('generic-listbox-4-0'); 120 | expect(firstLi.hasAttribute('aria-selected')).to.equal(false); 121 | expect(firstLi.hasAttribute('selected')).to.equal(false); 122 | 123 | el.selected = 1; 124 | await el.updateComplete; 125 | 126 | expect(ul.getAttribute('aria-activedescendant')).to.equal('generic-listbox-4-1'); 127 | expect(firstLi.getAttribute('aria-selected')).to.equal('true'); 128 | expect(firstLi.hasAttribute('selected')).to.equal(true); 129 | }); 130 | 131 | // @TODO: do I expect listbox to fire an event on keydown? 132 | 133 | describe('keycodes', () => { 134 | it('up', async () => { 135 | const el = await fixture(defaultFixture); 136 | const ul = el.querySelector('ul'); 137 | const li = el.querySelectorAll('li'); 138 | 139 | el.__onKeyDown({ preventDefault: () => {}, keyCode: 38, target: { localName: 'ul' } }); 140 | await el.updateComplete; 141 | 142 | expect(ul.getAttribute('aria-activedescendant')).to.equal('generic-listbox-5-16'); 143 | expect(li[16].getAttribute('aria-selected')).to.equal('true'); 144 | expect(li[16].hasAttribute('selected')).to.equal(true); 145 | }); 146 | 147 | it('down', async () => { 148 | const el = await fixture(defaultFixture); 149 | const ul = el.querySelector('ul'); 150 | const li = el.querySelectorAll('li'); 151 | 152 | el.__onKeyDown({ preventDefault: () => {}, keyCode: 40, target: { localName: 'ul' } }); 153 | await el.updateComplete; 154 | 155 | expect(ul.getAttribute('aria-activedescendant')).to.equal('generic-listbox-6-1'); 156 | expect(li[1].getAttribute('aria-selected')).to.equal('true'); 157 | expect(li[1].hasAttribute('selected')).to.equal(true); 158 | }); 159 | 160 | it('end and home', async () => { 161 | const el = await fixture(defaultFixture); 162 | const ul = el.querySelector('ul'); 163 | const li = el.querySelectorAll('li'); 164 | 165 | el.__onKeyDown({ preventDefault: () => {}, keyCode: 35, target: { localName: 'ul' } }); 166 | await el.updateComplete; 167 | 168 | expect(ul.getAttribute('aria-activedescendant')).to.equal('generic-listbox-7-16'); 169 | expect(li[16].getAttribute('aria-selected')).to.equal('true'); 170 | expect(li[16].hasAttribute('selected')).to.equal(true); 171 | 172 | el.__onKeyDown({ preventDefault: () => {}, keyCode: 36, target: { localName: 'ul' } }); 173 | await el.updateComplete; 174 | 175 | expect(ul.getAttribute('aria-activedescendant')).to.equal('generic-listbox-7-0'); 176 | expect(li[0].getAttribute('aria-selected')).to.equal('true'); 177 | expect(li[0].hasAttribute('selected')).to.equal(true); 178 | }); 179 | }); 180 | 181 | describe('events', () => { 182 | it('doesnt dispatch on first update', async () => { 183 | const el = fixtureSync(defaultFixture); 184 | const dispatchStub = stub(el, '__dispatch'); 185 | await el.updateComplete; 186 | expect(dispatchStub).callCount(0); 187 | dispatchStub.restore(); 188 | }); 189 | 190 | it('fires event on attr change', async () => { 191 | const el = await fixture(defaultFixture); 192 | const dispatchStub = stub(el, '__dispatch'); 193 | el.setAttribute('selected', '1'); 194 | await el.updateComplete; 195 | expect(dispatchStub).callCount(1); 196 | dispatchStub.restore(); 197 | }); 198 | 199 | it('fires event on prop change', async () => { 200 | const el = await fixture(defaultFixture); 201 | const dispatchStub = stub(el, '__dispatch'); 202 | el.selected = 1; 203 | await el.updateComplete; 204 | expect(dispatchStub).callCount(1); 205 | dispatchStub.restore(); 206 | }); 207 | 208 | it('fires event on keydown', async () => { 209 | const el = await fixture(defaultFixture); 210 | const dispatchStub = stub(el, '__dispatch'); 211 | el.__onKeyDown({ preventDefault: () => {}, keyCode: 40 }); 212 | await el.updateComplete; 213 | expect(dispatchStub).callCount(1); 214 | dispatchStub.restore(); 215 | }); 216 | 217 | it('fires event on click', async () => { 218 | const el = await fixture(defaultFixture); 219 | const dispatchStub = stub(el, '__dispatch'); 220 | el.querySelectorAll('li')[1].click(); 221 | await el.updateComplete; 222 | expect(dispatchStub).callCount(1); 223 | dispatchStub.restore(); 224 | }); 225 | }); 226 | }); 227 | -------------------------------------------------------------------------------- /generic-listbox/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 24 | generic-components 25 | 26 | 27 | 28 |
29 | 75 |
76 |

generic-listbox

77 | WAI ARIA Practices 78 | 79 |

A listbox widget presents a list of options and allows a user to select one or more of 80 | them.

81 |

API:

82 |
83 | 84 | 85 |
86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 |
namedetail
selected-changednumber
100 |
101 |
102 | 103 | 104 |
105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 |
nametype
selectednumber
labelstring
123 |
124 |
125 | 126 | 127 |
128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 |
nametype
selectednumber
valuestring
146 |
147 |
148 | 149 | 150 |
151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 |
namedescription
defaultProvide an unordered list element with list item children as lightdom
165 |
166 |
167 |
168 | 169 |

Usage:

170 |

The listbox requires a ul with li children, as well as a label 171 | attribute. 172 |

173 |

Default:

174 |
175 | Show code 176 |
198 |
199 |
200 | 201 |
    202 |
  • item 1
  • 203 |
  • item 2
  • 204 |
  • item 3
  • 205 |
  • item 4
  • 206 |
  • item 5
  • 207 |
  • item 6
  • 208 |
  • item 7
  • 209 |
  • item 8
  • 210 |
  • item 9
  • 211 |
  • item 10
  • 212 |
  • item 11
  • 213 |
  • item 12
  • 214 |
  • item 13
  • 215 |
  • item 14
  • 216 |
  • item 15
  • 217 |
  • item 16
  • 218 |
  • item 17
  • 219 | 220 |
221 | 222 |
223 |

Selected:

224 |
225 | Show code 226 |
248 |
249 |
250 | 251 |
    252 |
  • item 1
  • 253 |
  • item 2
  • 254 |
  • item 3
  • 255 |
  • item 4
  • 256 |
  • item 5
  • 257 |
  • item 6
  • 258 |
  • item 7
  • 259 |
  • item 8
  • 260 |
  • item 9
  • 261 |
  • item 10
  • 262 |
  • item 11
  • 263 |
  • item 12
  • 264 |
  • item 13
  • 265 |
  • item 14
  • 266 |
  • item 15
  • 267 |
  • item 16
  • 268 |
  • item 17
  • 269 |
270 |
271 |
272 | 273 | 274 |
275 |
276 | 277 | 278 | 279 | -------------------------------------------------------------------------------- /generic-switch/test/generic-switch.test.js: -------------------------------------------------------------------------------- 1 | import { html, fixture, expect } from '@open-wc/testing'; 2 | import { stub } from 'sinon'; 3 | import '../../switch.js'; 4 | 5 | describe('generic-switch', () => { 6 | it('a11y', async () => { 7 | const el = await fixture(html` 8 | 9 | `); 10 | 11 | await expect(el).to.be.accessible(); 12 | }); 13 | 14 | describe('events', () => { 15 | it('doesnt fire an event on first update', async () => { 16 | const el = await fixture(html` 17 | 18 | `); 19 | const dispatchStub = stub(el, 'dispatchEvent'); 20 | el.connectedCallback(); 21 | expect(dispatchStub).callCount(0); 22 | dispatchStub.restore(); 23 | }); 24 | 25 | it('doesnt fire an event on first update when checked', async () => { 26 | const el = await fixture(html` 27 | 28 | `); 29 | const dispatchStub = stub(el, 'dispatchEvent'); 30 | el.connectedCallback(); 31 | expect(dispatchStub).callCount(0); 32 | dispatchStub.restore(); 33 | }); 34 | 35 | it('doesnt fire an event on disabled change', async () => { 36 | const el = await fixture(html` 37 | 38 | `); 39 | const dispatchStub = stub(el, 'dispatchEvent'); 40 | el.setAttribute('disabled', ''); 41 | expect(dispatchStub).callCount(0); 42 | dispatchStub.restore(); 43 | }); 44 | 45 | it('fires an event on checked attr change', async () => { 46 | const el = await fixture(html` 47 | 48 | `); 49 | const dispatchStub = stub(el, 'dispatchEvent'); 50 | el.setAttribute('checked', ''); 51 | expect(dispatchStub).callCount(1); 52 | dispatchStub.restore(); 53 | }); 54 | 55 | it('fires an event on checked property change', async () => { 56 | const el = await fixture(html` 57 | 58 | `); 59 | const dispatchStub = stub(el, 'dispatchEvent'); 60 | el.checked = true; 61 | expect(dispatchStub).callCount(1); 62 | dispatchStub.restore(); 63 | }); 64 | 65 | it('fires event on keydown', async () => { 66 | const el = await fixture(html` 67 | 68 | `); 69 | const dispatchStub = stub(el, 'dispatchEvent'); 70 | el.__onKeyDown({ preventDefault: () => {}, keyCode: 13 }); 71 | el.__onKeyDown({ preventDefault: () => {}, keyCode: 32 }); 72 | expect(dispatchStub).callCount(2); 73 | dispatchStub.restore(); 74 | }); 75 | 76 | it('fires event on click', async () => { 77 | const el = await fixture(html` 78 | 79 | `); 80 | const dispatchStub = stub(el, 'dispatchEvent'); 81 | el.click(); 82 | expect(dispatchStub).callCount(1); 83 | dispatchStub.restore(); 84 | }); 85 | }); 86 | 87 | it('property to attr', async () => { 88 | const el = await fixture(html` 89 | 90 | `); 91 | const dispatchStub = stub(el, 'dispatchEvent'); 92 | 93 | el.checked = true; 94 | expect(el.hasAttribute('checked')).to.equal(true); 95 | 96 | el.checked = false; 97 | expect(el.hasAttribute('checked')).to.equal(false); 98 | 99 | expect(dispatchStub).callCount(2); 100 | }); 101 | 102 | it('attr to prop', async () => { 103 | const el = await fixture(html` 104 | 105 | `); 106 | const dispatchStub = stub(el, 'dispatchEvent'); 107 | 108 | el.setAttribute('checked', ''); 109 | expect(el.checked).to.equal(true); 110 | 111 | el.removeAttribute('checked'); 112 | expect(el.checked).to.equal(false); 113 | 114 | expect(dispatchStub).callCount(2); 115 | }); 116 | 117 | it('has role switch and tabindex 0', async () => { 118 | const el = await fixture(html` 119 | 120 | `); 121 | const btn = el.shadowRoot.querySelector('.button'); 122 | 123 | expect(btn.getAttribute('role')).to.equal('switch'); 124 | expect(btn.getAttribute('tabindex')).to.equal('0'); 125 | }); 126 | 127 | it('is checked on click', async () => { 128 | const el = await fixture(html` 129 | 130 | `); 131 | 132 | const btn = el.shadowRoot.querySelector('.button'); 133 | 134 | btn.click(); 135 | 136 | expect(btn.getAttribute('aria-checked')).to.equal('true'); 137 | expect(btn.hasAttribute('checked')).to.equal(true); 138 | expect(el.hasAttribute('checked')).to.equal(true); 139 | 140 | // still works after moving element around in the dom 141 | const wrapper = await fixture( 142 | html` 143 |
144 | `, 145 | ); 146 | wrapper.appendChild(el); 147 | 148 | btn.click(); 149 | expect(el.hasAttribute('checked')).to.equal(false); 150 | }); 151 | 152 | it('toggles on enter', async () => { 153 | const el = await fixture(html` 154 | 155 | `); 156 | const btn = el.shadowRoot.querySelector('.button'); 157 | 158 | el.__onKeyDown({ 159 | keyCode: 13, 160 | preventDefault: () => {}, 161 | }); 162 | 163 | expect(btn.getAttribute('aria-checked')).to.equal('true'); 164 | expect(btn.hasAttribute('checked')).to.equal(true); 165 | expect(el.hasAttribute('checked')).to.equal(true); 166 | 167 | el.__onKeyDown({ 168 | keyCode: 13, 169 | preventDefault: () => {}, 170 | }); 171 | 172 | expect(btn.getAttribute('aria-checked')).to.equal('false'); 173 | expect(btn.hasAttribute('checked')).to.equal(false); 174 | expect(el.hasAttribute('checked')).to.equal(false); 175 | }); 176 | 177 | it('toggles on space', async () => { 178 | const el = await fixture(html` 179 | 180 | `); 181 | const btn = el.shadowRoot.querySelector('.button'); 182 | 183 | el.__onKeyDown({ 184 | keyCode: 32, 185 | preventDefault: () => {}, 186 | }); 187 | 188 | expect(btn.getAttribute('aria-checked')).to.equal('true'); 189 | expect(btn.hasAttribute('checked')).to.equal(true); 190 | expect(el.hasAttribute('checked')).to.equal(true); 191 | 192 | el.__onKeyDown({ 193 | keyCode: 32, 194 | preventDefault: () => {}, 195 | }); 196 | 197 | expect(btn.getAttribute('aria-checked')).to.equal('false'); 198 | expect(btn.hasAttribute('checked')).to.equal(false); 199 | expect(el.hasAttribute('checked')).to.equal(false); 200 | }); 201 | 202 | it('reacts to disabled attribute change', async () => { 203 | const el = await fixture(html` 204 | 205 | `); 206 | const btn = el.shadowRoot.querySelector('.button'); 207 | 208 | el.setAttribute('disabled', ''); 209 | 210 | expect(btn.getAttribute('aria-checked')).to.equal('false'); 211 | expect(btn.hasAttribute('aria-disabled')).to.equal(true); 212 | expect(el.hasAttribute('disabled')).to.equal(true); 213 | 214 | el.removeAttribute('disabled'); 215 | 216 | expect(btn.getAttribute('aria-checked')).to.equal('false'); 217 | expect(btn.hasAttribute('aria-disabled')).to.equal(false); 218 | expect(el.hasAttribute('disabled')).to.equal(false); 219 | }); 220 | 221 | it('reacts to checked attribute change', async () => { 222 | const el = await fixture(html` 223 | 224 | `); 225 | el.setAttribute('checked', ''); 226 | 227 | const btn = el.shadowRoot.querySelector('.button'); 228 | 229 | expect(btn.getAttribute('aria-checked')).to.equal('true'); 230 | expect(btn.hasAttribute('checked')).to.equal(true); 231 | expect(el.hasAttribute('checked')).to.equal(true); 232 | 233 | el.removeAttribute('checked'); 234 | 235 | expect(btn.getAttribute('aria-checked')).to.equal('false'); 236 | expect(btn.hasAttribute('checked')).to.equal(false); 237 | expect(el.hasAttribute('checked')).to.equal(false); 238 | }); 239 | 240 | it('is unchecked when clicked again', async () => { 241 | const el = await fixture(html` 242 | 243 | `); 244 | 245 | el.shadowRoot.querySelector('div').click(); 246 | const btn = el.shadowRoot.querySelector('.button'); 247 | 248 | expect(btn.getAttribute('aria-checked')).to.equal('false'); 249 | expect(btn.hasAttribute('checked')).to.equal(false); 250 | expect(el.hasAttribute('checked')).to.equal(false); 251 | }); 252 | 253 | it('sets required aria attributes on checked', async () => { 254 | const el = await fixture(html` 255 | 256 | `); 257 | const btn = el.shadowRoot.querySelector('.button'); 258 | 259 | expect(btn.getAttribute('aria-checked')).to.equal('true'); 260 | expect(btn.hasAttribute('checked')).to.equal(true); 261 | expect(el.hasAttribute('checked')).to.equal(true); 262 | }); 263 | 264 | it('does not check when disabled', async () => { 265 | const el = await fixture(html` 266 | foo 267 | `); 268 | 269 | el.shadowRoot.querySelector('.button').click(); 270 | 271 | expect(el.hasAttribute('checked')).to.equal(false); 272 | }); 273 | }); 274 | -------------------------------------------------------------------------------- /cem-plugin-reactify.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import prettier from 'prettier'; 6 | 7 | const packageJsonPath = `${process.cwd()}${path.sep}package.json`; 8 | const packageJson = JSON.parse(fs.readFileSync(packageJsonPath).toString()); 9 | 10 | function getDefineCallForElement(cem, tagName) { 11 | let result = undefined; 12 | 13 | cem?.modules?.forEach(_module => { 14 | _module?.exports?.forEach(ex => { 15 | if (ex.kind === 'custom-element-definition' && ex.name === tagName) result = _module.path; 16 | }); 17 | }); 18 | 19 | return result; 20 | } 21 | 22 | function camelize(str) { 23 | const arr = str.split('-'); 24 | const capital = arr.map((item, index) => 25 | index ? item.charAt(0).toUpperCase() + item.slice(1).toLowerCase() : item.toLowerCase(), 26 | ); 27 | return capital.join(''); 28 | } 29 | 30 | function capitalizeFirstLetter(string) { 31 | return string.charAt(0).toUpperCase() + string.slice(1); 32 | } 33 | 34 | const has = arr => Array.isArray(arr) && arr.length > 0; 35 | 36 | const RESERVED_WORDS = [ 37 | 'children', 38 | 'localName', 39 | 'ref', 40 | 'style', 41 | 'className', 42 | 'abstract', 43 | 'arguments', 44 | 'await', 45 | 'boolean', 46 | 'break', 47 | 'byte', 48 | 'case', 49 | 'catch', 50 | 'char', 51 | 'class', 52 | 'const', 53 | 'continue', 54 | 'debugger', 55 | 'default', 56 | 'delete', 57 | 'do', 58 | 'double', 59 | 'else', 60 | 'enum', 61 | 'eval', 62 | 'export', 63 | 'extends', 64 | 'false', 65 | 'final', 66 | 'finally', 67 | 'float', 68 | 'for', 69 | 'function', 70 | 'goto', 71 | 'if', 72 | 'implements', 73 | 'import', 74 | 'in', 75 | 'instanceof', 76 | 'int', 77 | 'interface', 78 | 'let', 79 | 'long', 80 | 'native', 81 | 'new', 82 | 'null', 83 | 'package', 84 | 'private', 85 | 'protected', 86 | 'public', 87 | 'return', 88 | 'short', 89 | 'static', 90 | 'super', 91 | 'switch', 92 | 'synchronized', 93 | 'this', 94 | 'throw', 95 | 'throws', 96 | 'transient', 97 | 'true', 98 | 'try', 99 | 'typeof', 100 | 'var', 101 | 'void', 102 | 'volatile', 103 | 'while', 104 | 'with', 105 | 'yield', 106 | ]; 107 | 108 | /** 109 | * ATTRIBUTE/PROPERTY NAME CLASHES: 110 | * It could be the case that an attr/property are not correctly linked together (e.g.: the attr does not have a `fieldName` pointing 111 | * to the property). In that case, there will be two props passed to the React component function with the same name, which will break things 112 | * Make sure to document components correctly (in most cases, all you have to do is add an @attr jsdoc to the field) 113 | * 114 | * Attrs and properties are distinguished by an attr's `fieldName`. If an attr has a `fieldName`, we ignore it as being an attribute, and 115 | * only use the property (which is whatever the `fieldName` points to). If an attr does not have a `fieldName`, we apply it as an attr 116 | * 117 | * EVENTS: 118 | * `'selected-changed'` event expects a function passed as `onSelectedChanged` (we add the 'on', and we camelize and capitalize the event name) 119 | */ 120 | 121 | export default function reactify({ exclude = [], attributeMapping = {}, outdir = 'legacy' }) { 122 | return { 123 | name: 'reactify', 124 | packageLinkPhase({ customElementsManifest }) { 125 | if (!fs.existsSync(outdir)) { 126 | fs.mkdirSync(outdir); 127 | } 128 | 129 | const components = []; 130 | customElementsManifest.modules.forEach(mod => { 131 | mod.declarations.forEach(dec => { 132 | if (!exclude.includes(dec.name) && (dec.customElement || dec.tagName)) { 133 | components.push(dec); 134 | } 135 | }); 136 | }); 137 | 138 | components.forEach(component => { 139 | let useEffect = false; 140 | const fields = component?.members?.filter( 141 | member => 142 | member.kind === 'field' && 143 | !member.static && 144 | member.privacy !== 'private' && 145 | member.privacy !== 'protected', 146 | ); 147 | 148 | const booleanAttributes = []; 149 | const attributes = []; 150 | 151 | component?.attributes 152 | ?.filter(attr => !attr.fieldName) 153 | ?.forEach(attr => { 154 | /** Handle reserved keyword attributes */ 155 | if (RESERVED_WORDS.includes(attr?.name)) { 156 | /** If we have a user-specified mapping, rename */ 157 | if (attr.name in attributeMapping) { 158 | const attribute = { 159 | ...attr, 160 | originalName: attr.name, 161 | name: attributeMapping[attr.name], 162 | }; 163 | if (attr?.type?.text === 'boolean') { 164 | booleanAttributes.push(attribute); 165 | } else { 166 | attributes.push(attribute); 167 | } 168 | return; 169 | } 170 | throw new Error( 171 | `Attribute \`${attr.name}\` in custom element \`${component.name}\` is a reserved keyword and cannot be used. Please provide an \`attributeMapping\` in the plugin options to rename the JavaScript variable that gets passed to the attribute.`, 172 | ); 173 | } 174 | 175 | if (attr?.type?.text === 'boolean') { 176 | booleanAttributes.push(attr); 177 | } else { 178 | attributes.push(attr); 179 | } 180 | }); 181 | 182 | let params = []; 183 | component?.events?.forEach(event => { 184 | params.push(`on${capitalizeFirstLetter(camelize(event.name))}`); 185 | }); 186 | 187 | fields?.forEach(member => { 188 | params.push(member.name); 189 | }); 190 | 191 | [...(booleanAttributes || []), ...(attributes || [])]?.forEach(attr => { 192 | params.push(camelize(attr.name)); 193 | }); 194 | 195 | params = params?.join(', '); 196 | 197 | const createEventName = event => `on${capitalizeFirstLetter(camelize(event.name))}`; 198 | 199 | const events = component?.events?.map( 200 | event => ` 201 | useEffect(() => { 202 | if(${createEventName(event)} !== undefined) { 203 | ref.current.addEventListener('${event.name}', ${createEventName(event)}); 204 | } 205 | }, []) 206 | `, 207 | ); 208 | 209 | const booleanAttrs = booleanAttributes?.map( 210 | attr => ` 211 | useEffect(() => { 212 | if(${attr?.name ?? attr.originalName} !== undefined) { 213 | if(${attr?.name ?? attr.originalName}) { 214 | ref.current.setAttribute('${attr.name}', ''); 215 | } else { 216 | ref.current.removeAttribute('${attr.name}'); 217 | } 218 | } 219 | }, [${attr?.originalName ?? attr.name}]) 220 | `, 221 | ); 222 | 223 | const attrs = attributes?.map( 224 | attr => ` 225 | useEffect(() => { 226 | if(${attr?.name ?? 227 | attr.originalName} !== undefined && ref.current.getAttribute('${attr?.originalName ?? 228 | attr.name}') !== String(${attr?.name ?? attr.originalName})) { 229 | ref.current.setAttribute('${attr?.originalName ?? attr.name}', ${attr?.name ?? 230 | attr.originalName}) 231 | } 232 | }, [${attr?.name ?? attr.originalName}]) 233 | `, 234 | ); 235 | 236 | const props = fields?.map( 237 | member => ` 238 | useEffect(() => { 239 | if(${member.name} !== undefined && ref.current.${member.name} !== ${member.name}) { 240 | ref.current.${member.name} = ${member.name}; 241 | } 242 | }, [${member.name}]) 243 | `, 244 | ); 245 | 246 | if (has(events) || has(props) || has(attrs) || has(booleanAttrs)) { 247 | useEffect = true; 248 | } 249 | 250 | const moduleSpecifier = path.join( 251 | packageJson.name, 252 | getDefineCallForElement(customElementsManifest, component.tagName), 253 | ); 254 | 255 | const result = ` 256 | import React${useEffect ? ', {useEffect, useRef}' : ''} from "react"; 257 | import '${moduleSpecifier}'; 258 | 259 | export function ${component.name}({children${params ? ',' : ''} ${params}}) { 260 | ${useEffect ? `const ref = useRef(null);` : ''} 261 | 262 | ${has(events) ? '/** Event listeners - run once */' : ''} 263 | ${events?.join('') || ''} 264 | ${ 265 | has(booleanAttrs) 266 | ? '/** Boolean attributes - run whenever an attr has changed */' 267 | : '' 268 | } 269 | ${booleanAttrs?.join('') || ''} 270 | ${has(attrs) ? '/** Attributes - run whenever an attr has changed */' : ''} 271 | ${attrs?.join('') || ''} 272 | ${has(props) ? '/** Properties - run whenever a property has changed */' : ''} 273 | ${props?.join('') || ''} 274 | 275 | return ( 276 | <${component.tagName} ${useEffect ? 'ref={ref}' : ''} ${[ 277 | ...booleanAttributes, 278 | ...attributes, 279 | ] 280 | .map(attr => `${attr?.originalName ?? attr.name}={${attr?.name ?? attr.originalName}}`) 281 | .join(' ')}> 282 | {children} 283 | 284 | ) 285 | } 286 | `; 287 | 288 | fs.writeFileSync( 289 | path.join(outdir, `${component.name}.jsx`), 290 | prettier.format(result, { parser: 'babel' }), 291 | ); 292 | }); 293 | }, 294 | }; 295 | } 296 | -------------------------------------------------------------------------------- /generic-accordion/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 28 | generic-components 29 | 30 | 31 | 32 |
33 | 79 |
80 |

generic-accordion

81 | WAI ARIA Practices 82 | 83 |

An accordion is a vertically stacked set of interactive headings that each contain a title, 84 | content snippet, or thumbnail representing a section of content.

85 |

API:

86 |
87 | 88 | 89 |
90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
namedetail
selected-changednumber
104 |
105 |
106 | 107 | 108 |
109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 |
nametype
selectednumber
123 |
124 |
125 | 126 | 127 |
128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 |
nametype
selectednumber
142 |
143 |
144 | 145 | 146 |
147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 |
namedescription
defaultProvide button elements and content nodes as lightdom
161 |
162 |
163 |
164 | 165 |

Usage:

166 |

The accordion requires a button as trigger, and content elements. You can find an example down below.

167 |
168 |

Default:

169 |
170 | Show code 171 |
184 |
185 |
186 | 187 | 188 |
189 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt 190 | ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi 191 | ut aliquip ex ea commodo consequat.

192 |
193 | 194 | 195 |
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam 196 | rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt 197 | explicabo.
198 | 199 | 200 |
Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid 201 | ex ea commodi consequatur.
202 |
203 |
204 |
205 |

Selected:

206 |
207 | Show code 208 |
221 |
222 |
223 | 224 | 225 |
226 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt 227 | ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi 228 | ut aliquip ex ea commodo consequat.

229 |
230 | 231 | 232 |
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam 233 | rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt 234 | explicabo.
235 | 236 | 237 |
Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid 238 | ex ea commodi consequatur.
239 |
240 |
241 | 242 | 243 |
244 |
245 | 246 | 247 | 248 | -------------------------------------------------------------------------------- /generic-dialog/test/generic-dialog.test.js: -------------------------------------------------------------------------------- 1 | import { html, fixture, expect, oneEvent } from '@open-wc/testing'; 2 | import '../../dialog.js'; 3 | import { dialog } from '../dialog.js'; 4 | 5 | const defaultFixture = html` 6 | 7 | 8 |
9 |

Im used as a web component!

10 |

dialog content

11 | 12 |
13 |
14 | `; 15 | 16 | describe('generic-dialog', () => { 17 | it('a11y', async () => { 18 | const el = await fixture(defaultFixture); 19 | 20 | await expect(el).to.be.accessible(); 21 | }); 22 | 23 | describe('Web Component API', () => { 24 | it('dialog has required role', async () => { 25 | const el = await fixture(defaultFixture); 26 | const button = el.querySelector('button'); 27 | button.click(); 28 | 29 | const dialogNode = document.body.querySelector('generic-dialog-overlay'); 30 | const dialogFrame = dialogNode.shadowRoot.querySelector('[part="dialog"]'); 31 | expect(dialogFrame.getAttribute('role')).to.equal('dialog'); 32 | el.close(); 33 | }); 34 | 35 | it('body children get disabled/aria-hidden/inert', async () => { 36 | const el = await fixture(defaultFixture); 37 | const button = el.querySelector('button'); 38 | button.click(); 39 | 40 | [...document.body.children] 41 | .filter(({ localName }) => localName === 'div') 42 | .forEach(node => { 43 | expect(node.hasAttribute('dialog-disabled')).to.equal(true); 44 | expect(node.hasAttribute('aria-hidden')).to.equal(true); 45 | expect(node.hasAttribute('inert')).to.equal(true); 46 | }); 47 | 48 | el.close(); 49 | }); 50 | 51 | it('puts the content in the dialogFrame', async () => { 52 | const el = await fixture(defaultFixture); 53 | const button = el.querySelector('button'); 54 | button.click(); 55 | 56 | const dialogNode = document.body.querySelector('generic-dialog-overlay'); 57 | 58 | expect(dialogNode.innerHTML.trim()).to.equal( 59 | `
60 |

Im used as a web component!

61 |

dialog content

62 | 63 |
`.trim(), 64 | ); 65 | 66 | el.close(); 67 | }); 68 | 69 | it('opens the dialog', async () => { 70 | const el = await fixture(defaultFixture); 71 | const button = el.querySelector('button'); 72 | button.click(); 73 | 74 | const dialogNode = document.body.querySelector('generic-dialog-overlay'); 75 | expect(dialogNode).to.exist; 76 | expect(dialog.__dialogOpen).to.equal(true); 77 | 78 | el.close(); 79 | }); 80 | 81 | it('dialog closes', async () => { 82 | const el = await fixture(defaultFixture); 83 | const button = el.querySelector('button'); 84 | button.click(); 85 | 86 | el.close(); 87 | expect(dialog.__dialogOpen).to.equal(false); 88 | expect(document.body.querySelector('generic-dialog-overlay')).not.to.exist; 89 | }); 90 | 91 | it('dialog closes on escape', async () => { 92 | const el = await fixture(defaultFixture); 93 | 94 | el.setAttribute('close-on-escape', ''); 95 | const button = el.querySelector('button'); 96 | button.click(); 97 | 98 | document.body.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 27 })); 99 | 100 | expect(dialog.__dialogOpen).to.equal(false); 101 | expect(document.body.querySelector('generic-dialog-overlay')).not.to.exist; 102 | }); 103 | 104 | it('dialog closes on outside click', async () => { 105 | const el = await fixture(defaultFixture); 106 | 107 | el.setAttribute('close-on-outside-click', ''); 108 | const button = el.querySelector('button'); 109 | button.click(); 110 | 111 | document.body.querySelector('generic-dialog-overlay').dispatchEvent(new Event('mousedown')); 112 | 113 | expect(dialog.__dialogOpen).to.equal(false); 114 | expect(document.body.querySelector('generic-dialog-overlay')).not.to.exist; 115 | }); 116 | 117 | it('fires an event on open', async () => { 118 | const el = await fixture(defaultFixture); 119 | const button = el.querySelector('button'); 120 | 121 | const listener = oneEvent(dialog, 'dialog-opened'); 122 | button.click(); 123 | await listener; 124 | expect(listener).to.exist; 125 | 126 | el.close(); 127 | }); 128 | 129 | it('fires an event on close', async () => { 130 | const el = await fixture(defaultFixture); 131 | const button = el.querySelector('button'); 132 | button.click(); 133 | 134 | const listener = oneEvent(dialog, 'dialog-closed'); 135 | el.close(); 136 | await listener; 137 | expect(listener).to.exist; 138 | }); 139 | 140 | it('resets focus to the invoker', async () => { 141 | const el = await fixture(defaultFixture); 142 | const button = el.querySelector('button'); 143 | button.click(); 144 | 145 | el.close(); 146 | 147 | expect(document.activeElement).to.equal(button); 148 | }); 149 | 150 | it('calls connectedCallback once', async () => { 151 | const el = await fixture(defaultFixture); 152 | const container = el.parentElement; 153 | 154 | expect(el.shadowRoot.querySelectorAll('slot[name="invoker"]').length).to.equal(1); 155 | expect(el.shadowRoot.querySelectorAll('slot[name="content"]').length).to.equal(1); 156 | 157 | el.remove(); 158 | container.append(el); 159 | 160 | expect(el.shadowRoot.querySelectorAll('slot[name="invoker"]').length).to.equal(1); 161 | expect(el.shadowRoot.querySelectorAll('slot[name="content"]').length).to.equal(1); 162 | }); 163 | }); 164 | 165 | describe('JavaScript API', () => { 166 | it('dialog has required role', async () => { 167 | const button = await fixture(``); 168 | 169 | dialog.open({ 170 | invokerNode: button, 171 | content: () => {}, 172 | }); 173 | 174 | const dialogNode = document.body.querySelector('generic-dialog-overlay'); 175 | const dialogFrame = dialogNode.shadowRoot.querySelector('[part="dialog"]'); 176 | expect(dialogFrame.getAttribute('role')).to.equal('dialog'); 177 | dialog.close(); 178 | }); 179 | 180 | it('body children get disabled/aria-hidden/inert', async () => { 181 | const button = await fixture(``); 182 | 183 | dialog.open({ 184 | invokerNode: button, 185 | content: () => {}, 186 | }); 187 | 188 | [...document.body.children] 189 | .filter(({ localName }) => localName === 'div') 190 | .forEach(node => { 191 | expect(node.hasAttribute('dialog-disabled')).to.equal(true); 192 | expect(node.hasAttribute('aria-hidden')).to.equal(true); 193 | expect(node.hasAttribute('inert')).to.equal(true); 194 | }); 195 | 196 | dialog.close(); 197 | }); 198 | 199 | it('puts the content in the dialogFrame', async () => { 200 | const button = await fixture(``); 201 | const content = `

foo

`; 202 | 203 | dialog.open({ 204 | invokerNode: button, 205 | content: node => { 206 | node.innerHTML = content; // eslint-disable-line 207 | }, 208 | }); 209 | 210 | const dialogNode = document.body.querySelector('generic-dialog-overlay'); 211 | expect(dialogNode.innerHTML).to.equal(content); 212 | dialog.close(); 213 | }); 214 | 215 | it('opens the dialog', async () => { 216 | const button = await fixture(``); 217 | 218 | dialog.open({ 219 | invokerNode: button, 220 | content: () => {}, 221 | }); 222 | 223 | const dialogNode = document.body.querySelector('generic-dialog-overlay'); 224 | expect(dialogNode).to.exist; 225 | expect(dialog.__dialogOpen).to.equal(true); 226 | dialog.close(); 227 | }); 228 | 229 | it('dialog closes', async () => { 230 | const button = await fixture(``); 231 | 232 | dialog.open({ 233 | invokerNode: button, 234 | closeOnEscape: true, 235 | content: () => {}, 236 | }); 237 | 238 | dialog.close(); 239 | expect(dialog.__dialogOpen).to.equal(false); 240 | expect(document.body.querySelector('generic-dialog-overlay')).not.to.exist; 241 | }); 242 | 243 | it('dialog closes on escape', async () => { 244 | const button = await fixture(``); 245 | 246 | dialog.open({ 247 | invokerNode: button, 248 | closeOnEscape: true, 249 | content: () => {}, 250 | }); 251 | 252 | dialog.__onKeyDown({ keyCode: 27 }); 253 | expect(dialog.__dialogOpen).to.equal(false); 254 | expect(document.body.querySelector('generic-dialog-overlay')).not.to.exist; 255 | }); 256 | 257 | it('dialog closes on outside click', async () => { 258 | const button = await fixture(``); 259 | 260 | dialog.open({ 261 | invokerNode: button, 262 | closeOnOutsideClick: true, 263 | content: () => {}, 264 | }); 265 | 266 | document.body.querySelector('generic-dialog-overlay').dispatchEvent(new Event('mousedown')); 267 | 268 | expect(dialog.__dialogOpen).to.equal(false); 269 | expect(document.body.querySelector('generic-dialog-overlay')).not.to.exist; 270 | }); 271 | 272 | it('fires an event on open', async () => { 273 | const button = await fixture(``); 274 | 275 | const listener = oneEvent(dialog, 'dialog-opened'); 276 | 277 | dialog.open({ 278 | invokerNode: button, 279 | closeOnOutsideClick: true, 280 | content: () => {}, 281 | }); 282 | await listener; 283 | 284 | expect(listener).to.exist; 285 | dialog.close(); 286 | }); 287 | 288 | it('fires an event on close', async () => { 289 | const button = await fixture(``); 290 | 291 | dialog.open({ 292 | invokerNode: button, 293 | closeOnOutsideClick: true, 294 | content: () => {}, 295 | }); 296 | 297 | const listener = oneEvent(dialog, 'dialog-closed'); 298 | dialog.close(); 299 | await listener; 300 | expect(listener).to.exist; 301 | }); 302 | 303 | it('resets focus to the invoker', async () => { 304 | const button = await fixture(``); 305 | 306 | dialog.open({ 307 | invokerNode: button, 308 | closeOnOutsideClick: true, 309 | content: () => {}, 310 | }); 311 | 312 | dialog.close(); 313 | expect(document.activeElement).to.equal(button); 314 | }); 315 | }); 316 | }); 317 | --------------------------------------------------------------------------------