Skip to content

Commit b5ab1c5

Browse files
committed
Add scrollable container and use it in tables
1 parent 3e7a841 commit b5ab1c5

6 files changed

Lines changed: 307 additions & 1 deletion

File tree

packages/nhsuk-frontend/src/nhsuk/components/tables/_index.scss

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,4 +347,24 @@ $sortable-table-hover-colour: color.adjust(nhsuk-colour("grey-5"), $lightness: -
347347
width: auto;
348348
height: 1.25rem;
349349
}
350+
351+
// Remove bottom margin from tables nested inside a scrollable container
352+
// to avoid double spacing (the container provides the margin).
353+
//
354+
// Pin the caption to the left of the visible scroll area so it
355+
// remains readable when the table overflows.
356+
// Wrap table caption within visible scroll area with max-inline-size.
357+
// (established by container-type: inline-size on .nhsuk-scrollable-container)
358+
.nhsuk-scrollable-container {
359+
.nhsuk-table {
360+
margin: 0;
361+
}
362+
363+
.nhsuk-table__caption {
364+
box-sizing: border-box;
365+
position: sticky;
366+
left: 0;
367+
max-inline-size: 100cqi;
368+
}
369+
}
350370
}

packages/nhsuk-frontend/src/nhsuk/components/tables/fixtures.mjs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,6 +1047,185 @@ const fixtures = {
10471047
]
10481048
]
10491049
}
1050+
},
1051+
'with scrollable container': {
1052+
context: {
1053+
caption: 'Childhood vaccination coverage by nation and year',
1054+
captionSize: 'm',
1055+
captionId: 'vaccination-coverage-caption',
1056+
scrollable: true,
1057+
firstCellIsHeader: true,
1058+
head: [
1059+
{
1060+
text: 'Nation'
1061+
},
1062+
{
1063+
text: '2019 to 2020',
1064+
format: 'numeric'
1065+
},
1066+
{
1067+
text: '2020 to 2021',
1068+
format: 'numeric'
1069+
},
1070+
{
1071+
text: '2021 to 2022',
1072+
format: 'numeric'
1073+
},
1074+
{
1075+
text: '2022 to 2023',
1076+
format: 'numeric'
1077+
},
1078+
{
1079+
text: '2023 to 2024',
1080+
format: 'numeric'
1081+
},
1082+
{
1083+
text: '2024 to 2025',
1084+
format: 'numeric'
1085+
},
1086+
{
1087+
text: '2025 to 2026',
1088+
format: 'numeric'
1089+
}
1090+
],
1091+
rows: [
1092+
[
1093+
{
1094+
text: 'England'
1095+
},
1096+
{
1097+
text: '91.5%',
1098+
format: 'numeric'
1099+
},
1100+
{
1101+
text: '89.2%',
1102+
format: 'numeric'
1103+
},
1104+
{
1105+
text: '90.1%',
1106+
format: 'numeric'
1107+
},
1108+
{
1109+
text: '91.0%',
1110+
format: 'numeric'
1111+
},
1112+
{
1113+
text: '83.7%',
1114+
format: 'numeric'
1115+
},
1116+
{
1117+
text: '85.1%',
1118+
format: 'numeric'
1119+
},
1120+
{
1121+
text: '86.4%',
1122+
format: 'numeric'
1123+
}
1124+
],
1125+
[
1126+
{
1127+
text: 'Northern Ireland'
1128+
},
1129+
{
1130+
text: '95.1%',
1131+
format: 'numeric'
1132+
},
1133+
{
1134+
text: '94.8%',
1135+
format: 'numeric'
1136+
},
1137+
{
1138+
text: '95.3%',
1139+
format: 'numeric'
1140+
},
1141+
{
1142+
text: '95.0%',
1143+
format: 'numeric'
1144+
},
1145+
{
1146+
text: '86.4%',
1147+
format: 'numeric'
1148+
},
1149+
{
1150+
text: '87.9%',
1151+
format: 'numeric'
1152+
},
1153+
{
1154+
text: '89.1%',
1155+
format: 'numeric'
1156+
}
1157+
],
1158+
[
1159+
{
1160+
text: 'Scotland'
1161+
},
1162+
{
1163+
text: '96.2%',
1164+
format: 'numeric'
1165+
},
1166+
{
1167+
text: '95.9%',
1168+
format: 'numeric'
1169+
},
1170+
{
1171+
text: '96.4%',
1172+
format: 'numeric'
1173+
},
1174+
{
1175+
text: '96.1%',
1176+
format: 'numeric'
1177+
},
1178+
{
1179+
text: '89.2%',
1180+
format: 'numeric'
1181+
},
1182+
{
1183+
text: '90.5%',
1184+
format: 'numeric'
1185+
},
1186+
{
1187+
text: '91.3%',
1188+
format: 'numeric'
1189+
}
1190+
],
1191+
[
1192+
{
1193+
text: 'Wales'
1194+
},
1195+
{
1196+
text: '94.8%',
1197+
format: 'numeric'
1198+
},
1199+
{
1200+
text: '94.5%',
1201+
format: 'numeric'
1202+
},
1203+
{
1204+
text: '95.1%',
1205+
format: 'numeric'
1206+
},
1207+
{
1208+
text: '94.9%',
1209+
format: 'numeric'
1210+
},
1211+
{
1212+
text: '89.5%',
1213+
format: 'numeric'
1214+
},
1215+
{
1216+
text: '91.0%',
1217+
format: 'numeric'
1218+
},
1219+
{
1220+
text: '92.2%',
1221+
format: 'numeric'
1222+
}
1223+
]
1224+
]
1225+
},
1226+
screenshot: {
1227+
viewports: ['mobile', 'tablet', 'desktop']
1228+
}
10501229
}
10511230
}
10521231

