Skip to content

Commit 41cfb8f

Browse files
author
Fergus Bisset
committed
Simple Sortable Table Explorations for Sortable Data Table Working Group
1 parent d7841ef commit 41cfb8f

File tree

5 files changed

+890
-1
lines changed

5 files changed

+890
-1
lines changed
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import { Meta } from '@storybook/addon-docs/blocks';
2+
import Mermaid from '../_internal/Mermaid';
3+
4+
<Meta title="NHS/Content/Table/SimpleSortableTable"/>
5+
6+
# SortableTable Component Lifecycle Research
7+
8+
This documentation provides detailed analysis of sortable table component patterns,
9+
based on research from MOJ Frontend's SortableTable implementation.
10+
11+
## Component Lifecycle Diagram
12+
13+
The following diagram illustrates the complete lifecycle of a sortable table component,
14+
from initialisation through user interaction and DOM updates.
15+
16+
<Mermaid className="is-airy">{`
17+
---
18+
title: SortableTable Component Lifecycle
19+
---
20+
flowchart TD
21+
Start([Component Instantiation]) --> CheckElements{Check thead<br/>& tbody exist?}
22+
23+
CheckElements -->|No| EarlyReturn([Early Return])
24+
CheckElements -->|Yes| StoreRefs[Store DOM References<br/>$head, $body, $caption]
25+
26+
StoreRefs --> DefineIcons[Define SVG Icons<br/>↑ ↓ ↕]
27+
DefineIcons --> FindHeadings[Query all th elements<br/>in thead]
28+
29+
FindHeadings --> Phase1[Initialisation]
30+
31+
Phase1 --> CreateButtons[createHeadingButtons]
32+
CreateButtons --> IterateHeadings{For each heading<br/>with aria-sort}
33+
IterateHeadings --> WrapButton[Wrap text content<br/>in button element<br/>data-index=column]
34+
WrapButton --> IterateHeadings
35+
36+
IterateHeadings --> UpdateCaption[updateCaption]
37+
UpdateCaption --> CheckCaption{Caption exists?}
38+
CheckCaption -->|Yes| AddAssistive[Add visually-hidden<br/>assistive text about<br/>sortable columns]
39+
CheckCaption -->|No| UpdateIndicators
40+
AddAssistive --> UpdateIndicators
41+
42+
UpdateIndicators[updateDirectionIndicators] --> SyncIcons[Sync SVG icons with<br/>current aria-sort state<br/>ascending→↑<br/>descending→↓<br/>none→↕]
43+
44+
SyncIcons --> CreateStatus[createStatusBox]
45+
CreateStatus --> AddLiveRegion[Insert aria-live region<br/>after table for<br/>screen reader updates]
46+
47+
AddLiveRegion --> InitSorted[initialiseSortedColumn]
48+
InitSorted --> CheckPreSort{Find column with<br/>aria-sort=ascending<br/>or descending?}
49+
CheckPreSort -->|Yes| DoInitialSort[Sort rows by<br/>that column]
50+
CheckPreSort -->|No| AttachListener
51+
DoInitialSort --> ApplyRows[Replace tbody rows<br/>with sorted order]
52+
ApplyRows --> AttachListener
53+
54+
AttachListener[Attach click listener<br/>to thead] --> Ready([Ready State])
55+
56+
Ready --> UserClick([User Clicks<br/>Sort Button])
57+
58+
UserClick --> HandleClick[onSortButtonClick]
59+
HandleClick --> FindButton{Find button<br/>in event target?}
60+
FindButton -->|No| Ready
61+
FindButton -->|Yes| GetState[Get current<br/>aria-sort state]
62+
63+
GetState --> CalcDirection{Current state?}
64+
CalcDirection -->|none or descending| SetAsc[Set direction:<br/>ascending]
65+
CalcDirection -->|ascending| SetDesc[Set direction:<br/>descending]
66+
67+
SetAsc --> GetRows[getTableRowsArray<br/>Get all tbody rows]
68+
SetDesc --> GetRows
69+
70+
GetRows --> SortRows[sort rows, columnNumber, direction]
71+
72+
SortRows --> ExtractValues[For each row pair<br/>extract cell values]
73+
ExtractValues --> CheckDataAttr{Has data-sort-value<br/>attribute?}
74+
CheckDataAttr -->|Yes| UseDataAttr[Use attribute value]
75+
CheckDataAttr -->|No| UseInner[Use innerHTML]
76+
UseDataAttr --> IsNumber{Value is<br/>finite number?}
77+
UseInner --> IsNumber
78+
79+
IsNumber -->|Yes| NumericCompare[Numeric subtraction<br/>valueA - valueB]
80+
IsNumber -->|No| StringCompare[String localeCompare<br/>valueA.localeCompare valueB]
81+
82+
NumericCompare --> SwapOrder{Sort direction?}
83+
StringCompare --> SwapOrder
84+
SwapOrder -->|descending| ReverseAB[Swap A and B]
85+
SwapOrder -->|ascending| KeepAB[Keep A and B]
86+
87+
ReverseAB --> SortComplete
88+
KeepAB --> SortComplete[Array.sort complete]
89+
90+
SortComplete --> AddSorted[addRows<br/>Replace tbody content<br/>with sorted rows]
91+
92+
AddSorted --> ClearStates[removeButtonStates<br/>Set all columns<br/>aria-sort=none]
93+
94+
ClearStates --> UpdateButton[updateButtonState<br/>Set clicked column<br/>to new direction]
95+
96+
UpdateButton --> AnnounceSort[Update aria-live status<br/>Sort by %heading% %direction%]
97+
98+
AnnounceSort --> RefreshIcons[updateDirectionIndicators<br/>Update all SVG icons]
99+
100+
RefreshIcons --> Ready
101+
102+
class Start lifecycle-start
103+
class Ready lifecycle-ready
104+
class EarlyReturn lifecycle-error
105+
class Phase1 lifecycle-phase
106+
class UserClick lifecycle-user-action
107+
class SortComplete lifecycle-complete
108+
class AnnounceSort lifecycle-announce
109+
class CreateButtons,UpdateCaption,UpdateIndicators,CreateStatus,InitSorted initPhase
110+
class SortRows,ExtractValues,CheckDataAttr,IsNumber,NumericCompare,StringCompare sortPhase
111+
class AddSorted,ClearStates,UpdateButton,RefreshIcons domPhase
112+
`}</Mermaid>
113+
114+
## Key Architectural Patterns
115+
116+
### 1. Progressive Enhancement
117+
The SortableTable component exemplifies progressive enhancement by building JavaScript functionality on top of a fully functional HTML table that works without any scripting:
118+
119+
- **Base Layer (HTML)**: A standard `<table>` with `<thead>` and `<tbody>` elements that displays all data in a readable tabular format. The table is fully accessible and usable even if JavaScript fails to load or is disabled.
120+
121+
- **Enhancement Detection**: The component checks for required DOM elements (`thead` and `tbody`) before attempting any initialisation. If these elements aren't present, the component returns early without throwing errors, leaving the existing markup intact.
122+
123+
- **Graceful Degradation**: If JavaScript is unavailable:
124+
- The table remains visible with all its data
125+
- Column headers are still readable (they just won't be sortable buttons)
126+
- Screen readers can still navigate and understand the table structure
127+
- Users can still access all information, just without the sorting convenience
128+
129+
- **Enhancement Layer**: When JavaScript executes successfully, the component:
130+
- Wraps column header text in `<button>` elements for interaction
131+
- Adds ARIA attributes (`aria-sort`) to communicate state
132+
- Inserts visual indicators (SVG arrows) to show sort direction
133+
- Creates a live region for screen reader announcements
134+
- Enables dynamic reordering of rows based on user interaction
135+
136+
This approach ensures that core functionality (viewing data) is never dependent on JavaScript, while the sorting interaction is treated as a convenient enhancement rather than a requirement. This is particularly important for accessibility and resilience in government services where users may have varied browser capabilities or network conditions.
137+
138+
### 2. Accessibility First
139+
- **ARIA Live Regions**: Status box with `aria-live="polite"` announces sort changes
140+
- **Button Semantics**: Column headers become button elements when sortable
141+
- **Visual Indicators**: SVG icons synchronised with `aria-sort` state
142+
- **Screen Reader Support**: Visually-hidden caption text explains sortability
143+
144+
### 3. Data Handling
145+
- **data-sort-value attribute**: Allows custom sort values separate from display
146+
- **Type Detection**: Automatic numeric vs string comparison
147+
- **Locale-Aware**: Uses `localeCompare()` for string sorting
148+
149+
### 4. State Management
150+
- Single source of truth: `aria-sort` attribute on column headers
151+
- State flows: none → ascending → descending → ascending...
152+
- Only one column can have active sort state
153+
154+
### 5. Event Delegation
155+
- Single click listener on thead (not per-button)
156+
- Event bubbling used for scalability
157+
- Closest() selector for target identification
158+
159+
## Performance Considerations
160+
161+
### DOM Manipulation Strategy
162+
163+
1. **Batch Updates**: All row removals/additions happen together
164+
2. **Native Sort**: Leverages Array.prototype.sort for efficiency
165+
3. **Minimal Reflows**: Icon updates use `insertAdjacentHTML`
166+
167+
### Potential Optimisations
168+
169+
- Virtual scrolling for large datasets
170+
- Debouncing rapid clicks
171+
- Web Worker for sorting large datasets
172+
- Memoization of cell values
173+
174+
## Comparison with AriaTabsDataGrid
175+
176+
| Feature | MOJ SortableTable | AriaTabsDataGrid |
177+
|---------|-------------------|-------------------|
178+
| Framework | Vanilla JS | React |
179+
| ARIA Pattern | Table with sortable columns | Tabs + Grid composite |
180+
| State Management | DOM attributes | React state/hooks |
181+
| Sorting | Client-side DOM manipulation | Data transformation |
182+
| Accessibility | Manual ARIA + live regions | React Aria components |
183+
| Progressive Enhancement | Yes (works without JS) | Requires JavaScript |
184+
| Use Case | Simple sortable tables | Complex multi-panel data views |
185+
186+
## Implementation Notes
187+
188+
### Critical Success Factors
189+
1. **Initialise early**: Set up all ARIA attributes and buttons before user interaction
190+
2. **Maintain sync**: Keep visual indicators, ARIA state, and DOM order consistent
191+
3. **Announce changes**: Use aria-live regions for screen reader feedback
192+
4. **Preserve semantics**: Table structure remains valid HTML table
193+
194+
### Common Pitfalls
195+
- Forgetting to remove previous sort indicators
196+
- Not updating live region with meaningful messages
197+
- Missing data-sort-value for formatted numbers (e.g., currency)
198+
- Inadequate keyboard navigation support
199+
200+
## Testing Recommendations
201+
202+
### Unit Tests
203+
- ✅ Button creation from sortable headers
204+
- ✅ Sort direction cycling (none → asc → desc → asc)
205+
- ✅ Numeric vs string comparison logic
206+
- ✅ data-sort-value precedence over innerHTML
207+
208+
### Integration Tests
209+
- ✅ Full user interaction flow
210+
- ✅ ARIA live region announcements
211+
- ✅ Icon synchronisation with state
212+
- ✅ Multiple rapid clicks handling
213+
214+
### Accessibility Tests
215+
- ✅ Screen reader announces sort changes
216+
- ✅ Keyboard navigation works correctly
217+
- ✅ Focus management after sorting
218+
- ✅ Color not sole indicator of sort state
219+
220+
## References
221+
222+
- **MOJ Frontend**: [SortableTable Component](https://moj-frontend.herokuapp.com/components/sortable-table)
223+
- **ARIA Authoring Practices**: [Sortable Table Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/table/)
224+
- **GOV.UK Frontend**: [ConfigurableComponent Base Class](https://frontend.design-system.service.gov.uk/js-api-reference/)
225+
226+
## Future Enhancements
227+
228+
1. **Multi-column Sort**: Hold shift to sort by multiple columns
229+
2. **Sort Persistence**: Remember sort preferences in sessionStorage
230+
3. **Keyboard Shortcuts**: Dedicated keys for common sort operations
231+
4. **Custom Comparators**: Plugin system for domain-specific sorting
232+
5. **Animation**: Subtle transitions when rows reorder
233+
6. **Filters**: Combine sorting with client-side filtering
234+
7. **Export**: Download sorted data as CSV
235+
236+
---
237+
238+
*This research documentation is maintained as part of the NHS FDP Design System
239+
to inform future table component development and improvements.*
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
@use "../../../packages/nhs-fdp/scss" as nhs;
2+
3+
.nhsuk-sortable-table-container {
4+
position: relative;
5+
}
6+
7+
.nhsuk-sortable-table__button {
8+
font-family: nhs.$nhs-fdp-font-family-base, nhs.$nhs-fdp-font-family-fallback;
9+
font-size: nhs.$nhs-fdp-font-size-16-mobile;
10+
font-weight: nhs.$nhs-fdp-font-weight-bold;
11+
line-height: nhs.$nhs-fdp-font-line-height-16-mobile;
12+
background: none;
13+
border: 0;
14+
color: nhs.$nhs-fdp-semantic-color-text-primary;
15+
cursor: pointer;
16+
display: flex;
17+
align-items: center;
18+
gap: nhs.$nhs-fdp-spacing-2;
19+
padding: 0;
20+
text-align: left;
21+
width: 100%;
22+
23+
@media (min-width: nhs.$nhs-fdp-breakpoint-medium) {
24+
font-size: nhs.$nhs-fdp-font-size-16-tablet;
25+
line-height: nhs.$nhs-fdp-font-line-height-16-tablet;
26+
}
27+
28+
&:hover {
29+
color: nhs.$nhs-fdp-semantic-intent-secondary;
30+
31+
.nhsuk-sortable-table__icon {
32+
fill: nhs.$nhs-fdp-semantic-intent-secondary;
33+
}
34+
}
35+
36+
&:focus {
37+
outline: 3px solid transparent;
38+
outline-offset: 2px;
39+
color: nhs.$nhs-fdp-color-primary-black !important;
40+
background-color: nhs.$nhs-fdp-color-primary-yellow;
41+
box-shadow:
42+
0 -2px nhs.$nhs-fdp-color-primary-yellow,
43+
0 3px nhs.$nhs-fdp-semantic-color-text-primary;
44+
text-decoration: none;
45+
46+
svg.nhsuk-sortable-table__icon {
47+
fill: nhs.$nhs-fdp-color-primary-black !important;
48+
}
49+
}
50+
51+
&:active {
52+
color: nhs.$nhs-fdp-semantic-color-text-primary;
53+
}
54+
}
55+
56+
.nhsuk-sortable-table__button-text {
57+
flex: 1;
58+
}
59+
60+
.nhsuk-sortable-table__icon-wrapper {
61+
display: flex;
62+
align-items: center;
63+
flex-shrink: 0;
64+
}
65+
66+
.nhsuk-sortable-table__icon {
67+
width: 13px;
68+
height: 17px;
69+
fill: nhs.$nhs-fdp-semantic-color-text-primary;
70+
transition: fill 0.2s ease;
71+
}
72+
73+
// Active sort state styling
74+
th[aria-sort="ascending"],
75+
th[aria-sort="descending"] {
76+
.nhsuk-sortable-table__button {
77+
color: nhs.$nhs-fdp-semantic-intent-secondary;
78+
79+
.nhsuk-sortable-table__icon {
80+
fill: nhs.$nhs-fdp-semantic-intent-secondary;
81+
}
82+
}
83+
}

0 commit comments

Comments
 (0)