css-is-awesome 1.14.2 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +20 -0
- package/README.md +4 -4
- package/dist/tokens.d.ts +1 -1
- package/llm.txt +2 -2
- package/mcp/server.cjs +1 -1
- package/package.json +1 -1
- package/scss/recipes/README.md +1 -1
- package/scss/recipes/breadcrumb.md +265 -0
- package/scss/recipes/color-picker.md +605 -0
- package/scss/recipes/combobox-multiselect.md +695 -0
- package/scss/recipes/combobox.md +3 -2
- package/scss/recipes/file-upload.md +653 -0
- package/scss/recipes/pagination.md +444 -0
- package/scss/recipes/sortable-list.md +701 -0
- package/scss/recipes/toast.md +571 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pagination
|
|
3
|
+
description: A keyboard-accessible pager — first/prev/next/last plus a windowed page-number range with ellipses, built on cia.pagination and cia.pagination-item, collapsing to prev / "Page 3 of 12" / next on phones.
|
|
4
|
+
category: navigation
|
|
5
|
+
complexity: medium
|
|
6
|
+
cia-version: ">=1.0.0"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Use this when
|
|
10
|
+
|
|
11
|
+
You have a list, table, or search result set that is too long for one screen and the user needs to jump between numbered pages — an admin list, a blog archive, search results. The full pattern is a `<nav>` landmark holding **first / previous / a windowed run of page numbers / next / last**, with the current page marked `aria-current="page"` and an ellipsis wherever the run collapses. If you only need prev/next and a count (an infinite feed with a fallback, a wizard), the minimal pager inside the [`data-table`](./data-table.md) recipe is enough — this recipe is for when users need to *land on page 7 directly*.
|
|
12
|
+
|
|
13
|
+
## Structure (raw HTML)
|
|
14
|
+
|
|
15
|
+
Page numbers are **links** when every page has its own URL (`?page=7` — the SEO-friendly, shareable form), and **buttons** when paging is purely client-side state. Both are shown; pick one and stay consistent.
|
|
16
|
+
|
|
17
|
+
### Link variant (each page has a URL)
|
|
18
|
+
|
|
19
|
+
```html
|
|
20
|
+
<nav aria-label="Pagination" data-cia-recipe="pagination" class="my-pager">
|
|
21
|
+
<ul data-slot="list">
|
|
22
|
+
<li><a href="?page=1" data-slot="item" aria-label="First page">«</a></li>
|
|
23
|
+
<li data-slot="prev"><a href="?page=5" data-slot="item" aria-label="Previous page">‹</a></li>
|
|
24
|
+
|
|
25
|
+
<li><a href="?page=1" data-slot="item">1</a></li>
|
|
26
|
+
<li data-slot="ellipsis" aria-hidden="true">…</li>
|
|
27
|
+
<li><a href="?page=5" data-slot="item">5</a></li>
|
|
28
|
+
<li><a href="?page=6" data-slot="item" aria-current="page">6</a></li>
|
|
29
|
+
<li><a href="?page=7" data-slot="item">7</a></li>
|
|
30
|
+
<li data-slot="ellipsis" aria-hidden="true">…</li>
|
|
31
|
+
<li><a href="?page=12" data-slot="item">12</a></li>
|
|
32
|
+
|
|
33
|
+
<li data-slot="next"><a href="?page=7" data-slot="item" aria-label="Next page">›</a></li>
|
|
34
|
+
<li><a href="?page=12" data-slot="item" aria-label="Last page">»</a></li>
|
|
35
|
+
|
|
36
|
+
<li data-slot="summary" aria-live="polite">Page 6 of 12</li>
|
|
37
|
+
</ul>
|
|
38
|
+
</nav>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
On the first page, "First" and "Previous" are disabled. A link cannot take the `disabled` attribute, so it becomes `aria-disabled="true"` + `tabindex="-1"` and drops its `href`:
|
|
42
|
+
|
|
43
|
+
```html
|
|
44
|
+
<li><a data-slot="item" aria-label="First page" aria-disabled="true" tabindex="-1">«</a></li>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Button variant (client-side state)
|
|
48
|
+
|
|
49
|
+
```html
|
|
50
|
+
<nav aria-label="Pagination" data-cia-recipe="pagination" class="my-pager">
|
|
51
|
+
<ul data-slot="list">
|
|
52
|
+
<li><button type="button" data-slot="item" aria-label="First page" disabled>«</button></li>
|
|
53
|
+
<li data-slot="prev"><button type="button" data-slot="item" aria-label="Previous page" disabled>‹</button></li>
|
|
54
|
+
<li><button type="button" data-slot="item" aria-current="page">1</button></li>
|
|
55
|
+
<li><button type="button" data-slot="item">2</button></li>
|
|
56
|
+
<li><button type="button" data-slot="item">3</button></li>
|
|
57
|
+
<li data-slot="ellipsis" aria-hidden="true">…</li>
|
|
58
|
+
<li><button type="button" data-slot="item">12</button></li>
|
|
59
|
+
<li data-slot="next"><button type="button" data-slot="item" aria-label="Next page">›</button></li>
|
|
60
|
+
<li><button type="button" data-slot="item" aria-label="Last page">»</button></li>
|
|
61
|
+
<li data-slot="summary" aria-live="polite">Page 1 of 12</li>
|
|
62
|
+
</ul>
|
|
63
|
+
</nav>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Notes on the markup:
|
|
67
|
+
|
|
68
|
+
- `<nav aria-label="Pagination">` is a **landmark** — screen-reader users can jump straight to it from a landmark list. If a page has two pagers (top and bottom of a table), give them distinct labels ("Pagination, top" / "Pagination, bottom") or the landmark list shows two identical entries.
|
|
69
|
+
- The list is a real `<ul>`, so assistive tech announces "list, 12 items" (11 controls plus the summary) and the user knows how many controls there are before tabbing through them.
|
|
70
|
+
- The arrow controls use `aria-label` because "«" has no accessible name. Page-number controls need nothing extra — "6" is their name, and `aria-current="page"` is what marks the active one.
|
|
71
|
+
- The ellipsis is `aria-hidden="true"` — it is a visual cue for "pages omitted here," not a control, so it should be neither announced nor focusable.
|
|
72
|
+
- `[data-slot="summary"]` is an `aria-live="polite"` region so a page change is announced once ("Page 7 of 12") without moving focus. It lives *inside* the list on purpose: on phones it becomes the visible page indicator seated between the two arrows, and flex `order` can only reseat a sibling (see Variants). A live region announces from anywhere in the DOM, so this costs nothing on desktop.
|
|
73
|
+
|
|
74
|
+
## Styling (cia mixins)
|
|
75
|
+
|
|
76
|
+
```scss
|
|
77
|
+
// Pager.module.scss — component stylesheet, so import the zero-emit barrel.
|
|
78
|
+
@use 'css-is-awesome/api' as cia;
|
|
79
|
+
|
|
80
|
+
.my-pager {
|
|
81
|
+
[data-slot="list"] {
|
|
82
|
+
@include cia.pagination($gap: 2xs);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Both <a> and <button> items get the same box. The mixin already styles
|
|
86
|
+
// :hover, [aria-current="page"], and [disabled]; the button reset below
|
|
87
|
+
// is what makes a <button> look identical to an <a>.
|
|
88
|
+
[data-slot="item"] {
|
|
89
|
+
@include cia.button-reset;
|
|
90
|
+
@include cia.pagination-item($size: 2.25rem, $r: md);
|
|
91
|
+
@include cia.focus-ring;
|
|
92
|
+
|
|
93
|
+
// The link variant can't take `disabled` — it uses aria-disabled.
|
|
94
|
+
&[aria-disabled="true"] {
|
|
95
|
+
@include cia.disabled;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
[data-slot="ellipsis"] {
|
|
100
|
+
@include cia.flex($justify: center);
|
|
101
|
+
inline-size: 2.25rem;
|
|
102
|
+
color: cia.color(text-muted);
|
|
103
|
+
user-select: none;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Desktop: a status line under the run of pages (a full-width flex item).
|
|
107
|
+
[data-slot="summary"] {
|
|
108
|
+
flex-basis: 100%;
|
|
109
|
+
margin-block-start: cia.space(2);
|
|
110
|
+
@include cia.font(reg, 1);
|
|
111
|
+
color: cia.color(text-secondary);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The two inputs worth knowing: `cia.pagination-item($size, $r)` sets the hit area (2.25rem = 36px meets the 24px minimum with room to spare; use `2.75rem` for a touch-first product) and the corner radius (`full` gives circular pills). `cia.pagination($gap)` takes any spacing-scale key.
|
|
117
|
+
|
|
118
|
+
## Interactivity
|
|
119
|
+
|
|
120
|
+
The link variant needs **zero JS** — every page is a real URL and the server renders the right `aria-current`. The button variant is a few lines of state, and the only non-trivial piece is deciding *which* page numbers to show.
|
|
121
|
+
|
|
122
|
+
**Windowing.** Show the first and last page always, plus `siblings` pages either side of the current one. Collapse any gap larger than one page into an ellipsis:
|
|
123
|
+
|
|
124
|
+
```js
|
|
125
|
+
// pages(current, total, siblings = 1) → [1, "…", 5, 6, 7, "…", 12]
|
|
126
|
+
function pages(current, total, siblings = 1) {
|
|
127
|
+
const start = Math.max(2, current - siblings);
|
|
128
|
+
const end = Math.min(total - 1, current + siblings);
|
|
129
|
+
const out = [1];
|
|
130
|
+
if (start === 3) out.push(2);
|
|
131
|
+
else if (start > 3) out.push("…");
|
|
132
|
+
for (let p = start; p <= end; p++) out.push(p);
|
|
133
|
+
if (end === total - 2) out.push(total - 1);
|
|
134
|
+
else if (end < total - 1) out.push("…");
|
|
135
|
+
if (total > 1) out.push(total);
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
A gap of exactly one page (e.g. current = 3 → `1 2 3 4 … 12`) renders that page rather than an ellipsis standing in for a single number — the ellipsis would be wider than the number it hides.
|
|
141
|
+
|
|
142
|
+
**State.** Clamp every change: `setPage(Math.min(Math.max(1, next), total))`. Disable first/prev on page 1 and next/last on the last page. Update the summary text, and `aria-live` does the announcing.
|
|
143
|
+
|
|
144
|
+
**Keyboard.** Nothing custom. Tab moves between items in DOM order; Enter/Space activates. Do **not** turn the pager into an arrow-key roving-tabindex widget — a pager is a list of links, not a toolbar, and users expect Tab to work.
|
|
145
|
+
|
|
146
|
+
**URL sync.** For the button variant in a SPA, mirror the page into the query string (`history.replaceState`) so reload and share still land on the right page — otherwise you've built a pager the back button doesn't understand.
|
|
147
|
+
|
|
148
|
+
## A11y checklist
|
|
149
|
+
|
|
150
|
+
- [ ] The pager is a `<nav>` with an `aria-label` — a named landmark ([WAI-ARIA: navigation role](https://www.w3.org/TR/wai-aria-1.2/#navigation)); two pagers on one page have distinct labels
|
|
151
|
+
- [ ] The active page carries `aria-current="page"` and is styled distinctly, not by colour alone — `cia.pagination-item` fills it with `action-primary` *and* inverts the text ([WCAG 2.2 SC 1.4.1 Use of Color](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html))
|
|
152
|
+
- [ ] Arrow controls have an accessible name via `aria-label` ("Previous page"), since the glyph has none ([WCAG 2.2 SC 4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html))
|
|
153
|
+
- [ ] Disabled controls are genuinely unreachable: `disabled` on buttons, `aria-disabled="true"` + `tabindex="-1"` + no `href` on links ([WAI-ARIA: aria-disabled](https://www.w3.org/TR/wai-aria-1.2/#aria-disabled))
|
|
154
|
+
- [ ] The ellipsis is `aria-hidden="true"` and not focusable — it is decoration, not a control
|
|
155
|
+
- [ ] Every item is at least 24×24 CSS px — the default `$size: 2.25rem` gives 36px ([WCAG 2.2 SC 2.5.8 Target Size (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html))
|
|
156
|
+
- [ ] A page change is announced via the `aria-live="polite"` summary, and focus is not moved ([WCAG 2.2 SC 4.1.3 Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html))
|
|
157
|
+
- [ ] Focus is visible on every item (`cia.focus-ring`) ([WCAG 2.2 SC 2.4.7 Focus Visible](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html))
|
|
158
|
+
|
|
159
|
+
## Framework examples
|
|
160
|
+
|
|
161
|
+
All four implement the button variant with the same windowing function: first/prev/next/last, `siblings = 1`, ellipses, disabled edges, live summary.
|
|
162
|
+
|
|
163
|
+
### React
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
"use client";
|
|
167
|
+
import { useState } from "react";
|
|
168
|
+
import styles from "./Pager.module.scss";
|
|
169
|
+
|
|
170
|
+
function pages(current: number, total: number, siblings = 1): (number | "…")[] {
|
|
171
|
+
const start = Math.max(2, current - siblings);
|
|
172
|
+
const end = Math.min(total - 1, current + siblings);
|
|
173
|
+
const out: (number | "…")[] = [1];
|
|
174
|
+
if (start === 3) out.push(2);
|
|
175
|
+
else if (start > 3) out.push("…");
|
|
176
|
+
for (let p = start; p <= end; p++) out.push(p);
|
|
177
|
+
if (end === total - 2) out.push(total - 1);
|
|
178
|
+
else if (end < total - 1) out.push("…");
|
|
179
|
+
if (total > 1) out.push(total);
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export default function Pager({ total = 12, onChange }: { total?: number; onChange?: (page: number) => void }) {
|
|
184
|
+
const [page, setPage] = useState(1);
|
|
185
|
+
|
|
186
|
+
function go(next: number) {
|
|
187
|
+
const clamped = Math.min(Math.max(1, next), total);
|
|
188
|
+
setPage(clamped);
|
|
189
|
+
onChange?.(clamped);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const atStart = page === 1;
|
|
193
|
+
const atEnd = page === total;
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<nav aria-label="Pagination" className={styles.myPager}>
|
|
197
|
+
<ul data-slot="list">
|
|
198
|
+
<li><button type="button" data-slot="item" aria-label="First page" disabled={atStart} onClick={() => go(1)}>«</button></li>
|
|
199
|
+
<li data-slot="prev"><button type="button" data-slot="item" aria-label="Previous page" disabled={atStart} onClick={() => go(page - 1)}>‹</button></li>
|
|
200
|
+
{pages(page, total).map((p, i) =>
|
|
201
|
+
p === "…" ? (
|
|
202
|
+
<li key={`e${i}`} data-slot="ellipsis" aria-hidden="true">…</li>
|
|
203
|
+
) : (
|
|
204
|
+
<li key={p}>
|
|
205
|
+
<button type="button" data-slot="item" aria-current={p === page ? "page" : undefined} onClick={() => go(p)}>
|
|
206
|
+
{p}
|
|
207
|
+
</button>
|
|
208
|
+
</li>
|
|
209
|
+
),
|
|
210
|
+
)}
|
|
211
|
+
<li data-slot="next"><button type="button" data-slot="item" aria-label="Next page" disabled={atEnd} onClick={() => go(page + 1)}>›</button></li>
|
|
212
|
+
<li><button type="button" data-slot="item" aria-label="Last page" disabled={atEnd} onClick={() => go(total)}>»</button></li>
|
|
213
|
+
<li data-slot="summary" aria-live="polite">Page {page} of {total}</li>
|
|
214
|
+
</ul>
|
|
215
|
+
</nav>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Vue
|
|
221
|
+
|
|
222
|
+
```vue
|
|
223
|
+
<script setup>
|
|
224
|
+
import { computed, ref } from "vue";
|
|
225
|
+
|
|
226
|
+
const props = defineProps({ total: { type: Number, default: 12 } });
|
|
227
|
+
const emit = defineEmits(["change"]);
|
|
228
|
+
const page = ref(1);
|
|
229
|
+
|
|
230
|
+
function pages(current, total, siblings = 1) {
|
|
231
|
+
const start = Math.max(2, current - siblings);
|
|
232
|
+
const end = Math.min(total - 1, current + siblings);
|
|
233
|
+
const out = [1];
|
|
234
|
+
if (start === 3) out.push(2);
|
|
235
|
+
else if (start > 3) out.push("…");
|
|
236
|
+
for (let p = start; p <= end; p++) out.push(p);
|
|
237
|
+
if (end === total - 2) out.push(total - 1);
|
|
238
|
+
else if (end < total - 1) out.push("…");
|
|
239
|
+
if (total > 1) out.push(total);
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const items = computed(() => pages(page.value, props.total));
|
|
244
|
+
const atStart = computed(() => page.value === 1);
|
|
245
|
+
const atEnd = computed(() => page.value === props.total);
|
|
246
|
+
|
|
247
|
+
function go(next) {
|
|
248
|
+
page.value = Math.min(Math.max(1, next), props.total);
|
|
249
|
+
emit("change", page.value);
|
|
250
|
+
}
|
|
251
|
+
</script>
|
|
252
|
+
|
|
253
|
+
<template>
|
|
254
|
+
<nav aria-label="Pagination" class="my-pager">
|
|
255
|
+
<ul data-slot="list">
|
|
256
|
+
<li><button type="button" data-slot="item" aria-label="First page" :disabled="atStart" @click="go(1)">«</button></li>
|
|
257
|
+
<li data-slot="prev"><button type="button" data-slot="item" aria-label="Previous page" :disabled="atStart" @click="go(page - 1)">‹</button></li>
|
|
258
|
+
<template v-for="(p, i) in items" :key="p === '…' ? `e${i}` : p">
|
|
259
|
+
<li v-if="p === '…'" data-slot="ellipsis" aria-hidden="true">…</li>
|
|
260
|
+
<li v-else>
|
|
261
|
+
<button type="button" data-slot="item" :aria-current="p === page ? 'page' : undefined" @click="go(p)">{{ p }}</button>
|
|
262
|
+
</li>
|
|
263
|
+
</template>
|
|
264
|
+
<li data-slot="next"><button type="button" data-slot="item" aria-label="Next page" :disabled="atEnd" @click="go(page + 1)">›</button></li>
|
|
265
|
+
<li><button type="button" data-slot="item" aria-label="Last page" :disabled="atEnd" @click="go(total)">»</button></li>
|
|
266
|
+
<li data-slot="summary" aria-live="polite">Page {{ page }} of {{ total }}</li>
|
|
267
|
+
</ul>
|
|
268
|
+
</nav>
|
|
269
|
+
</template>
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Svelte
|
|
273
|
+
|
|
274
|
+
```svelte
|
|
275
|
+
<script>
|
|
276
|
+
export let total = 12;
|
|
277
|
+
export let onChange = (page) => {};
|
|
278
|
+
|
|
279
|
+
let page = 1;
|
|
280
|
+
|
|
281
|
+
function pages(current, total, siblings = 1) {
|
|
282
|
+
const start = Math.max(2, current - siblings);
|
|
283
|
+
const end = Math.min(total - 1, current + siblings);
|
|
284
|
+
const out = [1];
|
|
285
|
+
if (start === 3) out.push(2);
|
|
286
|
+
else if (start > 3) out.push("…");
|
|
287
|
+
for (let p = start; p <= end; p++) out.push(p);
|
|
288
|
+
if (end === total - 2) out.push(total - 1);
|
|
289
|
+
else if (end < total - 1) out.push("…");
|
|
290
|
+
if (total > 1) out.push(total);
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function go(next) {
|
|
295
|
+
page = Math.min(Math.max(1, next), total);
|
|
296
|
+
onChange(page);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
$: items = pages(page, total);
|
|
300
|
+
$: atStart = page === 1;
|
|
301
|
+
$: atEnd = page === total;
|
|
302
|
+
</script>
|
|
303
|
+
|
|
304
|
+
<nav aria-label="Pagination" class="my-pager">
|
|
305
|
+
<ul data-slot="list">
|
|
306
|
+
<li><button type="button" data-slot="item" aria-label="First page" disabled={atStart} on:click={() => go(1)}>«</button></li>
|
|
307
|
+
<li data-slot="prev"><button type="button" data-slot="item" aria-label="Previous page" disabled={atStart} on:click={() => go(page - 1)}>‹</button></li>
|
|
308
|
+
{#each items as p, i (p === "…" ? `e${i}` : p)}
|
|
309
|
+
{#if p === "…"}
|
|
310
|
+
<li data-slot="ellipsis" aria-hidden="true">…</li>
|
|
311
|
+
{:else}
|
|
312
|
+
<li>
|
|
313
|
+
<button type="button" data-slot="item" aria-current={p === page ? "page" : undefined} on:click={() => go(p)}>{p}</button>
|
|
314
|
+
</li>
|
|
315
|
+
{/if}
|
|
316
|
+
{/each}
|
|
317
|
+
<li data-slot="next"><button type="button" data-slot="item" aria-label="Next page" disabled={atEnd} on:click={() => go(page + 1)}>›</button></li>
|
|
318
|
+
<li><button type="button" data-slot="item" aria-label="Last page" disabled={atEnd} on:click={() => go(total)}>»</button></li>
|
|
319
|
+
<li data-slot="summary" aria-live="polite">Page {page} of {total}</li>
|
|
320
|
+
</ul>
|
|
321
|
+
</nav>
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### Vanilla (Web Component)
|
|
325
|
+
|
|
326
|
+
```js
|
|
327
|
+
function pages(current, total, siblings = 1) {
|
|
328
|
+
const start = Math.max(2, current - siblings);
|
|
329
|
+
const end = Math.min(total - 1, current + siblings);
|
|
330
|
+
const out = [1];
|
|
331
|
+
if (start === 3) out.push(2);
|
|
332
|
+
else if (start > 3) out.push("…");
|
|
333
|
+
for (let p = start; p <= end; p++) out.push(p);
|
|
334
|
+
if (end === total - 2) out.push(total - 1);
|
|
335
|
+
else if (end < total - 1) out.push("…");
|
|
336
|
+
if (total > 1) out.push(total);
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
class MyPager extends HTMLElement {
|
|
341
|
+
connectedCallback() {
|
|
342
|
+
this.total = Number(this.getAttribute("total") ?? 12);
|
|
343
|
+
this.page = Number(this.getAttribute("page") ?? 1);
|
|
344
|
+
this.className = "my-pager";
|
|
345
|
+
this.addEventListener("click", (e) => {
|
|
346
|
+
const btn = e.target.closest("[data-page]");
|
|
347
|
+
if (btn && !btn.disabled) this.go(Number(btn.dataset.page));
|
|
348
|
+
});
|
|
349
|
+
this.render();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
go(next) {
|
|
353
|
+
this.page = Math.min(Math.max(1, next), this.total);
|
|
354
|
+
this.render();
|
|
355
|
+
this.dispatchEvent(new CustomEvent("change", { detail: this.page }));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
render() {
|
|
359
|
+
const { page, total } = this;
|
|
360
|
+
const ctrl = (label, glyph, target, disabled, slot = "") =>
|
|
361
|
+
`<li ${slot ? `data-slot="${slot}"` : ""}><button type="button" data-slot="item" aria-label="${label}" data-page="${target}" ${disabled ? "disabled" : ""}>${glyph}</button></li>`;
|
|
362
|
+
const nums = pages(page, total)
|
|
363
|
+
.map((p) =>
|
|
364
|
+
p === "…"
|
|
365
|
+
? `<li data-slot="ellipsis" aria-hidden="true">…</li>`
|
|
366
|
+
: `<li><button type="button" data-slot="item" data-page="${p}" ${p === page ? 'aria-current="page"' : ""}>${p}</button></li>`,
|
|
367
|
+
)
|
|
368
|
+
.join("");
|
|
369
|
+
this.innerHTML = `
|
|
370
|
+
<nav aria-label="Pagination">
|
|
371
|
+
<ul data-slot="list">
|
|
372
|
+
${ctrl("First page", "«", 1, page === 1)}
|
|
373
|
+
${ctrl("Previous page", "‹", page - 1, page === 1, "prev")}
|
|
374
|
+
${nums}
|
|
375
|
+
${ctrl("Next page", "›", page + 1, page === total, "next")}
|
|
376
|
+
${ctrl("Last page", "»", total, page === total)}
|
|
377
|
+
<li data-slot="summary" aria-live="polite">Page ${page} of ${total}</li>
|
|
378
|
+
</ul>
|
|
379
|
+
</nav>`;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
customElements.define("my-pager", MyPager);
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
## Variants
|
|
386
|
+
|
|
387
|
+
### Mobile: prev / "Page 3 of 12" / next
|
|
388
|
+
|
|
389
|
+
Eleven 36px targets don't fit a phone width, and a thumb doesn't need "jump to page 9" anyway. Below the `md` breakpoint hide the numbered run and the first/last jumps, and let the live summary sit *between* prev and next as the visible indicator. Pure CSS — the markup is unchanged, so the wide layout comes back on rotate without any JS:
|
|
390
|
+
|
|
391
|
+
```scss
|
|
392
|
+
@use 'css-is-awesome/api' as cia;
|
|
393
|
+
|
|
394
|
+
.my-pager {
|
|
395
|
+
@include cia.mobile-only {
|
|
396
|
+
[data-slot="list"] {
|
|
397
|
+
justify-content: space-between;
|
|
398
|
+
flex-wrap: nowrap;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Keep only Previous / the summary / Next.
|
|
402
|
+
li:not([data-slot="prev"]):not([data-slot="next"]):not([data-slot="summary"]) {
|
|
403
|
+
display: none;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
[data-slot="prev"] { order: 0; }
|
|
407
|
+
[data-slot="summary"] {
|
|
408
|
+
order: 1;
|
|
409
|
+
flex-basis: auto;
|
|
410
|
+
margin: 0;
|
|
411
|
+
}
|
|
412
|
+
[data-slot="next"] { order: 2; }
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
This needs `data-slot="prev"` / `data-slot="next"` on the two arrow `<li>`s (the framework examples below carry them). The summary is already an `<li>`, so `order` can seat it between the arrows — that's the whole reason it lives inside the list.
|
|
418
|
+
|
|
419
|
+
### Pill shape and bigger targets
|
|
420
|
+
|
|
421
|
+
```scss
|
|
422
|
+
@use 'css-is-awesome/api' as cia;
|
|
423
|
+
|
|
424
|
+
.my-pager [data-slot="item"] {
|
|
425
|
+
@include cia.pagination-item($size: 2.75rem, $r: full);
|
|
426
|
+
}
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### Page-size selector
|
|
430
|
+
|
|
431
|
+
Pair the pager with a native `<select>` ("10 / 25 / 50 per page") styled with `cia.select-base`, and reset to page 1 whenever the size changes — page 7 of 12 is page 3 of 5 at the new size, and users find a silently-out-of-range page more confusing than a jump back to the start.
|
|
432
|
+
|
|
433
|
+
## Pitfalls
|
|
434
|
+
|
|
435
|
+
- **Don't hide disabled controls.** Removing "Previous" on page 1 makes the layout shift and the control positions move under the cursor. Disable it in place — `cia.pagination-item` already dims it.
|
|
436
|
+
- **Don't make page numbers `<span onclick>`.** They must be `<a href>` or `<button>` — anything else is unreachable by keyboard and nameless to screen readers, no matter how it's styled.
|
|
437
|
+
- **Don't announce with `role="alert"`.** A page change is a status, not an error; `aria-live="polite"` waits for the user to finish what they're doing. `alert` interrupts.
|
|
438
|
+
- **Sort, then paginate.** If the pager sits on a sortable table, apply the sort to the whole data set before slicing the page — see [`data-table`](./data-table.md). Sorting only the visible page is the single most common pager bug.
|
|
439
|
+
- **Server + client both rendering `aria-current`.** In an SSR framework, derive the current page from the URL on both sides so hydration doesn't briefly show page 1 as active.
|
|
440
|
+
|
|
441
|
+
## Related recipes
|
|
442
|
+
|
|
443
|
+
- [`data-table`](./data-table.md) — the minimal prev/next pager, and the sort-then-paginate rule this recipe assumes
|
|
444
|
+
- [`admin-dashboard-layout`](./admin-dashboard-layout.md) — where a full pager most often sits, under a filtered list
|