Skip to content
Merged
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
61 changes: 60 additions & 1 deletion blocks/table/table.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
--table-border-width: 1px;
--table-head-row-padding-block: 7px;
--table-body-row-padding-block: 11px;
--table-scroll-hint-width: var(--s2-spacing-700);
--table-scroll-hint-color: var(--se-body-background-color);

position: relative;
container-type: inline-size;
container-name: table-wrapper;
margin-block: var(--s2-spacing-500) var(--s2-spacing-800);
Expand Down Expand Up @@ -71,6 +74,25 @@

.table-cell-value {
grid-column: 2;
min-inline-size: 0;
}

.table-type-cell {
se-disclosure {
/* Bound intrinsic sizing without fixing the other table columns. */
inline-size: 100%;
max-inline-size: 28rem;
}

> .table-cell-value:not(:has(se-disclosure)) {
background-color: rgb(from var(--s2-gray-1000) r g b / 8%);
font-family: monospace;
padding: 2px;
border-radius: 3px;
font-size: var(--s2-component-s-medium-font-size);
color: var(--s2-gray-800);
inline-size: fit-content;
}
}

tbody td svg {
Expand All @@ -94,9 +116,40 @@
position: relative;
}

@container table-wrapper (width >= 650px) {
.table-scroll {
overflow-x: auto;

/* Leave room for focus rings on links at the first and last rows. */
padding-block: var(--s2-spacing-100);
margin-block: calc(var(--s2-spacing-100) * -1);
}

/* Keep the hint outside the scroller so it stays at the visible edge. */
&::after {
content: '';
position: absolute;
inset-block: 0;
inset-inline-end: 0;
inline-size: var(--table-scroll-hint-width);
background: linear-gradient(to right, transparent, var(--table-scroll-hint-color));
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}

&:dir(rtl)::after {
background: linear-gradient(to left, transparent, var(--table-scroll-hint-color));
}

&[data-scroll-hint]::after {
opacity: 1;
}

@container table-wrapper (width >= 650px) {
.table-type-cell:has(se-disclosure) {
min-inline-size: 16rem;
}

table {
border-collapse: separate;
border-spacing: 0;
Expand Down Expand Up @@ -212,3 +265,9 @@
background-color: transparent;
}
}

@media (prefers-reduced-motion: reduce) {
.table::after {
transition: none;
}
}
104 changes: 102 additions & 2 deletions blocks/table/table.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const EXCLUDED_SOURCES = new Set([
// `kind`/`values`/`optional` back the playground's controls; this table shows the
// human-readable `type` instead.
const EXCLUDED_COLUMNS = new Set(['status', 'since', 'kind', 'values', 'optional']);
const TYPE_DISCLOSURE_THRESHOLD = 500;

/**
* The column-header text repeated inside a body cell. Only the narrow stacked layout
Expand All @@ -62,6 +63,30 @@ const buildCellValue = () => {
return value;
};

const buildDescription = (text) => {
const content = document.createDocumentFragment();
const tokens = /(?<!`)(`+)(?!`)([\s\S]*?)(?<!`)\1(?!`)|(?<!!)\[([^\]]+)\]\((https?:\/\/(?:[^()\s]|\([^()\s]*\))+)\)/gi;
let offset = 0;
for (const match of text.matchAll(tokens)) {
const [markdown, backticks, codeText, label, href] = match;
let node;
if (backticks) {
node = document.createElement('code');
node.textContent = codeText;
} else if (label.trim() && URL.canParse(href)) {
node = document.createElement('a');
node.href = href;
node.append(buildDescription(label));
}
if (node) {
content.append(text.slice(offset, match.index), node);
offset = match.index + markdown.length;
}
}
content.append(text.slice(offset));
return content;
};

const buildTableElement = (headerCells, dataCells) => {
const tableHead = document.createElement('thead');
tableHead.classList.add('header-row');
Expand Down Expand Up @@ -152,14 +177,79 @@ const buildDataTable = async (href) => {
const dataCells = rows.map((props) => properties.map((key) => {
const tableCell = document.createElement('td');
const value = buildCellValue();
value.textContent = props[key] || '-';
const resolvedValue = props[key] || '-';
if (key === 'property'
|| key === 'attribute'
|| key === 'type'
|| (key === 'default' && resolvedValue !== '-')) {
tableCell.classList.add('table-type-cell');
}
if (key === 'type' && typeof props[key] === 'string' && props[key].length > TYPE_DISCLOSURE_THRESHOLD) {
const disclosure = document.createElement('se-disclosure');
disclosure.setAttribute('preview', '');
disclosure.setAttribute('label', `View more about ${props.attribute || props.property || 'this property'}`);
disclosure.textContent = resolvedValue;
value.append(disclosure);
} else if (key === 'description' && typeof resolvedValue === 'string') {
value.append(buildDescription(resolvedValue));
} else {
value.textContent = resolvedValue;
}
tableCell.append(value);
return tableCell;
}));

return buildTableElement(headerCells, dataCells);
};

const buildScroller = (table, labelIds) => {
const scroller = document.createElement('div');
scroller.className = 'table-scroll';
scroller.tabIndex = 0;
if (labelIds.length) {
scroller.role = 'region';
scroller.setAttribute('aria-labelledby', labelIds.join(' '));
}
// WebKit does not natively scroll focused containers with horizontal arrows.
scroller.addEventListener('keydown', (event) => {
if (event.target !== scroller || event.defaultPrevented
|| event.altKey || event.ctrlKey || event.metaKey || event.shiftKey
|| !['ArrowLeft', 'ArrowRight'].includes(event.key)
|| scroller.scrollWidth - scroller.clientWidth <= 1) {
return;
}
event.preventDefault();
scroller.scrollBy({ left: event.key === 'ArrowLeft' ? -40 : 40 });
});
scroller.append(table);
return scroller;
};

const watchScrollHint = (el, scroller, instructions) => {
const update = () => {
const overflow = scroller.scrollWidth - scroller.clientWidth;
const remaining = overflow - Math.abs(scroller.scrollLeft);
el.toggleAttribute('data-scroll-hint', remaining > 1);
const descriptionId = overflow > 1 ? instructions.id : null;
if (scroller.getAttribute('aria-describedby') !== descriptionId) {
instructions.hidden = !descriptionId;
if (descriptionId) {
scroller.setAttribute('aria-describedby', descriptionId);
} else {
scroller.removeAttribute('aria-describedby');
}
}
};
update();
// Measure again when styles, content, or the surrounding layout change.
const observer = new ResizeObserver(update);
observer.observe(scroller);
observer.observe(scroller.querySelector('table'));
scroller.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update, { passive: true });
document.fonts?.ready.then(update);
};

export default async function init(el) {
const dataHref = el.querySelector('a[href$=".json"]')?.href;

Expand Down Expand Up @@ -187,5 +277,15 @@ export default async function init(el) {
return heading.id;
});
if (table && labelIds.length) { table.setAttribute('aria-labelledby', labelIds.join(' ')); }
el.tabIndex = 0;
if (table) {
if (table.querySelector('se-disclosure')) { await import('../../deps/se/se.js'); }
const scroller = buildScroller(table, labelIds);
const instructions = document.createElement('span');
instructions.id = `table-scroll-instructions-${Math.random().toString(36).slice(2)}`;
instructions.className = 'visually-hidden';
instructions.textContent = 'Scrollable table. Use the left and right arrow keys to view more.';
instructions.hidden = true;
el.replaceChildren(scroller, instructions);
watchScrollHint(el, scroller, instructions);
}
}
109 changes: 109 additions & 0 deletions deps/se/se.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,115 @@
}
}

:host(se-disclosure) {
min-inline-size: 0;
overflow-wrap: anywhere;
}

/* see this example in the SWC textfield component API table. */
.se-disclosure {
button {
/* Adding the trigger must not resize the table column being measured. */
contain: inline-size;
box-sizing: border-box;
display: flex;
align-items: start;
gap: var(--s2-spacing-75);
min-block-size: 24px;
max-inline-size: 100%;
padding: var(--s2-spacing-100) var(--s2-spacing-200);
border: none;
border-radius: var(--s2-corner-radius-500);
background: none;
box-shadow: none;
text-shadow: none;
inline-size: 100%;
color: var(--s2-gray-900);
font-family: var(--s2-font-family);
font-weight: var(--s2-heading-font-weight);
text-align: start;
cursor: pointer;

&:hover,
&:active {
background: var(--s2-gray-300);
}

&:active {
scale: 0.97;
}

svg {
flex: 0 0 16px;
inline-size: 10px;
block-size: 10px;
margin-block-start: 2px;
fill: none;
stroke: currentcolor;
stroke-width: 2;
transition: rotate 200ms;
}

&[aria-expanded="true"] svg {
rotate: 90deg;
}
}

.se-disclosure-label {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

button:focus-visible,
.se-disclosure-panel:focus-visible {
outline: var(--se-focus-indicator-width) solid var(--s2-blue-900);
outline-offset: -2px;
}

.se-disclosure-preview {
max-block-size: 4lh;
overflow: hidden;
white-space: pre-wrap;
padding: var(--s2-spacing-300) var(--s2-spacing-200);
border: 1px solid transparent;
font-family: monospace;
font-size: var(--s2-component-s-medium-font-size);
color: var(--s2-gray-800);
inline-size: fit-content;

&.measuring {
position: absolute;
inset-inline: 0;
visibility: hidden;
pointer-events: none;
}
}

.se-disclosure-panel[role="region"] {
box-sizing: border-box;
max-block-size: 20rem;
overflow: auto;
overscroll-behavior: contain;
padding: var(--s2-spacing-300) var(--s2-spacing-200);
border: 1px solid var(--s2-gray-400);
border-radius: var(--s2-corner-radius-300);
white-space: pre-wrap;
background-color: rgb(from var(--s2-gray-1000) r g b / 8%);
font-family: monospace;
font-size: var(--s2-component-s-medium-font-size);
color: var(--s2-gray-800);
inline-size: fit-content;
}

@media (prefers-reduced-motion: reduce) {
button svg {
transition: none;
}
}
}

/* Opt-in: keep the label wired to the field for assistive tech, but hide it visually
(mirrors the global .visually-hidden utility). Use on icon-only / self-evident fields. */
:host([hide-label]) label {
Expand Down
Loading
Loading