packages/nhsuk-frontend/src/nhsuk/components/tables/macro-options.mjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,18 @@ const options = {
158158
description: 'If set to `true`, responsive table classes will be applied.',
159159
released: '4.0.0'
160160
},
161+
scrollable: {
162+
type: 'boolean',
163+
required: false,
164+
description:
165+
'If set to `true`, the table will be wrapped in a scrollable container. The container uses `role="region"`, `aria-labelledby`, and `tabindex="0"` so that keyboard users can scroll it. A `caption` is required when using this option.'
166+
},
167+
captionId: {
168+
type: 'string',
169+
required: false,
170+
description:
171+
'ID for the table caption, used by the scrollable container\'s `aria-labelledby`. Auto-generated from the caption text when not provided.'
172+
},
161173
variant: {
162174
type: 'string',
163175
required: false,

packages/nhsuk-frontend/src/nhsuk/components/tables/template.njk

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,15 @@
4141

4242
{%- set headingLevel = params.headingLevel if params.headingLevel else 3 %}
4343

44+
{#- Set up scrollable container caption ID when scrollable is enabled #}
45+
{%- set captionId = params.captionId if params.captionId else (params.id ~ "-caption" if params.id else "table-caption-" ~ params.caption | replace(" ", "-") | lower | truncate(40, true, "")) -%}
46+
4447
{%- set table -%}
4548
<table class="{{ classNames }}" {%- if params.id %} id="{{ params.id }}"{% endif %}
4649
{%- if params.responsive %} role="table"{% endif %}
4750
{{- nhsukAttributes(params.attributes) }} data-module="nhsuk-table">
4851
{% if params.caption %}
49-
<caption class="{{ classNamesCaption }}">
52+
<caption class="{{ classNamesCaption }}"{% if params.scrollable %} id="{{ captionId }}"{% endif %}>
5053
{{ params.caption }}
5154
</caption>
5255
{% endif %}
@@ -134,6 +137,10 @@
134137
}) -%}
135138
{{ table | safe | trim | indent(4) }}
136139
{%- endcall %}
140+
{%- elif params.scrollable -%}
141+
<div class="nhsuk-scrollable-container" role="region"{% if params.caption %} aria-labelledby="{{ captionId }}"{% endif %} tabindex="0">
142+
{{ table | safe | trim | indent(4) }}
143+
</div>
137144
{%- else -%}
138145
{{ table | safe | trim }}
139146
{% endif %}

