Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<td>`
* 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
Expand Down Expand Up @@ -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
Expand All @@ -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 `<td>` 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
}

Expand Down Expand Up @@ -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 <th> and copies
* it onto the matching column index in every body <td>.
*
* 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 <tbody> element for row insertion.
*/
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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 = `
<button type="button"
class="cmp-adaptiveform-table__mobile-bar-btn cmp-adaptiveform-table__mobile-bar-btn--sort"
aria-haspopup="listbox"
aria-label="Sort table"
${sortingEnabled ? '' : 'disabled'}>
<span aria-hidden="true">⇅</span><span>Sort</span>
</button>
<div class="cmp-adaptiveform-table__mobile-bar-divider" role="separator" aria-orientation="vertical"></div>
<button type="button"
class="cmp-adaptiveform-table__mobile-bar-btn cmp-adaptiveform-table__mobile-bar-btn--filter"
aria-haspopup="dialog"
aria-label="Filter table">
<span aria-hidden="true">▼</span><span>Filter</span>
</button>`;
widget.before(bar);

// --- Sort overlay ---
if (sortingEnabled) {
const optionsHtml = headers.map((h, i) => `
<li class="cmp-adaptiveform-table__sort-option"
role="option" tabindex="0" data-col-index="${i}" aria-selected="false">
<span class="cmp-adaptiveform-table__sort-option-label">${esc(h)}</span>
<span class="cmp-adaptiveform-table__sort-option-indicator" aria-hidden="true"></span>
</li>`).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 = `
<div class="cmp-adaptiveform-table__sort-sheet">
<div class="cmp-adaptiveform-table__sort-sheet-handle" aria-hidden="true"></div>
<p class="cmp-adaptiveform-table__sort-sheet-title">Sort by</p>
<ul class="cmp-adaptiveform-table__sort-options" role="listbox" aria-label="Sort columns">
${optionsHtml}
</ul>
</div>`;
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 = `
<div class="cmp-adaptiveform-table__sort-sheet">
<div class="cmp-adaptiveform-table__sort-sheet-handle" aria-hidden="true"></div>
<p class="cmp-adaptiveform-table__sort-sheet-title">Sort order</p>
<ul class="cmp-adaptiveform-table__sort-options" role="listbox" aria-label="Sort direction">
<li class="cmp-adaptiveform-table__sort-option"
role="option" tabindex="0" data-dir="asc" aria-selected="false">
<span>Ascending</span>
<span class="cmp-adaptiveform-table__sort-option-indicator" aria-hidden="true"></span>
</li>
<li class="cmp-adaptiveform-table__sort-option"
role="option" tabindex="0" data-dir="desc" aria-selected="false">
<span>Descending</span>
<span class="cmp-adaptiveform-table__sort-option-indicator" aria-hidden="true"></span>
</li>
</ul>
</div>`;
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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
}
Loading