diff --git a/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/table/v1/table/README.md b/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/table/v1/table/README.md index d3221a18b1..79a8b3042e 100644 --- a/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/table/v1/table/README.md +++ b/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/table/v1/table/README.md @@ -22,6 +22,8 @@ Adaptive Form Table component written in HTL that allows authors to capture data * Ability to contain `tableheader` and `tablerow` child components * Configurable proportional column widths via comma-separated values * Optional column sorting with ascending/descending toggle (per-column sort can be individually disabled) +* Mobile card layout — at `max-width: 768px` rows stack into cards; column headers are shown as inline labels via CSS `::before` using `data-label` attributes stamped on each `` +* Mobile action bar — Sort and Filter overlays accessible via bottom-sheet UI on narrow viewports; Sort requires `enableSorting` * Document of Record (DoR) support — table structure and column widths exported for XFA-based DoR rendering * Short description / long description / question mark help pattern * Visible and enabled state binding for rules engine @@ -68,6 +70,18 @@ BLOCK cmp-adaptiveform-table ELEMENT cmp-adaptiveform-tablerow__runtime-controls ELEMENT cmp-adaptiveform-tablerow__add-button ELEMENT cmp-adaptiveform-tablerow__remove-button + ELEMENT cmp-adaptiveform-table__mobile-bar (injected by JS; hidden on desktop) + ELEMENT cmp-adaptiveform-table__mobile-bar-btn + MODIFIER cmp-adaptiveform-table__mobile-bar-btn--sort + MODIFIER cmp-adaptiveform-table__mobile-bar-btn--filter + ELEMENT cmp-adaptiveform-table__mobile-bar-divider + ELEMENT cmp-adaptiveform-table__sort-overlay (shared by sort and filter bottom-sheet overlays) + ELEMENT cmp-adaptiveform-table__sort-sheet + ELEMENT cmp-adaptiveform-table__sort-sheet-handle + ELEMENT cmp-adaptiveform-table__sort-sheet-title + ELEMENT cmp-adaptiveform-table__sort-options + ELEMENT cmp-adaptiveform-table__sort-option + ELEMENT cmp-adaptiveform-table__sort-option-indicator ``` ## Theme Editor Support @@ -94,7 +108,10 @@ The following attributes are required for initialization: The following are optional attributes that can be added to the component: 1. `data-cmp-visible` - boolean indicating whether the component is currently visible 2. `data-cmp-enabled` - boolean indicating whether the component is currently enabled -3. `data-cmp-sorting-enabled` - set to `"true"` when `./enableSorting` is authored; controls sort button rendering in `tableheader.html` +3. `data-cmp-sorting-enabled` - set to `"true"` when `./enableSorting` is authored; controls sort button rendering in `tableheader.html` and enables the Sort button in the mobile action bar + +The following attribute is stamped by JavaScript on each `` in the table body at runtime: +1. `data-label` - set to the corresponding column header text; used by CSS `::before` to render inline labels in the mobile card layout (no author action required) ## Information * **Vendor**: Adobe diff --git a/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/table/v1/table/clientlibs/site/js/tableview.js b/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/table/v1/table/clientlibs/site/js/tableview.js index daca571477..81f8a063f9 100644 --- a/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/table/v1/table/clientlibs/site/js/tableview.js +++ b/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/table/v1/table/clientlibs/site/js/tableview.js @@ -34,12 +34,18 @@ this.children = []; /** @type {{ col: number, dir: 'asc'|'desc' }|null} */ this._tableSortState = null; + /** @type {HTMLElement|null} */ + this._mobileSortOverlay = null; + /** @type {HTMLElement|null} */ + this._mobileFilterOverlay = null; } setModel(model) { super.setModel(model); queueMicrotask(() => { this.#initColumnSortingIfEnabled(); + this.#stampMobileLabels(); + this.#initMobileSortBar(); }); } @@ -113,6 +119,47 @@ this.updateLabel(state.label); } + /** + * Mobile card layout (CSS-driven, via @media max-width:768px) stacks each + * row into a card and renders the column header as a ::before label sourced + * from each cell's data-label attribute. This method is the single point that + * stamps that attribute: it reads the header text from each and copies + * it onto the matching column index in every body . + * + * Layout itself is pure CSS — this only supplies the label text. It runs on + * init and again after rows are added so dynamically-cloned rows are covered. + * It never touches field state, visibility, or DOM structure. + * + * @param {HTMLElement} [scope] - Optional row element to limit stamping to + * (used after a single row is added); defaults to the whole tbody. + */ + #stampMobileLabels(scope) { + const widget = this.element.querySelector(Table.selectors.widget); + if (!widget) { + return; + } + const thead = widget.querySelector("thead"); + const tbody = widget.querySelector("tbody"); + if (!thead || !tbody) { + return; + } + const headers = Array.from(thead.querySelectorAll("th.cmp-adaptiveform-tablehead")) + .map((th) => th.innerText.replace(/\s+/g, " ").trim()); + if (headers.length === 0) { + return; + } + const rows = scope && scope.matches && scope.matches("tr") + ? [scope] + : Array.from(tbody.querySelectorAll(":scope > tr")); + rows.forEach((row) => { + Array.from(row.cells).forEach((cell, index) => { + if (index < headers.length && headers[index]) { + cell.setAttribute("data-label", headers[index]); + } + }); + }); + } + /** * Get the element for row insertion. */ @@ -177,6 +224,9 @@ // the wrong model index when clicked. this.#syncTableRowHooks(htmlElement, addedModel.id); + // Stamp mobile card labels on the freshly added row. + this.#stampMobileLabels(htmlElement); + return htmlElement; } @@ -263,13 +313,13 @@ * @param {HTMLTableSectionElement} thead * @param {number} colIndex */ - #sortTableByColumn(tbody, thead, colIndex) { + #sortTableByColumn(tbody, thead, colIndex, forceDir = null) { const rows = Array.from(tbody.querySelectorAll(":scope > tr")); if (rows.length <= 1) { return; } - let dir = "asc"; - if (this._tableSortState && this._tableSortState.col === colIndex) { + let dir = forceDir ?? "asc"; + if (!forceDir && this._tableSortState && this._tableSortState.col === colIndex) { dir = this._tableSortState.dir === "asc" ? "desc" : "asc"; } this._tableSortState = { col: colIndex, dir: dir }; @@ -321,6 +371,196 @@ } } + /** + * Builds the mobile action bar (Sort | Filter) inserted before the table widget. + * Bar is hidden on desktop via CSS. Overlays appended to document.body so + * position:fixed works freely. Sort requires enableSorting; filter is always on. + */ + #initMobileSortBar() { + const widget = this.element.querySelector(Table.selectors.widget); + if (!widget) return; + const thead = widget.querySelector('thead'); + const tbody = widget.querySelector('tbody'); + if (!thead || !tbody) return; + + const esc = (s) => s.replace(/&/g, '&').replace(//g, '>'); + const headers = Array.from(thead.querySelectorAll('th.cmp-adaptiveform-tablehead')) + .map((th) => th.innerText.replace(/\s+/g, ' ').trim()); + if (headers.length === 0) return; + + const sortingEnabled = this.element.dataset.cmpSortingEnabled === 'true'; + + // --- Action bar (inserted BEFORE the widget) --- + const bar = document.createElement('div'); + bar.className = 'cmp-adaptiveform-table__mobile-bar'; + bar.innerHTML = ` + + + `; + widget.before(bar); + + // --- Sort overlay --- + if (sortingEnabled) { + const optionsHtml = headers.map((h, i) => ` +
  • + ${esc(h)} + +
  • `).join(''); + + const sortOverlay = document.createElement('div'); + sortOverlay.className = 'cmp-adaptiveform-table__sort-overlay'; + sortOverlay.setAttribute('role', 'dialog'); + sortOverlay.setAttribute('aria-modal', 'true'); + sortOverlay.setAttribute('aria-label', 'Sort options'); + sortOverlay.innerHTML = ` +
    + +

    Sort by

    + +
    `; + document.body.appendChild(sortOverlay); + this._mobileSortOverlay = sortOverlay; + + bar.querySelector('.cmp-adaptiveform-table__mobile-bar-btn--sort').addEventListener('click', () => { + this.#openMobileSortSheet(); + }); + sortOverlay.addEventListener('click', (e) => { + if (e.target === sortOverlay) this.#closeMobileSortSheet(); + }); + sortOverlay.addEventListener('keydown', (e) => { + if (e.key === 'Escape') this.#closeMobileSortSheet(); + }); + sortOverlay.querySelectorAll('.cmp-adaptiveform-table__sort-option').forEach((opt) => { + const activate = () => { + const colIndex = parseInt(opt.dataset.colIndex, 10); + this.#sortTableByColumn(tbody, thead, colIndex); + this.#updateMobileSortIndicators(); + this.#closeMobileSortSheet(); + }; + opt.addEventListener('click', activate); + opt.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(); } + }); + }); + } + + // --- Filter overlay (Ascending / Descending direction picker) --- + const filterOverlay = document.createElement('div'); + filterOverlay.className = 'cmp-adaptiveform-table__sort-overlay'; + filterOverlay.setAttribute('role', 'dialog'); + filterOverlay.setAttribute('aria-modal', 'true'); + filterOverlay.setAttribute('aria-label', 'Sort order'); + filterOverlay.innerHTML = ` +
    + +

    Sort order

    + +
    `; + document.body.appendChild(filterOverlay); + this._mobileFilterOverlay = filterOverlay; + + bar.querySelector('.cmp-adaptiveform-table__mobile-bar-btn--filter').addEventListener('click', () => { + this.#openMobileFilterSheet(); + }); + filterOverlay.addEventListener('click', (e) => { + if (e.target === filterOverlay) this.#closeMobileFilterSheet(); + }); + filterOverlay.addEventListener('keydown', (e) => { + if (e.key === 'Escape') this.#closeMobileFilterSheet(); + }); + filterOverlay.querySelectorAll('.cmp-adaptiveform-table__sort-option').forEach((opt) => { + const activate = () => { + const dir = opt.dataset.dir; + const colIndex = this._tableSortState ? this._tableSortState.col : 0; + this.#sortTableByColumn(tbody, thead, colIndex, dir); + this.#updateMobileFilterIndicators(); + this.#closeMobileFilterSheet(); + }; + opt.addEventListener('click', activate); + opt.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(); } + }); + }); + } + + #openMobileSortSheet() { + if (!this._mobileSortOverlay) return; + this.#updateMobileSortIndicators(); + this._mobileSortOverlay.classList.add('is-open'); + document.body.style.overflow = 'hidden'; + const first = this._mobileSortOverlay.querySelector('.cmp-adaptiveform-table__sort-option'); + if (first) first.focus(); + } + + #closeMobileSortSheet() { + if (!this._mobileSortOverlay) return; + this._mobileSortOverlay.classList.remove('is-open'); + document.body.style.overflow = ''; + } + + #openMobileFilterSheet() { + if (!this._mobileFilterOverlay) return; + this.#updateMobileFilterIndicators(); + this._mobileFilterOverlay.classList.add('is-open'); + document.body.style.overflow = 'hidden'; + const first = this._mobileFilterOverlay.querySelector('.cmp-adaptiveform-table__sort-option'); + if (first) first.focus(); + } + + #closeMobileFilterSheet() { + if (!this._mobileFilterOverlay) return; + this._mobileFilterOverlay.classList.remove('is-open'); + document.body.style.overflow = ''; + } + + #updateMobileFilterIndicators() { + if (!this._mobileFilterOverlay) return; + this._mobileFilterOverlay.querySelectorAll('.cmp-adaptiveform-table__sort-option').forEach((opt) => { + const active = !!this._tableSortState && this._tableSortState.dir === opt.dataset.dir; + opt.setAttribute('aria-selected', active ? 'true' : 'false'); + const indicator = opt.querySelector('.cmp-adaptiveform-table__sort-option-indicator'); + if (indicator) indicator.textContent = active ? '✓' : ''; + }); + } + + #updateMobileSortIndicators() { + if (!this._mobileSortOverlay) return; + this._mobileSortOverlay.querySelectorAll('.cmp-adaptiveform-table__sort-option').forEach((opt) => { + const colIndex = parseInt(opt.dataset.colIndex, 10); + const indicator = opt.querySelector('.cmp-adaptiveform-table__sort-option-indicator'); + const active = this._tableSortState && this._tableSortState.col === colIndex; + opt.setAttribute('aria-selected', active ? 'true' : 'false'); + if (indicator) { + indicator.textContent = active ? (this._tableSortState.dir === 'asc' ? '↑' : '↓') : ''; + } + }); + } + /** * @param {HTMLTableCellElement|undefined} cell * @returns {string} diff --git a/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/tablerow/v1/tablerow/clientlibs/site/css/tablerowview.css b/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/tablerow/v1/tablerow/clientlibs/site/css/tablerowview.css index dcb91ae672..541d56d587 100644 --- a/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/tablerow/v1/tablerow/clientlibs/site/css/tablerowview.css +++ b/ui.af.apps/src/main/content/jcr_root/apps/core/fd/components/form/tablerow/v1/tablerow/clientlibs/site/css/tablerowview.css @@ -36,3 +36,9 @@ .cmp-adaptiveform-tablerow__add-button:focus-visible, .cmp-adaptiveform-tablerow__remove-button:focus-visible {} + +@media (max-width: 768px) { + .cmp-adaptiveform-tablecell.cmp-adaptiveform-tablecell--with-row-controls {} + + .cmp-adaptiveform-tablerow__runtime-controls {} +}