packages/nhsuk-frontend/src/nhsuk/core/objects/_index.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@
77
@forward "grid";
88
@forward "input-wrapper";
99
@forward "main-wrapper";
10+
@forward "scrollable-container";
1011
@forward "width-container";
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
@use "../settings" as *;
2+
@use "../tools" as *;
3+
@use "../helpers" as *;
4+
5+
////
6+
/// Scrollable container object
7+
///
8+
/// @group objects/scrollable-container
9+
////
10+
11+
// Wraps content in an accessible scrollable region. The container must
12+
// include role="region", aria-labelledby (pointing to a label id),
13+
// and tabindex="0" so that keyboard users can scroll it with arrow keys.
14+
// Based on https://adrianroselli.com/2020/11/under-engineered-responsive-tables.html
15+
//
16+
// Visual scroll indicators use the background-attachment: local trick from
17+
// Lea Verou (https://lea.verou.me/2012/04/background-attachment-local/).
18+
// Cover gradients scroll with the content (local), hiding the shadow at the
19+
// edges. The solid shadow (scroll) stays fixed to the container.
20+
// Override --nhsuk-scrollable-container-bg to match the containing element's
21+
// background if it is not the default page background.
22+
23+
@include nhsuk-exports("nhsuk/objects/scrollable-container") {
24+
.nhsuk-scrollable-container {
25+
--nhsuk-scrollable-container-bg: #{$nhsuk-body-background-colour};
26+
27+
// Establish an inline-size container so that children can use cqi units
28+
// to size themselves relative to the visible container width rather than
29+
// the (potentially wider) scrolling content.
30+
container-type: inline-size;
31+
32+
@include nhsuk-responsive-margin(7, "bottom");
33+
34+
&[role="region"][aria-labelledby][tabindex] {
35+
overflow-x: scroll;
36+
37+
// Horizontal scroll shadow indicators
38+
background:
39+
// Left cover — hides left shadow when scrolled to the start
40+
linear-gradient(
41+
to right,
42+
var(--nhsuk-scrollable-container-bg) nhsuk-spacing(2),
43+
transparent
44+
)
45+
left center
46+
/ nhsuk-spacing(2) 100%
47+
local
48+
no-repeat,
49+
// Right cover — hides right shadow when scrolled to the end
50+
linear-gradient(
51+
to left,
52+
var(--nhsuk-scrollable-container-bg) nhsuk-spacing(2),
53+
transparent
54+
)
55+
right center
56+
/ nhsuk-spacing(2) 100%
57+
local
58+
no-repeat,
59+
// Left scroll indicator shadow
60+
radial-gradient(
61+
ellipse nhsuk-spacing(2) 45% at left center,
62+
rgba($nhsuk-border-hover-colour, 0.4) 95%,
63+
transparent 100%
64+
)
65+
left center
66+
/ nhsuk-spacing(2) 100%
67+
scroll
68+
no-repeat,
69+
// Right scroll indicator shadow
70+
radial-gradient(
71+
ellipse nhsuk-spacing(2) 45% at right center,
72+
rgba($nhsuk-border-hover-colour, 0.4) 95%,
73+
transparent 100%
74+
)
75+
right center
76+
/ nhsuk-spacing(2) 100%
77+
scroll
78+
no-repeat;
79+
}
80+
81+
&[role="region"][aria-labelledby][tabindex]:focus {
82+
outline: $nhsuk-focus-width solid $nhsuk-focus-colour;
83+
outline-offset: $nhsuk-focus-width;
84+
box-shadow: 0 0 0 $nhsuk-focus-width $nhsuk-focus-text-colour;
85+
}
86+
}
87+
}

0 commit comments

Comments
 (0)