Home Reference Source

src/components/tf-pager.js

/**
 * `<tf-pager>` custom element: accessible pagination controls.
 *
 * An open shadow root exposing a `nav` landmark (`part="nav"`) with four
 * semantic `<button type="button">` elements in first/prev/next/last
 * order — each via its own `part`, a named slot and a text hook — plus a
 * page indicator (`part="page-status"`) carrying `aria-current="page"`.
 *
 * Attributes:
 * - `label` — `aria-label` on the landmark.
 * - `first-label`, `prev-label`, `next-label`, `last-label` — `aria-label`
 *   on each button.
 * - `first-text`, `prev-text`, `next-text`, `last-text` — button text.
 * - `page` — current page number, 1-based.
 * - `total-pages` — page count, rendered as supplied. The host resolves
 *   it from the legacy Paging feature (paging.js:284, :534).
 * - `page-text`, `of-text` — text rendered either side of the selector.
 * - `selector-type` — `select` (default) or `input`.
 * - `selector-label` — `aria-label` on the page selector; defaults to
 *   `Current page`, and an empty value opts out of the name.
 * - `disabled` — disables all four buttons.
 * No accessible name is rendered unless its attribute is set.
 *
 * The select is populated through `setPages()`, which takes `{text,
 * value}` entries from the host — the legacy option model, page number
 * and start row derived from `pageLength` (paging.js:546).
 *
 * Content is projected through the slots (`slot="first"`, `slot="prev"`,
 * `slot="next"`, `slot="last"`). 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.
 */

const BUTTONS = [
    { part: 'first' },
    { part: 'prev' },
    { part: 'next' },
    { part: 'last' }
];

const DEFAULT_SELECTOR_LABEL = 'Current page';

// Cloned into every instance's shadow root. Structure and `part` hooks
// only; no class or presentational style is baked in. The indicator sits
// between the prev and next buttons, mirroring the legacy markup order
// (src/modules/paging.js: first, prev, page text, selector, of text,
// total, next, last).
const TEMPLATE = document.createElement('template');
TEMPLATE.innerHTML = `
    <nav part="nav">
        <button type="button" part="first">
            <slot name="first"></slot><span part="first-text"></span>
        </button>
        <button type="button" part="prev">
            <slot name="prev"></slot><span part="prev-text"></span>
        </button>
        <span part="page-status" aria-current="page">
            <span part="page-text"></span>
            <span part="page-selector"></span>
            <span part="of-text"></span>
            <span part="total-pages"></span>
        </span>
        <button type="button" part="next">
            <slot name="next"></slot><span part="next-text"></span>
        </button>
        <button type="button" part="last">
            <slot name="last"></slot><span part="last-text"></span>
        </button>
    </nav>
`;

