Home Reference Source

src/components/tf-results-per-page.js

/**
 * `<tf-results-per-page>` custom element: the labelled select that picks
 * how many rows a page holds.
 *
 * An open shadow root exposing a `<label>` (`part="results-label"`)
 * wrapping the label text (`part="results-text"`) and a native `<select>`
 * (`part="results-select"`). The select is the label's control: the
 * rendered text is its accessible name, and activating the text focuses
 * the select. Legacy keeps the text in a sibling `<span>` and names the
 * select nowhere (src/modules/paging.js:662-669).
 *
 * Attributes:
 * - `results-text` — label text, rendered as text content.
 * - `page-length` — the selected page length. The matching option is
 *   selected, or the first option when the attribute is empty or matches
 *   none, and the effective value is reflected back into the attribute,
 *   so the attribute always reports what the select shows.
 *
 * The label text names the select. When `results-text` is absent or
 * empty, `aria-label` (`Rows per page`, from ROADMAP
 * v1-webcomponents-investigation.md:313) names it instead.
 *
 * Events:
 * - `page-length-change` — dispatched when the user picks a length, with
 *   `detail: {pageLength}`. That value is the select's raw value, a
 *   string; the host parses it (paging.js:778).
 *
 * `setPageLengths()` takes the page lengths from the host, and each
 * entry becomes an option whose value and text are both that entry
 * (legacy `new Option(v, v)`, paging.js:681).
 *
 * Styling is left to the theme via `--tf-*` custom properties and
 * `::part()`; the legacy `results_slc_css_class` and
 * `results_span_css_class` keys are `::part()` hooks, and 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.
 */

// Name used when the host supplies no label text.
const DEFAULT_RESULTS_LABEL = 'Rows per page';

const TEMPLATE = document.createElement('template');
TEMPLATE.innerHTML = `
    <label part="results-label">
        <span part="results-text"></span><select part="results-select"></select>
    </label>
`;

export class TfResultsPerPageElement extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.appendChild(TEMPLATE.content.cloneNode(true));
        this._textEl =
            this.shadowRoot.querySelector('[part="results-text"]');
        this._select =
            this.shadowRoot.querySelector('[part="results-select"]');
        // Page lengths supplied through setPageLengths().
        this._lengths = [];
        // A change inside the shadow root does not reach the host: the
        // native `change` event is not composed, so it is relayed as a
        // composed CustomEvent carrying the chosen value. The pick is
        // written into `page-length` before the event is dispatched.
        this._select.addEventListener('change', () => {
            this.setAttribute('page-length', this._select.value);
            this.dispatchEvent(new CustomEvent('page-length-change', {
                detail: { pageLength: this._select.value },
                bubbles: true,
                composed: true
            }));
        });
    }

    static get observedAttributes() {
        return ['results-text', 'page-length'];
    }

    connectedCallback() {
        this._update();
    }

    attributeChangedCallback() {
        this._update();
    }

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

    /**
     * Set the selected page length.
     * @param {*} pageLength Page length
     */
    setPageLength(pageLength) {
        this.setAttribute('page-length',
            pageLength == null ? '' : String(pageLength));
    }

    /**
     * Supply the option list. Each entry becomes an option whose value
     * and text are both the entry (src/modules/paging.js:681).
     * @param {Array<*>} lengths Page lengths
     */
    setPageLengths(lengths) {
        this._lengths = Array.isArray(lengths) ? lengths : [];
        this._updateOptions();
    }

    /**
     * Render the label text, name the select and select the option
     * matching `page-length`.
     */
    _update() {
        this._textEl.textContent = this.getAttribute('results-text') || '';
        this._setSelectName();
        this._selectOptions();
    }

    /**
     * Name the select from the label text, or from the default when
     * `results-text` is absent or empty.
     */
    _setSelectName() {
        if (this.getAttribute('results-text')) {
            this._select.removeAttribute('aria-label');
        } else {
            this._select.setAttribute('aria-label', DEFAULT_RESULTS_LABEL);
        }
    }

    /**
     * Rebuild the option list from the supplied page lengths.
     */
    _updateOptions() {
        this._select.textContent = '';
        this._lengths.forEach((length) => {
            this._select.add(new Option(length, length, false, false));
        });
        this._selectOptions();
    }

    /**
     * Select the option whose value matches `page-length`, or the first
     * option when the attribute is empty or matches none, and reflect the
     * option that ended up selected back into the attribute.
     */
    _selectOptions() {
        const options = this._select.options;
        if (options.length === 0) {
            return;
        }
        const pageLength = this.getAttribute('page-length') || '';
        let index = -1;
        for (let i = 0; i < options.length; i++) {
            if (options[i].value === pageLength) {
                index = i;
                break;
            }
        }
        if (index === -1) {
            index = 0;
        }
        options[index].selected = true;
        if (options[index].value !== pageLength) {
            this.setAttribute('page-length', options[index].value);
        }
    }
}

// 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-results-per-page')) {
    customElements.define('tf-results-per-page', TfResultsPerPageElement);
}