A single sortable table header cell with built-in sort, alignment and resize support.
The qds-column-header component encapsulates sorting, alignment and column resize behaviour for a single table header cell. Built as its own component so these behaviours are specified and tested once, rather than reimplemented inside qds-data-table and again in every bespoke grid in Q360.
<th> composition ruleA <th> must be a direct child of <tr> for the table layout algorithm to work. A custom element cannot be a <th>, and wrapping one breaks column sizing.
Resolution: qds-data-table creates real <th> elements and puts a qds-column-header inside each one. The header component sets display: block; width: 100% on its host so it fills the cell. The <th> carries the width and alignment; the header component carries the interactive content.
This is the only arrangement that keeps native table layout and gives the header its own encapsulated behaviour. The obvious alternative — display: table-cell on a custom element — silently breaks colspan, sticky headers and column sizing.
aria-sort ownership splitThe <th> owns aria-sort, not this component. aria-sort must be on the element with role="columnheader", which is the native <th>. This component never sets aria-sort on its own host; assistive tech will not find it there.
qds-data-table writes aria-sort="ascending", "descending" or "none" onto the <th> based on the sort state it manages.
<th aria-sort="descending">
<qds-column-header
key="marketValue"
label="Market value"
sortable
sort-direction="desc"
align="end"
></qds-column-header>
</th>
When the label is an icon or abbreviation, provide an accessible name via aria-label-text:
<th>
<qds-column-header
key="delta"
label="Δ"
aria-label-text="Daily change"
sortable
align="end"
></qds-column-header>
</th>
For multi-column sort, set sort-priority to render a priority number:
<th aria-sort="ascending">
<qds-column-header
key="tradeDate"
label="Trade date"
sortable
sort-direction="asc"
sort-priority="1"
></qds-column-header>
</th>
key — Column key; echoed in sort and resize events. Default ''.label — Column label text. Also settable as slotted text content. Default ''.align — start | center | end. Text alignment within the header. Default start.sortable — Boolean. When present, the header is sortable and the host receives tabindex="0".sort-direction — asc | desc | empty. Empty means unsorted. Default empty.resizable — Boolean. When present, renders a drag handle for column resize.sticky — start | end | empty. Passed through for styling; the table owns the offsets.aria-label-text — String. Accessible name when the label is an icon or abbreviation.sort-priority — String. Multi-sort priority number to display next to the sort indicator.None beyond reflected attributes.
header — The header container.label — The label text element.sort-indicator — The sort direction chevron.sort-priority — The multi-sort priority number.resize-handle — The column resize drag handle.--qds-column-header-background — Header surface colour. Default var(--theme-background-tertiary).--qds-column-header-color — Label text colour. Default var(--theme-text-secondary).--qds-column-header-border-color — Bottom border colour. Default var(--theme-border-strong).--qds-column-header-padding-x — Horizontal padding. Default var(--theme-space-3, 12px).--qds-column-header-font-size — Label font size. Default 13px.qds-sort — Fired on click, Enter or Space when sortable. Detail: { component, key, direction, additive }. direction is the requested direction ('asc', 'desc' or null), already advanced through the cycle. additive is true on Shift-click.qds-resize-start — Fired when the drag handle is pressed. Detail: { component, key, width }.qds-resize — Fired during drag, throttled to requestAnimationFrame. Detail: { component, key, width }.qds-resize-end — Fired when the drag handle is released. Detail: { component, key, width }.The header does not apply the resulting width itself. qds-data-table owns column widths; a header that resized itself would fight the table’s layout.
Three states: unsorted -> ascending -> descending -> unsorted. Returning to unsorted matters in financial tables, where the original order is often a meaningful sequence (trade order, ledger order) that the user needs to get back to without reloading.
Shift-click adds to a multi-column sort rather than replacing it. The header does not track multi-sort state itself; it fires the event with additive: true and the table decides.
align="end" puts the sort indicator to the left of the label in DOM order, so the label stays flush with right-aligned numerals below it. This detail is what makes a numeric column look right.
Enter / Space — Advances the sort cycle (when sortable).Shift + Enter — Additive sort.ArrowLeft / ArrowRight — When focus is on the resize handle, adjusts the reported width by 8px per press.The host is tabindex="0" when sortable, absent otherwise. The resize handle is a separate tab stop with its own aria-label (e.g. “Resize Market value column”).
<table>
<thead>
<tr>
<th>
<qds-column-header key="name" label="Name" sortable></qds-column-header>
</th>
<th aria-sort="descending">
<qds-column-header
key="marketValue"
label="Market value"
sortable
sort-direction="desc"
align="end"
resizable
></qds-column-header>
</th>
</tr>
</thead>
<tbody>
<tr><td>Alpha Fund</td><td>1,234,567</td></tr>
</tbody>
</table>
<script>
document.querySelector('qds-column-header').addEventListener('qds-sort', (e) => {
console.log('Sort requested:', e.detail.key, e.detail.direction, 'additive:', e.detail.additive);
});
</script>