export class TfPagerElement extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.appendChild(TEMPLATE.content.cloneNode(true));
        this._nav = this.shadowRoot.querySelector('nav');
        this._buttons = BUTTONS.map(
            (btn) => this.shadowRoot.querySelector(`[part="${btn.part}"]`));
        this._texts = BUTTONS.map(
            (btn) =>
                this.shadowRoot.querySelector(`[part="${btn.part}-text"]`));
        this._pageText = this.shadowRoot.querySelector('[part="page-text"]');
        this._pageSelector =
            this.shadowRoot.querySelector('[part="page-selector"]');
        this._ofText = this.shadowRoot.querySelector('[part="of-text"]');
        this._totalPagesEl =
            this.shadowRoot.querySelector('[part="total-pages"]');
        // Page entries supplied through setPages().
        this._pages = [];
    }

    static get observedAttributes() {
        return ['label', 'disabled', 'page', 'total-pages',
            'page-text', 'of-text', 'selector-type', 'selector-label']
            .concat(BUTTONS.map((btn) => `${btn.part}-label`))
            .concat(BUTTONS.map((btn) => `${btn.part}-text`));
    }

    connectedCallback() {
        this._update();
    }

    attributeChangedCallback() {
        this._update();
    }

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

    /**
     * Set the current page number.
     * @param {*} page Page number, 1-based
     */
    setPage(page) {
        this.setAttribute('page', page == null ? '' : String(page));
    }

    /**
     * Set the page count, resolved by the host from the legacy Paging
     * feature.
     * @param {*} totalPages Page count
     */
    setTotalPages(totalPages) {
        this.setAttribute('total-pages',
            totalPages == null ? '' : String(totalPages));
    }

    /**
     * Render the landmark, button attributes and page indicator into the
     * shadow parts.
     */
    _update() {
        if (!this._nav) {
            return;
        }

        this._setAriaLabel(this._nav, 'label');

        BUTTONS.forEach((btn, idx) => {
            this._setAriaLabel(this._buttons[idx], `${btn.part}-label`);
            this._texts[idx].textContent =
                this.getAttribute(`${btn.part}-text`) || '';

            if (this.hasAttribute('disabled')) {
                this._buttons[idx].setAttribute('disabled', '');
            } else {
                this._buttons[idx].removeAttribute('disabled');
            }
        });

        this._pageText.textContent = this.getAttribute('page-text') || '';
        this._ofText.textContent = this.getAttribute('of-text') || '';
        this._totalPagesEl.textContent = this._totalPages();

        this._updateSelector(this._syncSelector());
    }

    /**
     * The `total-pages` value supplied by the host, or empty when it has
     * not been set.
     * @return {String} Total pages, or an empty string
     */
    _totalPages() {
        return this.getAttribute('total-pages') || '';
    }

    /**
     * Supply the page list rendered into the select. Each entry is
     * `{text, value}` — the legacy option model, page number as visible
     * text and start row as value (paging.js:546).
     * @param {Array<Object>} pages Page entries
     */
    setPages(pages) {
        this._pages = Array.isArray(pages) ? pages : [];
        this._update();
    }

    /**
     * Ensure the page selector matches `selector-type`, creating it on
     * first use and replacing it when the type changes. Exactly one
     * control exists at a time, so no presentational attribute is needed
     * to hide the other.
     * @return {Element} The page selector control
     */
    _syncSelector() {
        const isInput = this.getAttribute('selector-type') === 'input';
        const part = isInput ? 'page-input' : 'page-select';
        const current = this._pageSelector.firstElementChild;

        if (current && current.getAttribute('part') === part) {
            return current;
        }

        this._pageSelector.textContent = '';
        const el = document.createElement(isInput ? 'input' : 'select');
        el.setAttribute('part', part);
        if (isInput) {
            el.setAttribute('type', 'number');
            el.setAttribute('min', '1');
        }
        this._pageSelector.appendChild(el);
        return el;
    }

    /**
     * Render the host-supplied page entries, page count and accessible
     * name into the selector control; the input variant holds the page
     * number itself (src/modules/paging.js:377,547).
     * @param {Element} el The selector control
     */
    _updateSelector(el) {
        this._setSelectorLabel(el);

        const page = this.getAttribute('page') || '';
        const total = this._totalPages();
        const isInput = el.getAttribute('part') === 'page-input';

        if (isInput) {
            el.value = page;
            if (total !== '') {
                el.setAttribute('max', total);
            } else {
                el.removeAttribute('max');
            }
            return;
        }

        el.textContent = '';
        this._pages.forEach((entry) => {
            const option = document.createElement('option');
            option.value = String(entry.value);
            option.textContent = String(entry.text);
            if (String(entry.text) === page) {
                option.setAttribute('selected', 'true');
            }
            el.appendChild(option);
        });
    }

    /**
     * Name the page selector. Absent `selector-label` renders the
     * ROADMAP default, a non-empty value overrides it, and an explicit
     * empty value removes the name so it can be opted out of.
     * @param {Element} el The selector control
     */
    _setSelectorLabel(el) {
        if (!this.hasAttribute('selector-label')) {
            el.setAttribute('aria-label', DEFAULT_SELECTOR_LABEL);
            return;
        }
        this._setAriaLabel(el, 'selector-label');
    }

    /**
     * Render an attribute as `aria-label` on a shadow element, or remove
     * it when the attribute is absent.
     * @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-pager')) {
    customElements.define('tf-pager', TfPagerElement);
}