Home Reference Source

src/components/tf-status.js

/**
 * `<tf-status>` custom element: accessible status indicator
 * (ROADMAP: preliminary-and-milestone-0.8.0-plan.md, "Component 2").
 *
 * Scaffold (#905): an open shadow root exposing the `label` and
 * `message` parts, rendered statically from the `text` and `message`
 * attributes. The host is the live region (#906): connecting it enforces
 * `role="status"` / `aria-live="polite"` / `aria-atomic="true"` so
 * message changes are announced. The StatusBar bridge (#907) follows.
 *
 * Styling is deliberately not shipped here. TableFilter themes through
 * the `--tf-*` custom properties (#892/#945 — inherited into shadow
 * trees) and `::part()` hooks, so the element exposes `part="label"` /
 * `part="message"` and leaves the look to the stylesheet/theme. It also
 * hardcodes no CSS class: the status class is configurable
 * (`status_bar.css_class`, default `status`) and #907 applies it to
 * this host.
 *
 * Importing this module registers the element. Like `<table-filter>` it
 * is deliberately not part of the legacy webpack bundle (PhantomJS
 * `_wrapNativeSuper`); consumers load it as a module, and shipping it
 * in a bundle belongs with #965.
 */

// Cloned into every instance's shadow root. Structure and `part` hooks
// only: `part` is the stable internal contract (selected by _update()
// and themed via ::part()), and no class or presentational style is
// baked in, so themes and consumers stay in control.
const TEMPLATE = document.createElement('template');
TEMPLATE.innerHTML = `
    <span part="label"></span>
    <span part="message"></span>
`;

export class TfStatusElement extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.appendChild(TEMPLATE.content.cloneNode(true));
        this._labelEl = this.shadowRoot.querySelector('[part="label"]');
        this._msgEl = this.shadowRoot.querySelector('[part="message"]');
    }

    static get observedAttributes() {
        return ['text', 'message'];
    }

    connectedCallback() {
        // Live-region contract (#906), enforced on connect so manually
        // authored markup gets it too. `role="status"` already implies
        // `aria-live="polite"` and `aria-atomic="true"`; setting them
        // explicitly keeps the contract visible to consumers. The host
        // is the region: the shadow parts carry no ARIA of their own, so
        // a message change is announced once, as "label message".
        this.setAttribute('role', 'status');
        this.setAttribute('aria-live', 'polite');
        this.setAttribute('aria-atomic', 'true');
        this._update();
    }

    attributeChangedCallback() {
        this._update();
    }

    /**
     * Set the message text, rendered into the `message` part. Values are
     * coerced to strings so a falsy non-string (e.g. 0) is displayed
     * rather than silently dropped.
     * @param {String} msg Message text
     */
    setMessage(msg) {
        this.setAttribute('message', msg == null ? '' : String(msg));
    }

    /**
     * Render the `text` and `message` attributes into the shadow parts.
     */
    _update() {
        if (this._labelEl) {
            this._labelEl.textContent = this.getAttribute('text') || '';
        }
        if (this._msgEl) {
            this._msgEl.textContent = this.getAttribute('message') || '';
        }
    }
}

// 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-status')) {
    customElements.define('tf-status', TfStatusElement);
}