Home Reference Source

src/components/tf-checklist.js

/**
 * `<tf-checklist>` custom element: multi-select filter popup.
 *
 * An open shadow root exposing a `<button type="button">` trigger
 * (`part="trigger"`) and a listbox popover (`part="listbox"`), following
 * the WAI-ARIA APG combobox pattern with a multi-select listbox.
 *
 * Attributes:
 * - `trigger-text` — visible trigger text, rendered as text content.
 * - `label` — `aria-label` on the trigger. Unset keeps the trigger's text
 *   as its accessible name.
 * - `listbox-label` — `aria-label` on the listbox. Unset names the listbox
 *   from the trigger through `aria-labelledby`.
 * - `open` — whether the popover is expanded. The trigger click toggles
 *   it; `aria-expanded` and the listbox's `hidden` state follow.
 * - `disabled` — disables the trigger.
 *
 * Options are supplied through `setOptions()`. Each entry is either a
 * primitive value or an object with `value`, `text`, `disabled` and
 * `selected`; `text` defaults to the value and `setOptions()` clears the
 * previous selection. Every entry renders as a `role="option"` element
 * (`part="option"`) with `aria-selected`, `aria-disabled` for disabled
 * entries, and a decorative checkbox (`part="option-checkbox"`,
 * `aria-hidden`, non-interactive) followed by its text (`part="option-text"`),
 * rendered as `textContent` (markup shown literally, XSS-safe). The checkbox
 * is a styling hook; selection state is exposed through `aria-selected`
 * and the `option` part tokens (`option-selected` / `option-disabled`).
 *
 * `setSelectedValues()` selects the options matching the given values
 * and `getSelectedValues()` returns the selected values in option order.
 * Clicking an enabled option toggles it. Option text is rendered as
 * `textContent` (markup shown literally, matching legacy behavior).
 *
 * Events:
 * - `open-change` — dispatched when the trigger toggles the popover, with
 *   `detail: {open}`, the expanded state as a boolean. Bubbles and crosses
 *   the shadow boundary.
 * - `selection-change` — dispatched when a click toggles an option, with
 *   `detail: {values}`, the selected values in option order. Bubbles and
 *   crosses the shadow boundary. `setOptions()` and
 *   `setSelectedValues()` do not dispatch it.
 *
 * Content is projected into the trigger through `slot="trigger"`.
 * `setText()`, `setLabel()`, `setListboxLabel()` and `setOpen()` reflect
 * the matching attributes. Styling is left to the theme via `--tf-*`
 * custom properties and `::part()`; no CSS class or presentational style
 * is shipped.
 *
 * Importing this module registers the element. It is not part of the
 * legacy webpack bundle; consumers load it as a module.
 */

// Ids live inside each instance's shadow root, so they are unique per
// component instance. The trigger names the listbox by default.
const TRIGGER_ID = 'trigger';
const LISTBOX_ID = 'listbox';

const OPTION_SELECTOR = '[part~="option"]';

// Cloned into every instance's shadow root. Structure and `part` hooks
// only; no class or presentational style is baked in.
const TEMPLATE = document.createElement('template');
TEMPLATE.innerHTML = `
    <button type="button" part="trigger" id="${TRIGGER_ID}"
        aria-haspopup="listbox" aria-expanded="false"
        aria-controls="${LISTBOX_ID}">
        <slot name="trigger"></slot><span part="trigger-text"></span>
    </button>
    <div part="listbox" id="${LISTBOX_ID}" role="listbox"
        aria-multiselectable="true" aria-labelledby="${TRIGGER_ID}"
        tabindex="-1" hidden></div>
`;

/**
 * Normalize an option entry into `{value, text, disabled, selected}`.
 * @param {*} entry Primitive value or `{value, text, disabled, selected}`
 * @returns {Object} Normalized option
 */
function normalizeOption(entry) {
    const isObject = entry !== null && typeof entry === 'object';
    const raw = isObject ? entry.value : entry;
    const value = raw == null ? '' : String(raw);
    const text = isObject && entry.text != null ?
        String(entry.text) : value;
    return {
        value,
        text,
        disabled: Boolean(isObject && entry.disabled),
        selected: Boolean(isObject && entry.selected)
    };
}

