@reliability-design/web 1.4.3 → 1.4.4

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.
@@ -94,3 +94,4 @@ Two data modes:
94
94
  - Form validation is *displayed* (`error`, `error-message`, `required`), not performed — validity logic belongs to the form layer (e.g. Angular validators)
95
95
  - CSS parts: `panel`, `list`, `option`, plus re-exported textfield parts (`label`, `control`, `input`, `helper`, `right-icon`)
96
96
  - Panel styling matches `rel-select`'s listbox; override via `--rel-autocomplete-bg`, `--rel-autocomplete-menu-border`, `--rel-autocomplete-menu-radius`, `--rel-autocomplete-menu-shadow`, `--rel-autocomplete-option-hover`
97
+ - The open panel is promoted to the browser **top layer** (native Popover API, `popover="manual"`), so it renders correctly inside transformed / `overflow: hidden` containers — a centered `rel-modal`, a bottom `rel-drawer`. Engines without the Popover API fall back to `position: fixed` with a measure-and-cancel correction for transformed ancestors
@@ -143,6 +143,15 @@ export class RelAutocomplete extends LitElement {
143
143
  position: fixed;
144
144
  z-index: 1001;
145
145
  display: none;
146
+ /* Reset native [popover] UA styles (the panel is promoted to the
147
+ top layer so transformed ancestors — e.g. a centered rel-modal
148
+ panel — can't hijack its containing block or clip it). */
149
+ inset: auto;
150
+ margin: 0;
151
+ padding: 0;
152
+ width: auto;
153
+ height: auto;
154
+ color: inherit;
146
155
  box-sizing: border-box;
147
156
  background: var(--rel-autocomplete-bg, var(--rel-bg-neutral));
148
157
  border: 1px solid var(--rel-autocomplete-menu-border, var(--rel-border-default));
@@ -222,6 +231,7 @@ export class RelAutocomplete extends LitElement {
222
231
  disconnectedCallback() {
223
232
  super.disconnectedCallback();
224
233
  this.removeGlobalListeners();
234
+ this.hideNativePopover();
225
235
  clearTimeout(this.debounceTimer);
226
236
  }
227
237
 
@@ -309,7 +319,10 @@ export class RelAutocomplete extends LitElement {
309
319
  this.open = true;
310
320
  this.highlightedIndex = -1;
311
321
  this.addGlobalListeners();
312
- requestAnimationFrame(() => this.updatePosition());
322
+ requestAnimationFrame(() => {
323
+ this.showNativePopover();
324
+ this.updatePosition();
325
+ });
313
326
  }
314
327
 
315
328
  private closePanel() {
@@ -317,6 +330,35 @@ export class RelAutocomplete extends LitElement {
317
330
  this.open = false;
318
331
  this.highlightedIndex = -1;
319
332
  this.removeGlobalListeners();
333
+ this.hideNativePopover();
334
+ }
335
+
336
+ /* A transformed ancestor (e.g. rel-modal's centered panel with
337
+ * translate(-50%,-50%)) becomes the containing block for position:fixed
338
+ * descendants, breaking viewport-based coordinates and letting the
339
+ * panel's overflow:hidden clip the suggestions. Promoting the panel to
340
+ * the browser top layer sidesteps both — same fix as rel-popover.
341
+ * `manual` keeps light-dismiss and Escape handling under this
342
+ * component's own control. */
343
+
344
+ private showNativePopover() {
345
+ const p = this.panelEl;
346
+ if (!p || typeof p.showPopover !== "function") return;
347
+ try {
348
+ if (!p.matches(":popover-open")) p.showPopover();
349
+ } catch {
350
+ /* older engines / detached element — fixed-position fallback applies */
351
+ }
352
+ }
353
+
354
+ private hideNativePopover() {
355
+ const p = this.panelEl;
356
+ if (!p || typeof p.hidePopover !== "function") return;
357
+ try {
358
+ if (p.matches(":popover-open")) p.hidePopover();
359
+ } catch {
360
+ /* already hidden or unsupported — nothing to do */
361
+ }
320
362
  }
321
363
 
322
364
  private updatePosition = () => {
@@ -337,6 +379,18 @@ export class RelAutocomplete extends LitElement {
337
379
  left = Math.max(8, window.innerWidth - t.width - 8);
338
380
  }
339
381
 
382
+ // Without top-layer support a transformed ancestor may be the containing
383
+ // block for position:fixed, shifting the panel away from the viewport
384
+ // coordinates computed above. Apply, re-measure, and cancel the offset.
385
+ this.panelEl.style.top = `${top}px`;
386
+ this.panelEl.style.left = `${left}px`;
387
+ this.panelEl.style.width = `${t.width}px`;
388
+ const applied = this.panelEl.getBoundingClientRect();
389
+ top -= applied.top - top;
390
+ left -= applied.left - left;
391
+ this.panelEl.style.top = `${top}px`;
392
+ this.panelEl.style.left = `${left}px`;
393
+
340
394
  this.panelStyle = `top:${top}px;left:${left}px;width:${t.width}px;`;
341
395
  };
342
396
 
@@ -583,6 +637,7 @@ export class RelAutocomplete extends LitElement {
583
637
  id="${panelId}"
584
638
  part="panel list"
585
639
  class="ac-panel"
640
+ popover="manual"
586
641
  role="listbox"
587
642
  aria-label="${this.accessibleName || "Suggestions"}"
588
643
  style="${this.panelStyle}"
@@ -1,20 +1,41 @@
1
1
  # rel-checkbox
2
2
 
3
3
  ## Overview
4
- A tri-state checkbox component supporting checked, unchecked, and indeterminate states. Includes label and supporting text with built-in error and disabled states for form validation scenarios.
4
+ A tri-state checkbox component supporting checked, unchecked, and indeterminate states. Includes label, optional supporting text, and optional label prefix icon, with built-in error and disabled states. Supports two sizes, two densities, and leading/trailing box alignment — matching the Figma Checkbox sheet vocabulary (Size, Density, Alignment) verbatim.
5
5
 
6
6
  ## Usage
7
7
  ```html
8
8
  <!-- Basic example -->
9
9
  <rel-checkbox label="Accept terms"></rel-checkbox>
10
10
 
11
- <!-- Example with common options -->
11
+ <!-- Supporting text, compact density (default: text on its own line, indented past the box) -->
12
12
  <rel-checkbox
13
13
  label="Subscribe to newsletter"
14
14
  supportingtext="We'll send you weekly updates"
15
15
  status="unchecked"
16
16
  state="default">
17
17
  </rel-checkbox>
18
+
19
+ <!-- Comfortable density: label + supporting text stack beside the box -->
20
+ <rel-checkbox
21
+ density="comfortable"
22
+ label="Notify the broker"
23
+ supportingtext="Sends an email when the POD is uploaded">
24
+ </rel-checkbox>
25
+
26
+ <!-- Trailing alignment: box at the far edge of the row (settings-row style).
27
+ Trailing always renders comfortable density — compact has no trailing layout. -->
28
+ <rel-checkbox
29
+ alignment="trailing"
30
+ label="Email notifications"
31
+ supportingtext="Weekly summary of load activity">
32
+ </rel-checkbox>
33
+
34
+ <!-- Small size -->
35
+ <rel-checkbox size="small" label="Include archived"></rel-checkbox>
36
+
37
+ <!-- Indeterminate (programmatic-only; user toggle resolves to checked/unchecked) -->
38
+ <rel-checkbox label="Select all" status="indeterminate"></rel-checkbox>
18
39
  ```
19
40
 
20
41
  ## API
@@ -23,42 +44,62 @@ A tri-state checkbox component supporting checked, unchecked, and indeterminate
23
44
  | Name | Type | Default | Description |
24
45
  |------|------|---------|-------------|
25
46
  | `label` | `string` | `"Label"` | Label text displayed next to the checkbox |
26
- | `icon` | `string` | `""` | `rel-icon` name rendered between the checkbox and the label (label prefix icon). Unset renders no icon and reserves no space |
27
- | `supportingtext` | `string` | `""` | Helper text displayed below the checkbox and label |
47
+ | `icon` | `string` | `""` | `rel-icon` name rendered between the checkbox and the label (label prefix icon; scales with `size`). Unset renders no icon and reserves no space |
48
+ | `supportingtext` | `string` | `""` | Helper text; placement depends on `density` |
28
49
  | `state` | `"default" \| "error" \| "disabled"` | `"default"` | Visual and interaction state of the checkbox |
29
- | `status` | `"unchecked" \| "checked" \| "indeterminate"` | `"unchecked"` | Current checked state of the checkbox |
50
+ | `status` | `"unchecked" \| "checked" \| "indeterminate"` | `"unchecked"` | Current check state. Indeterminate is programmatic-only |
51
+ | `checked` | `boolean` (accessor) | `false` | Convenience accessor over `status` (used by the Angular CVA) |
52
+ | `size` | `"small" \| "medium"` | `"medium"` | Control scale: medium = 18px box, body-lg label, body-sm supporting text; small = 14px box, body-md label, body-xs supporting text (reflected) |
53
+ | `density` | `"compact" \| "comfortable"` | `"compact"` | Supporting-text layout: compact puts it on its own line under the row, indented past the box; comfortable stacks label + supporting text beside the box (reflected) |
54
+ | `alignment` | `"leading" \| "trailing"` | `"leading"` | Box position: leading = before the label; trailing = far edge of the row. Trailing always renders comfortable density — compact+trailing is "Not available" in the Figma sheet, and a dev-mode `console.warn` fires if requested (reflected) |
55
+ | `value` | `string` | `""` | Value reported in the `rel-change` event detail |
56
+
57
+ ### Layout matrix (mirrors the Figma sheet)
58
+ | Size | Density | Leading | Trailing |
59
+ |------|---------|---------|----------|
60
+ | M | Comfortable | ✓ | ✓ |
61
+ | M | Compact | ✓ (default) | — renders comfortable |
62
+ | S | Comfortable | ✓ | ✓ |
63
+ | S | Compact | ✓ | — renders comfortable |
30
64
 
31
65
  ## Events
32
66
  | Event | Detail | Description |
33
67
  |-------|--------|-------------|
34
- | `change` | `{ checked: boolean, indeterminate: boolean }` | Fired when checkbox state changes through user interaction |
68
+ | `rel-change` | `{ checked: boolean, value: string }` | Fired on user toggle (bubbles, composed) |
35
69
 
36
70
  ## Slots
37
71
  Default slot not used.
38
72
 
39
73
  ## CSS Parts
40
- None exposed.
74
+ - `base` — outer wrapper
75
+ - `label` — label text span
76
+ - `helper` — supporting text span
41
77
 
42
78
  ## Tokens / CSS Variables
43
79
 
44
80
  ### Semantic Tokens
45
81
  - `--rel-fonts-font-family` - Font family
46
- - `--rel-body-md-regular-font-size`, `--rel-body-sm-regular-font-size` - Font sizes for label and supporting text
47
- - `--rel-fonts-weight-body-medium` - Font weight for label
82
+ - `--rel-body-lg-regular-*` / `--rel-body-sm-regular-*` - Label and supporting text type (medium size)
83
+ - `--rel-body-md-regular-*` / `--rel-body-xs-regular-*` - Label and supporting text type (small size)
84
+
85
+ ### Spacing Tokens
86
+ - `--rel-spacing-xs` - Gap between box, icon, and label
87
+ - `--rel-spacing-3xs` - Gap between label and supporting text (comfortable) / row and supporting text (compact)
48
88
 
49
89
  ### Color Tokens
50
- - `--rel-text-label` - Checkbox border and checked background color
51
- - `--rel-bg-surface` - Checkbox background and checkmark color
52
- - `--rel-text-default` - Label text color
53
- - `--rel-text-placeholder` - Supporting text color
54
- - `--rel-bg-danger` - Error state border and background color
55
- - `--rel-border-info` - Focus outline color
90
+ - `--rel-text-default` - Box border and label text color (rest)
91
+ - `--rel-text-body` - Hover color for box, label, and icon
92
+ - `--rel-bg-neutral-inverse-2` - Checked box fill and indeterminate dash
93
+ - `--rel-text-inverse` - Checkmark color
94
+ - `--rel-text-caption` - Supporting text color
95
+ - `--rel-border-focus` - Focus outline color
96
+ - `--rel-border-danger` / `--rel-action-danger-fill` / `--rel-text-danger` - Error state colors
56
97
 
57
98
  ## Accessibility
58
99
  - Uses visually hidden native checkbox input for screen reader support
59
100
  - Custom visual checkbox with proper focus indicators
60
101
  - Focus visible state with outline offset for keyboard users
61
- - Label click targets the checkbox for larger interaction area
102
+ - Label click targets the checkbox for larger interaction area (in trailing alignment the whole row is clickable)
62
103
  - Disabled state communicated through opacity reduction and cursor change
63
104
 
64
105
  ### Keyboard Interactions
@@ -67,12 +108,18 @@ None exposed.
67
108
 
68
109
  ### Focus Behavior
69
110
  - Focus outline appears with offset around the custom checkbox box
70
- - Uses info border color for high contrast
111
+ - Uses focus border color for high contrast
71
112
 
72
113
  ## Notes / Edge Cases
73
- - Clicking cycles through three states in order: unchecked → checked → indeterminate → unchecked (repeating)
114
+ - User toggling is binary: indeterminate is never entered by interaction — toggling from indeterminate resolves to checked. Set `status="indeterminate"` programmatically (e.g. a Select-All parent driven by app state)
74
115
  - When `state="disabled"`, click events are blocked and opacity is reduced to 0.55
75
- - The indeterminate state displays a horizontal line instead of a checkmark
76
- - In error state, the checkbox border and checked background use error color
77
- - Supporting text is positioned below with left padding to align with checkbox content
78
- - Native checkbox input is visually hidden but maintains accessibility features
116
+ - The host is `display: block`, so a trailing checkbox stretches to its container width; constrain the parent to control row width
117
+ - Comfortable density with no supporting text is visually identical to compact
118
+ - The hidden native input is absolutely positioned against the `.row` containing block; keep `position: relative` on `.row` (Tab-scroll page-jump fix, shared with rel-radio)
119
+
120
+ ## Recipes (intentionally NOT component props)
121
+ - **Selectable list row**: `alignment="trailing"` checkbox inside a padded, hover-highlighted container
122
+ - **Select All / nested group**: parent checkbox with `status="indeterminate"` wired to children in app state
123
+ - **Selectable card**: `rel-card` wrapping a checkbox; toggle a selected border on `rel-change`
124
+
125
+ See the Storybook Checkbox stories for working recipe markup.
@@ -1,9 +1,12 @@
1
- import { LitElement, html, css } from "lit";
1
+ import { LitElement, html, css, type PropertyValues } from "lit";
2
2
  import { customElement, property } from "lit/decorators.js";
3
3
  import "@reliability-design/icons/rel-icon";
4
4
 
5
5
  type CheckboxState = "default" | "error" | "disabled";
6
6
  type CheckboxStatus = "unchecked" | "checked" | "indeterminate";
7
+ type CheckboxSize = "small" | "medium";
8
+ type CheckboxDensity = "compact" | "comfortable";
9
+ type CheckboxAlignment = "leading" | "trailing";
7
10
 
8
11
  @customElement("rel-checkbox")
9
12
  export class RelCheckbox extends LitElement {
@@ -15,6 +18,21 @@ export class RelCheckbox extends LitElement {
15
18
  @property({ type: String }) status: CheckboxStatus = "unchecked";
16
19
  @property({ type: String }) value = "";
17
20
 
21
+ /** Control scale (Figma Size). `"medium"` (default) = 18px box with body-lg
22
+ * label; `"small"` = 14px box with body-md label. */
23
+ @property({ type: String, reflect: true }) size: CheckboxSize = "medium";
24
+
25
+ /** Supporting-text layout (Figma Density). `"compact"` (default) puts the
26
+ * supporting text on its own line under the row, indented past the box;
27
+ * `"comfortable"` stacks label + supporting text beside the box. */
28
+ @property({ type: String, reflect: true }) density: CheckboxDensity = "compact";
29
+
30
+ /** Box position (Figma Alignment). `"leading"` (default) renders the box
31
+ * before the label; `"trailing"` pushes it to the far edge of the row.
32
+ * Trailing has no compact layout in the spec, so it always renders with
33
+ * comfortable density. */
34
+ @property({ type: String, reflect: true }) alignment: CheckboxAlignment = "leading";
35
+
18
36
  get checked(): boolean {
19
37
  return this.status === "checked";
20
38
  }
@@ -53,6 +71,29 @@ export class RelCheckbox extends LitElement {
53
71
  cursor: pointer;
54
72
  }
55
73
 
74
+ /* Trailing alignment — box moves to the far edge, label stack to the
75
+ start. row-reverse keeps the box first in DOM order (input + .box
76
+ adjacency drives the focus ring) while space-between stretches the
77
+ row to its container. */
78
+ :host([alignment="trailing"]) .row {
79
+ flex-direction: row-reverse;
80
+ justify-content: space-between;
81
+ }
82
+
83
+ /* Comfortable density — label and supporting text stack beside the box. */
84
+ .content {
85
+ display: flex;
86
+ flex-direction: column;
87
+ gap: var(--rel-spacing-3xs);
88
+ min-width: 0;
89
+ }
90
+
91
+ .label-line {
92
+ display: flex;
93
+ align-items: center;
94
+ gap: var(--rel-spacing-xs);
95
+ }
96
+
56
97
  input {
57
98
  position: absolute;
58
99
  inset: 0;
@@ -195,7 +236,6 @@ export class RelCheckbox extends LitElement {
195
236
 
196
237
  .text {
197
238
  color: var(--rel-text-caption);
198
- padding-left: 28px;
199
239
 
200
240
  font-family: var(--rel-body-sm-regular-font-family);
201
241
 
@@ -208,8 +248,63 @@ export class RelCheckbox extends LitElement {
208
248
  letter-spacing: var(--rel-body-sm-regular-letter-spacing);
209
249
 
210
250
  }
251
+
252
+ /* Compact density — supporting text sits under the whole row, indented
253
+ past the box (box width + row gap, matching the pre-existing 28px). */
254
+ .text-below {
255
+ padding-left: 28px;
256
+ }
257
+
258
+ /* Size variant (medium is the base scale above). */
259
+ :host([size="small"]) .box {
260
+ width: 14px;
261
+ height: 14px;
262
+ border-radius: 3px;
263
+ }
264
+
265
+ :host([size="small"]) svg {
266
+ width: 10px;
267
+ height: 10px;
268
+ }
269
+
270
+ :host([size="small"]) .label {
271
+ font-family: var(--rel-body-md-regular-font-family);
272
+ font-weight: var(--rel-body-md-regular-font-weight);
273
+ font-size: var(--rel-body-md-regular-font-size);
274
+ line-height: var(--rel-body-md-regular-line-height);
275
+ letter-spacing: var(--rel-body-md-regular-letter-spacing);
276
+ }
277
+
278
+ :host([size="small"]) .text {
279
+ font-family: var(--rel-body-xs-regular-font-family);
280
+ font-weight: var(--rel-body-xs-regular-font-weight);
281
+ font-size: var(--rel-body-xs-regular-font-size);
282
+ line-height: var(--rel-body-xs-regular-line-height);
283
+ letter-spacing: var(--rel-body-xs-regular-letter-spacing);
284
+ }
285
+
286
+ :host([size="small"]) .text-below {
287
+ padding-left: 24px;
288
+ }
211
289
  `;
212
290
 
291
+ protected willUpdate(changed: PropertyValues<this>) {
292
+ if (
293
+ (changed.has("alignment") || changed.has("density")) &&
294
+ this.alignment === "trailing" &&
295
+ this.density === "compact"
296
+ ) {
297
+ console.warn(
298
+ '[rel-checkbox] density="compact" has no trailing layout; rendering comfortable density.'
299
+ );
300
+ }
301
+ }
302
+
303
+ /** Trailing has no compact layout in the spec — trailing wins. */
304
+ private get effectiveDensity(): CheckboxDensity {
305
+ return this.alignment === "trailing" ? "comfortable" : this.density;
306
+ }
307
+
213
308
  private toggle() {
214
309
  if (this.state === "disabled") return;
215
310
 
@@ -256,15 +351,27 @@ export class RelCheckbox extends LitElement {
256
351
  </svg>
257
352
  </span>
258
353
 
259
- ${this.icon
260
- ? html`<rel-icon name="${this.icon}" size="md" class="icon-prefix"></rel-icon>`
261
- : null}
262
-
263
- <span part="label" class="label">${this.label}</span>
354
+ <span class="content">
355
+ <span class="label-line">
356
+ ${this.icon
357
+ ? html`<rel-icon
358
+ name="${this.icon}"
359
+ size="${this.size === "small" ? "sm" : "md"}"
360
+ class="icon-prefix"
361
+ ></rel-icon>`
362
+ : null}
363
+
364
+ <span part="label" class="label">${this.label}</span>
365
+ </span>
366
+
367
+ ${this.effectiveDensity === "comfortable" && this.supportingtext
368
+ ? html`<span part="helper" class="text">${this.supportingtext}</span>`
369
+ : null}
370
+ </span>
264
371
  </label>
265
372
 
266
- ${this.supportingtext
267
- ? html`<span part="helper" class="text">${this.supportingtext}</span>`
373
+ ${this.effectiveDensity === "compact" && this.supportingtext
374
+ ? html`<span part="helper" class="text text-below">${this.supportingtext}</span>`
268
375
  : null}
269
376
  </div>
270
377
  `;
@@ -377,6 +377,20 @@ All interactions disabled:
377
377
  <rel-pagination totalPages="10" page="5" disabled></rel-pagination>
378
378
  ```
379
379
 
380
+ ### Very Large Page Counts
381
+
382
+ Page buttons auto-widen to fit their number (min-width 40px). Far first/last
383
+ buttons with numbers ≥ 10,000 are abbreviated so the control stays compact:
384
+ `10000` → `10k`, `368178` → `368k`, `1234567` → `1.2M`. The exact page number
385
+ is preserved in the button's `aria-label` and `title` (hover tooltip). The
386
+ current page and its immediate neighbors always render exact — abbreviating
387
+ them would make adjacent pages indistinguishable.
388
+
389
+ ```html
390
+ <rel-pagination totalPages="368178" page="7"></rel-pagination>
391
+ <!-- [<] [1] ... [6] [7] [8] ... [368k] [>] -->
392
+ ```
393
+
380
394
  ## Browser Support
381
395
 
382
396
  Compatible with all modern browsers supporting:
@@ -1,4 +1,4 @@
1
- import { LitElement, html, css } from "lit";
1
+ import { LitElement, html, css, nothing } from "lit";
2
2
  import { customElement, property, state } from "lit/decorators.js";
3
3
 
4
4
  @customElement("rel-pagination")
@@ -133,9 +133,10 @@ export class RelPagination extends LitElement {
133
133
  display: inline-flex;
134
134
  align-items: center;
135
135
  justify-content: center;
136
- width: 40px;
136
+ width: auto;
137
+ min-width: 40px;
137
138
  height: 40px;
138
- padding: var(--rel-spacing-xs);
139
+ padding: 0 var(--rel-spacing-xs);
139
140
  border-radius: var(--rel-border-radius-sm);
140
141
  border: 1px solid var(--rel-border-default);
141
142
  background: var(--rel-bg-neutral);
@@ -376,6 +377,15 @@ export class RelPagination extends LitElement {
376
377
  return pages;
377
378
  }
378
379
 
380
+ /** Abbreviate large page numbers (368178 → "368k") so buttons stay compact.
381
+ Exact below 10,000; floored to k up to 1M; then M with one decimal. */
382
+ private formatPageLabel(n: number): string {
383
+ if (n < 10_000) return String(n);
384
+ if (n < 1_000_000) return `${Math.floor(n / 1000)}k`;
385
+ const m = Math.floor(n / 100_000) / 10;
386
+ return `${Number.isInteger(m) ? m.toFixed(0) : m}M`;
387
+ }
388
+
379
389
  private handlePageChange(newPage: number) {
380
390
  const total = this.computedTotalPages;
381
391
  if (this.disabled || newPage < 1 || newPage > total || newPage === this.activePage) return;
@@ -427,17 +437,23 @@ export class RelPagination extends LitElement {
427
437
  <div class="pag-stack">
428
438
  ${this.renderNavBtn("prev")}
429
439
  <div class="pag-numbers">
430
- ${pages.map((p) =>
431
- p === -1
432
- ? html`<span class="pag-ellipsis">...</span>`
433
- : html`<button
434
- type="button"
435
- class="pag-page-btn ${Math.abs(p - current) <= 1 ? "near" : ""} ${p === 1 || p === total ? "edge" : ""}"
436
- aria-label="Page ${p}"
437
- aria-current="${p === current ? "page" : "false"}"
438
- @click="${() => this.handlePageChange(p)}"
439
- >${p}</button>`
440
- )}
440
+ ${pages.map((p) => {
441
+ if (p === -1) return html`<span class="pag-ellipsis">...</span>`;
442
+ /* Only far first/last buttons abbreviate. Anything in the
443
+ visible window stays exact — abbreviating window pages like
444
+ 368174/368175 would render identical "368k" buttons. */
445
+ const isNear = Math.abs(p - current) <= 1;
446
+ const isEdge = p === 1 || p === total;
447
+ const label = isEdge && !isNear ? this.formatPageLabel(p) : String(p);
448
+ return html`<button
449
+ type="button"
450
+ class="pag-page-btn ${isNear ? "near" : ""} ${p === 1 || p === total ? "edge" : ""}"
451
+ aria-label="Page ${p}"
452
+ title="${label === String(p) ? nothing : `Page ${p}`}"
453
+ aria-current="${p === current ? "page" : "false"}"
454
+ @click="${() => this.handlePageChange(p)}"
455
+ >${label}</button>`;
456
+ })}
441
457
  </div>
442
458
  ${this.renderNavBtn("next")}
443
459
  </div>