export class TfChecklistElement extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.appendChild(TEMPLATE.content.cloneNode(true));
        this._trigger = this.shadowRoot.querySelector('button');
        this._triggerText =
            this.shadowRoot.querySelector('[part="trigger-text"]');
        this._listbox = this.shadowRoot.querySelector('[part="listbox"]');
        // Option entries supplied through setOptions(), in render order.
        this._options = [];
        // Rendered option elements, aligned with _options by index.
        this._optionEls = [];
        this._searchString = '';
        this._trigger.addEventListener('click', () => this._toggle());
        this._listbox.addEventListener('click', (evt) =>
            this._onOptionClick(evt));
        this._trigger.addEventListener('keydown', (evt) =>
            this._onTriggerKeydown(evt));
        this._listbox.addEventListener('keydown', (evt) =>
            this._onListboxKeydown(evt));
        this._listbox.addEventListener('focusin', () => {
            this._updateActiveDescendant();
        });
        this._listbox.addEventListener('focusout', () => {
            this._listbox.removeAttribute('aria-activedescendant');
        });
    }

    static get observedAttributes() {
        return ['trigger-text', 'label', 'listbox-label', 'open',
            'disabled', 'col']; // 'col' reserved for #929 column mapping
    }

    connectedCallback() {
        this._update();
    }

    attributeChangedCallback() {
        this._update();
    }

    /**
     * Set the trigger's accessible name, rendered as `aria-label`.
     * @param {*} label Accessible name
     */
    setLabel(label) {
        this.setAttribute('label', label == null ? '' : String(label));
    }

    /**
     * Set the listbox's accessible name, rendered as `aria-label`.
     * @param {*} label Accessible name
     */
    setListboxLabel(label) {
        this.setAttribute('listbox-label',
            label == null ? '' : String(label));
    }

    /**
     * Set the trigger text, rendered into the `trigger-text` part.
     * @param {*} text Trigger text
     */
    setText(text) {
        this.setAttribute('trigger-text', text == null ? '' : String(text));
    }

    /**
     * Expand or collapse the popover.
     * @param {Boolean} open Whether the popover is expanded
     */
    setOpen(open) {
        if (open) {
            this.setAttribute('open', '');
        } else {
            this.removeAttribute('open');
        }
    }

    /**
     * Replace the option list. Entries are primitives or objects with
     * `value`, `text`, `disabled` and `selected`. The previous selection
     * is cleared.
     * @param {Array<*>} options Option entries
     */
    setOptions(options) {
        this._options = (Array.isArray(options) ? options : [])
            .map(normalizeOption);
        this._renderOptions();
    }

    /**
     * Select the options whose value matches an entry in `values`, and
     * clear the selection of the rest.
     * @param {Array<*>} values Selected values
     */
    setSelectedValues(values) {
        const selected = new Set(
            (Array.isArray(values) ? values : []).map((v) => String(v)));
        this._options.forEach((option, index) => {
            this._applySelection(option, index, selected.has(option.value));
        });
    }

    /**
     * Get the values of the selected options, in option order.
     * @returns {Array<String>} Selected values
     */
    getSelectedValues() {
        return this._options
            .filter((option) => option.selected)
            .map((option) => option.value);
    }

    /**
     * Toggle an option on click.
     * @param {Event} evt Click event from inside the listbox
     */
    _onOptionClick(evt) {
        const target = evt.target instanceof Element ? evt.target : null;
        const el = target ? target.closest(OPTION_SELECTOR) : null;
        if (!el) {
            return;
        }

        const index = Number(el.getAttribute('data-index'));
        const option = this._options[index];
        if (!option || option.disabled) {
            return;
        }

        this._applySelection(option, index, !option.selected);
        this.dispatchEvent(new CustomEvent('selection-change', {
            detail: { values: this.getSelectedValues() },
            bubbles: true,
            composed: true
        }));
    }

    /**
     * Render every option entry into the listbox.
     */
    _renderOptions() {
        this._listbox.textContent = '';
        this._optionEls = this._options.map((option, index) => {
            const el = this._createOption(option, index);
            this._listbox.appendChild(el);
            return el;
        });
    }

    /**
     * Build an option element for an entry and apply its selection state.
     * Delegates selection-state updates to `_applySelection()` so the two
     * paths cannot drift.
     * @param {Object} option Normalized option
     * @param {Number} index Position in the option list
     * @returns {Element} Option element
     */
    _createOption(option, index) {
        const el = document.createElement('div');
        el.setAttribute('role', 'option');
        el.setAttribute('tabindex', '-1');
        el.setAttribute('data-index', String(index));
        el.id = `option-${index}`;
        const checkbox = document.createElement('span');
        checkbox.setAttribute('part', 'option-checkbox');
        checkbox.setAttribute('aria-hidden', 'true');

        const text = document.createElement('span');
        text.setAttribute('part', 'option-text');
        text.textContent = option.text;

        el.appendChild(checkbox);
        el.appendChild(text);

        // Build part tokens directly so creation and updates share one logic.
        const partNames = ['option'];
        if (option.selected) {
            partNames.push('option-selected');
        }
        if (option.disabled) {
            partNames.push('option-disabled');
        }
        el.setAttribute('part', partNames.join(' '));
        el.setAttribute('aria-selected', option.selected ? 'true' : 'false');
        if (option.disabled) {
            el.setAttribute('aria-disabled', 'true');
        }

        return el;
    }

    /**
     * Set an option's selection state on the entry and its element.
     * @param {Object} option Normalized option
     * @param {Number} index Position in the option list
     * @param {Boolean} selected Whether the option is selected
     */
    _applySelection(option, index, selected) {
        option.selected = selected;
        const el = this._optionEls[index];
        if (!el) {
            return;
        }
        el.setAttribute('aria-selected', selected ? 'true' : 'false');
        const partNames = ['option'];
        if (selected) {
            partNames.push('option-selected');
        }
        if (option.disabled) {
            partNames.push('option-disabled');
        }
        el.setAttribute('part', partNames.join(' '));
    }

    /**
     * Keyboard navigation on trigger
     */
    _onTriggerKeydown(evt) {
        const open = this.hasAttribute('open');
        if (evt.key === 'ArrowDown' || evt.key === 'ArrowUp' ||
            evt.key === ' ' || evt.key === 'Enter') {
            evt.preventDefault();
            if (!open) {
                this.setOpen(true);
                this._setExpanded(true);
                this.dispatchEvent(new CustomEvent('open-change', {
                    detail: { open: true }, bubbles: true, composed: true
                }));
                const enabled = this._optionEls.filter(
                    el => el.getAttribute('aria-disabled') !== 'true');
                const first = enabled[0] || this._optionEls[0];
                if (first) {
                    first.focus();
                }
            }
        }
        if (evt.key === 'Escape' && open) {
            evt.preventDefault();
            this.setOpen(false);
            this._setExpanded(false);
            this.dispatchEvent(new CustomEvent('open-change', {
                detail: { open: false },
                bubbles: true,
                composed: true
            }));
            this._trigger.focus();
        }
    }

    /**
     * Keyboard navigation inside listbox
     */
    _onListboxKeydown(evt) {
        const options = this._optionEls.filter(
            el => el.getAttribute('aria-disabled') !== 'true');
        const focused = this.shadowRoot.activeElement;
        const index = options.indexOf(focused);
        const k = evt.key;
        switch (k) {
            case 'ArrowDown': {
                evt.preventDefault();
                const next = options[index + 1] || options[0];
                if (next) {
                    next.focus();
                }
                break;
            }
            case 'ArrowUp': {
                evt.preventDefault();
                const prev = options[index - 1] ||
                    options[options.length - 1];
                if (prev) {
                    prev.focus();
                }
                break;
            }
            case 'Home': {
                evt.preventDefault();
                if (options[0]) {
                    options[0].focus();
                }
                break;
            }
            case 'End': {
                evt.preventDefault();
                if (options[options.length - 1]) {
                    options[options.length - 1].focus();
                }
                break;
            }
            case ' ':
            case 'Enter': {
                evt.preventDefault();
                const target = evt.target.closest('[part~="option"]');
                if (target) {
                    const idx = Number(
                        target.getAttribute('data-index'));
                    const option = this._options[idx];
                    if (option && !option.disabled) {
                        this._applySelection(
                            option, idx, !option.selected);
                        this.dispatchEvent(new CustomEvent(
                            'selection-change', {
                                detail: {
                                    values: this.getSelectedValues()
                                },
                                bubbles: true,
                                composed: true
                            }));
                    }
                    if (k === 'Enter' &&
                        this.hasAttribute('open')) {
                        this.setOpen(false);
                        this._setExpanded(false);
                        this.dispatchEvent(new CustomEvent(
                            'open-change', {
                                detail: { open: false },
                                bubbles: true,
                                composed: true
                            }));
                        this._trigger.focus();
                    }
                }
                break;
            }
            case 'Escape': {
                evt.preventDefault();
                this.setOpen(false);
                this._setExpanded(false);
                this.dispatchEvent(new CustomEvent('open-change', {
                    detail: { open: false },
                    bubbles: true,
                    composed: true
                }));
                this._trigger.focus();
                break;
            }
            default: {
                if (evt.key.length === 1 && !evt.ctrlKey &&
                    !evt.metaKey && !evt.altKey) {
                    evt.preventDefault();
                    this._searchString += evt.key.toLowerCase();
                    clearTimeout(this._searchTimeout);
                    this._searchTimeout = setTimeout(
                        () => {
                            this._searchString = '';
                        }, 500);
                    const focused = this.shadowRoot.activeElement;
                    const enabledEls = this._optionEls.filter(
                        el => el.getAttribute('aria-disabled') !== 'true');
                    const startIndex = focused &&
                        enabledEls.indexOf(focused) >= 0 ?
                        enabledEls.indexOf(focused) + 1 : 0;
                    const match = enabledEls.slice(
                        startIndex).find(el => {
                        if (!el.hasAttribute('data-index')) {
                            return false;
                        }
                        const opt = this._options[
                            Number(el.getAttribute('data-index'))] || {};
                        const text = opt.text || '';
                        return text.toLowerCase().startsWith(
                            this._searchString);
                    }) || enabledEls.find(el => {
                        if (!el.hasAttribute('data-index')) {
                            return false;
                        }
                        const opt = this._options[
                            Number(el.getAttribute('data-index'))] || {};
                        const text = opt.text || '';
                        return text.toLowerCase().startsWith(
                            this._searchString);
                    });
                    if (match) {
                        match.focus();
                    }
                }
                break;
            }
        }
    }
    /**
     * Toggle the popover and announce the new state.
     */
    _toggle() {
        const open = !this.hasAttribute('open');
        this.setOpen(open);
        this.dispatchEvent(new CustomEvent('open-change', {
            detail: { open },
            bubbles: true,
            composed: true
        }));
    }

    _updateActiveDescendant() {
        const focused = this.shadowRoot.activeElement;
        if (focused && this._optionEls.includes(focused)) {
            this._listbox.setAttribute('aria-activedescendant', focused.id);
        } else {
            this._listbox.removeAttribute('aria-activedescendant');
        }
    }

    /**
     * Render the trigger and listbox attributes and expanded state.
     */
    _update() {
        if (!this._trigger) {
            return;
        }

        this._setAriaLabel(this._trigger, 'label');
        this._triggerText.textContent =
            this.getAttribute('trigger-text') || '';
        this._setListboxName();
        this._setExpanded(this.hasAttribute('open'));

        if (this.hasAttribute('disabled')) {
            this._trigger.setAttribute('disabled', '');
        } else {
            this._trigger.removeAttribute('disabled');
        }
    }

    /**
     * Name the listbox from its own attribute, or from the trigger.
     */
    _setListboxName() {
        const label = this.getAttribute('listbox-label');
        if (label) {
            this._listbox.setAttribute('aria-label', label);
            this._listbox.removeAttribute('aria-labelledby');
        } else {
            this._listbox.removeAttribute('aria-label');
            this._listbox.setAttribute('aria-labelledby', TRIGGER_ID);
        }
    }

    /**
     * Reflect the expanded state on the trigger and the popover.
     * @param {Boolean} open Whether the popover is expanded
     */
    _setExpanded(open) {
        this._trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
        if (open) {
            this._listbox.removeAttribute('hidden');
            this._searchString = '';
            clearTimeout(this._searchTimeout);
            this._updateActiveDescendant();
        } else {
            this._listbox.setAttribute('hidden', '');
            this._listbox.removeAttribute('aria-activedescendant');
        }
    }

    /**
     * Render an attribute as `aria-label` on a shadow element, or remove
     * it when the attribute is absent or empty.
     * @param {Element} el Shadow element to name
     * @param {String} attr Attribute holding the accessible name
     */
    _setAriaLabel(el, attr) {
        const label = this.getAttribute(attr);
        if (label) {
            el.setAttribute('aria-label', label);
        } else {
            el.removeAttribute('aria-label');
        }
    }
}

// Register once: loading this module twice (or on a page that already
// defined the element) must not throw.
if (typeof customElements !== 'undefined' &&
    !customElements.get('tf-checklist')) {
    customElements.define('tf-checklist', TfChecklistElement);
}