@pienter/ui 0.2.0 → 0.5.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/CONVENTIONS.md +297 -385
  3. package/README.md +3 -18
  4. package/components/feedback/toast/toast.css +1 -1
  5. package/components/form/combobox/Combobox.vue +21 -38
  6. package/components/form/combobox/combobox.css +1 -1
  7. package/components/form/form/Form.vue +1 -2
  8. package/components/form/label/label.css +1 -1
  9. package/components/form/number-field/NumberField.vue +1 -1
  10. package/components/form/number-field/number-field.css +1 -1
  11. package/components/form/radio-group/RadioGroup.vue +2 -5
  12. package/components/form/slider/slider.css +2 -3
  13. package/components/form/tags-input/tags-input.css +1 -2
  14. package/components/form/textarea/textarea.css +1 -1
  15. package/components/layout/accordion/Accordion.vue +6 -27
  16. package/components/layout/collapsible/Collapsible.vue +9 -19
  17. package/components/layout/table/table.css +3 -3
  18. package/components/navigation/pagination/Pagination.vue +1 -1
  19. package/components/navigation/pagination/PaginationFooter.vue +23 -0
  20. package/components/navigation/pagination/pagination.css +128 -29
  21. package/components/navigation/sidebar/Sidebar.vue +326 -84
  22. package/components/navigation/sidebar/SidebarMenuItem.vue +199 -0
  23. package/components/navigation/sidebar/context.ts +21 -0
  24. package/components/navigation/sidebar/sidebar.css +346 -78
  25. package/components/navigation/sidebar/types.ts +13 -1
  26. package/components/overlay/alert-dialog/AlertDialog.vue +10 -31
  27. package/components/overlay/command/Command.vue +10 -38
  28. package/components/overlay/command/command.css +1 -1
  29. package/components/overlay/dropdown-menu/DropdownMenu.vue +37 -62
  30. package/components/overlay/modal/Modal.vue +7 -28
  31. package/components/overlay/popover/Popover.vue +9 -31
  32. package/components/overlay/sheet/Sheet.vue +7 -28
  33. package/components/overlay/tooltip/Tooltip.vue +14 -19
  34. package/{utils/controllers/dialog.ts → composables/useDialog.ts} +90 -78
  35. package/composables/useDisclosure.ts +113 -0
  36. package/composables/useEventListener.ts +16 -0
  37. package/{utils/controllers/menu.ts → composables/useMenu.ts} +66 -102
  38. package/{utils/controllers/popover.ts → composables/usePopover.ts} +107 -120
  39. package/package.json +6 -38
  40. package/styles/0-settings/colors.css +8 -1
  41. package/styles/0-settings/layout.css +18 -0
  42. package/styles/0-settings/motion.css +2 -2
  43. package/styles/0-settings/spacing.css +3 -1
  44. package/utils/navigation/sidebar.ts +97 -0
  45. package/utils/validation/form.ts +6 -9
  46. package/components/action/button/Button.astro +0 -95
  47. package/components/action/button/IconButton.astro +0 -86
  48. package/components/display/avatar/Avatar.astro +0 -17
  49. package/components/display/avatar/AvatarStack.astro +0 -9
  50. package/components/display/badge/Badge.astro +0 -15
  51. package/components/display/empty/Empty.astro +0 -9
  52. package/components/display/icon/Icon.astro +0 -52
  53. package/components/feedback/alert/Alert.astro +0 -52
  54. package/components/feedback/progress/Progress.astro +0 -68
  55. package/components/feedback/skeleton/Skeleton.astro +0 -32
  56. package/components/feedback/spinner/Spinner.astro +0 -25
  57. package/components/feedback/toast/Toast.astro +0 -50
  58. package/components/form/checkbox/Checkbox.astro +0 -79
  59. package/components/form/date-input/DateInput.astro +0 -105
  60. package/components/form/form/Form.astro +0 -106
  61. package/components/form/input-otp/InputOTP.astro +0 -147
  62. package/components/form/label/Label.astro +0 -13
  63. package/components/form/number-field/NumberField.astro +0 -142
  64. package/components/form/radio-group/RadioGroup.astro +0 -105
  65. package/components/form/select/Select.astro +0 -105
  66. package/components/form/slider/Slider.astro +0 -205
  67. package/components/form/switch/Switch.astro +0 -75
  68. package/components/form/tags-input/TagsInput.astro +0 -153
  69. package/components/form/text-input/TextInput.astro +0 -84
  70. package/components/form/textarea/Textarea.astro +0 -86
  71. package/components/layout/card/Card.astro +0 -13
  72. package/components/layout/separator/Separator.astro +0 -31
  73. package/components/layout/table/Table.astro +0 -116
  74. package/components/navigation/breadcrumb/Breadcrumb.astro +0 -36
  75. package/components/navigation/navbar/Navbar.astro +0 -62
  76. package/components/navigation/sidebar/Sidebar.astro +0 -132
  77. package/components/navigation/sidebar/SidebarItemRender.astro +0 -83
  78. package/components/navigation/sidebar/SidebarItemRender.vue +0 -98
  79. package/components/overlay/alert-dialog/AlertDialog.astro +0 -112
  80. package/components/overlay/modal/Modal.astro +0 -66
  81. package/utils/controllers/disclosure.ts +0 -117
  82. package/utils/controllers/form.ts +0 -524
  83. package/utils/controllers/index.ts +0 -39
  84. package/utils/controllers/number-field.ts +0 -103
  85. package/utils/controllers/otp.ts +0 -252
  86. package/utils/controllers/sidebar.ts +0 -610
  87. package/utils/controllers/slider.ts +0 -336
  88. package/utils/controllers/tags-input.ts +0 -255
  89. package/utils/controllers/toast.ts +0 -426
package/README.md CHANGED
@@ -1,20 +1,13 @@
1
1
  # @pienter/ui
2
2
 
3
- Shared Pienter UI package with framework-specific components, CSS, icons, and browser utilities.
3
+ Shared Pienter UI package with Vue components, CSS, icons, and browser utilities.
4
4
 
5
- This package publishes raw source files. Consuming projects compile the Vue, Astro, TypeScript, and CSS files with their own toolchain.
5
+ This package publishes raw source files. Consuming projects compile the Vue, TypeScript, and CSS files with their own toolchain.
6
6
 
7
7
  ## Install
8
8
 
9
9
  ```sh
10
- npm install @pienter/ui
11
- ```
12
-
13
- Install the framework peer you use:
14
-
15
- ```sh
16
- npm install vue
17
- npm install astro
10
+ npm install @pienter/ui vue
18
11
  ```
19
12
 
20
13
  ## Usage
@@ -24,14 +17,6 @@ import Button from '@pienter/ui/components/Button.vue';
24
17
  import '@pienter/ui/styles';
25
18
  ```
26
19
 
27
- Astro components are available beside Vue components:
28
-
29
- ```astro
30
- ---
31
- import Button from '@pienter/ui/components/Button.astro';
32
- ---
33
- ```
34
-
35
20
  Components use explicit flat public entrypoints. Intentional shared types are
36
21
  available through module type entrypoints:
37
22
 
@@ -9,7 +9,7 @@
9
9
  display: flex;
10
10
  flex-direction: column;
11
11
  gap: var(--space-2xs);
12
- z-index: 1000;
12
+ z-index: var(--layer-top);
13
13
  pointer-events: none;
14
14
  max-width: calc(100vw - var(--space-s) * 2);
15
15
  }
@@ -85,12 +85,8 @@
85
85
  </template>
86
86
 
87
87
  <script setup lang="ts">
88
- import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue';
89
- import {
90
- mountPopover,
91
- type PopoverControl,
92
- type Placement,
93
- } from '../../../utils/controllers/popover.js';
88
+ import { computed, ref, watch } from 'vue';
89
+ import { usePopover, type Placement } from '../../../composables/usePopover.js';
94
90
  import { generateId } from '../../../utils/a11y/id.js';
95
91
 
96
92
  defineOptions({ inheritAttrs: false });
@@ -218,7 +214,25 @@ const activeIndex = ref<number | null>(null);
218
214
  // label of the currently-selected option.
219
215
  const isTyping = ref(false);
220
216
 
221
- let control: PopoverControl | null = null;
217
+ // Anchor the listbox to the input itself so positioning tracks the
218
+ // input's box (the input is the visual reference, not the wrapper).
219
+ // `modal: false` → `popover="auto"` so the browser handles
220
+ // outside-click / focus-out / Escape light-dismiss. The listbox
221
+ // closes naturally when the user tabs or clicks away.
222
+ const control = usePopover(listboxRef, {
223
+ anchor: inputRef,
224
+ placement: () => props.placement,
225
+ offset: () => props.offset,
226
+ onOpen: () => {
227
+ isOpen.value = true;
228
+ emit('open-change', true);
229
+ },
230
+ onClose: () => {
231
+ isOpen.value = false;
232
+ activeIndex.value = null;
233
+ emit('open-change', false);
234
+ },
235
+ });
222
236
 
223
237
  const hasErrors = computed(() => props.errors.length > 0);
224
238
 
@@ -271,12 +285,10 @@ const activeOptionDomId = computed(() => {
271
285
 
272
286
  function open(): void {
273
287
  if (props.disabled) return;
274
- if (!control) return;
275
288
  control.show();
276
289
  }
277
290
 
278
291
  function close(): void {
279
- if (!control) return;
280
292
  control.hide();
281
293
  }
282
294
 
@@ -463,30 +475,6 @@ function onKeydown(event: KeyboardEvent): void {
463
475
  }
464
476
  }
465
477
 
466
- onMounted(() => {
467
- if (!listboxRef.value || !inputRef.value) return;
468
- control = mountPopover(listboxRef.value, {
469
- // Anchor the listbox to the input itself so positioning
470
- // tracks the input's box (the input is the visual reference,
471
- // not the wrapper).
472
- anchor: inputRef.value,
473
- placement: props.placement,
474
- offset: props.offset,
475
- // `modal: false` → `popover="auto"` so the browser handles
476
- // outside-click / focus-out / Escape light-dismiss. The
477
- // listbox closes naturally when the user tabs or clicks away.
478
- onOpen: () => {
479
- isOpen.value = true;
480
- emit('open-change', true);
481
- },
482
- onClose: () => {
483
- isOpen.value = false;
484
- activeIndex.value = null;
485
- emit('open-change', false);
486
- },
487
- });
488
- });
489
-
490
478
  // Watch model changes from the consumer and reset typing state so
491
479
  // the input mirrors the new selection's label cleanly.
492
480
  watch(
@@ -496,11 +484,6 @@ watch(
496
484
  query.value = '';
497
485
  },
498
486
  );
499
-
500
- onBeforeUnmount(() => {
501
- control?.teardown();
502
- control = null;
503
- });
504
487
  </script>
505
488
 
506
489
  <style>
@@ -8,7 +8,7 @@
8
8
  * disabled state match TextInput / Textarea exactly.
9
9
  *
10
10
  * The listbox is a separate element (a `<ul popover="auto">`) the
11
- * controller positions via `mountPopover`. CSS resets the
11
+ * composable positions via `usePopover`. CSS resets the
12
12
  * user-agent popover defaults (`margin: 0; border: 0; inset: auto`)
13
13
  * and supplies the surface look — the Popover API places the
14
14
  * element in the top layer; inline `left`/`top` from the controller
@@ -131,8 +131,7 @@ function handleSubmit(): void {
131
131
 
132
132
  // Validation passed. Clear any lingering client-side errors from a
133
133
  // prior failed submit so child form-primitives don't keep displaying
134
- // them while the consumer's async submit runs (mirrors the Astro
135
- // controller's `clearAllErrors(el)` between client-pass and onSubmit).
134
+ // them while the consumer's async submit runs.
136
135
  clientFieldErrors.value = {};
137
136
 
138
137
  // Surface FormData to the consumer's submit handler; consumer manages
@@ -5,7 +5,7 @@
5
5
  * use cases where the form-primitive components' built-in label is not
6
6
  * suitable (e.g., labels associated with custom groups or with multiple
7
7
  * controls). Importing this file re-imports the scaffold so a
8
- * standalone Label dragged into an Astro page that does not also use
8
+ * standalone Label on a page that does not also use
9
9
  * TextInput / Textarea / Select still gets the typography rules.
10
10
  */
11
11
  @import '../text-input/text-input.css';
@@ -143,7 +143,7 @@ function handleStep(direction: 'decrement' | 'increment'): void {
143
143
  input.stepDown();
144
144
  }
145
145
  // Dispatch native input/change events so consumer-attached DOM
146
- // listeners observe the same behavior as the Astro controller path.
146
+ // listeners observe stepper clicks the same as typed edits.
147
147
  input.dispatchEvent(new Event('input', { bubbles: true }));
148
148
  input.dispatchEvent(new Event('change', { bubbles: true }));
149
149
  emit('update:modelValue', input.valueAsNumber);
@@ -90,7 +90,7 @@
90
90
  /* Lift the focused button above its neighbours so the outline
91
91
  * isn't clipped by the adjacent button's edge. */
92
92
  position: relative;
93
- z-index: 1;
93
+ z-index: var(--layer-1);
94
94
  }
95
95
 
96
96
  .pui-number-field__step:disabled {
@@ -8,11 +8,8 @@
8
8
  :aria-describedby="describedBy"
9
9
  :aria-invalid="hasErrors ? 'true' : undefined"
10
10
  >
11
- <!-- aria-invalid lives on the <fieldset> (group-level signal) AND, when the
12
- Astro mountForm controller is wired, on the first radio. The redundancy
13
- is intentional — see ./AUDIT.md "ARIA
14
- expectations" for the rationale. Do NOT remove either binding without
15
- coordinating with the form controller. -->
11
+ <!-- aria-invalid lives on the <fieldset>: a group-level signal see
12
+ ./AUDIT.md "ARIA expectations" for the rationale. -->
16
13
  <legend class="pui-field__label">{{ label }}</legend>
17
14
  <p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
18
15
  <div class="pui-radio-group" :data-orientation="orientation">
@@ -5,9 +5,8 @@
5
5
  * horizontal track that hosts a faint background rail, a coloured
6
6
  * fill segment representing the current value (or range), and one
7
7
  * or two `<button role="slider">` thumbs. Each thumb is absolutely
8
- * positioned via `inset-inline-start: <pct>%`; the controller
9
- * (Astro) and Vue's reactive computed both write the same
10
- * percentage so the visual is identical across both tiers.
8
+ * positioned via `inset-inline-start: <pct>%`, written by Vue's
9
+ * reactive computed.
11
10
  *
12
11
  * Layout rationale: rejected the native `<input type="range">`
13
12
  * baseline for two reasons — (a) cross-browser thumb / track
@@ -6,8 +6,7 @@
6
6
  * row that hosts existing tag pills followed by a text input where
7
7
  * the user types the next tag. The whole row reads as one
8
8
  * integrated control: clicking anywhere lands focus on the entry
9
- * input (handled by Vue / via the click handler in
10
- * `mountTagsInput`).
9
+ * input (a click handler in `TagsInput.vue`).
11
10
  *
12
11
  * Layout rationale: rejected the "fixed-height pills + horizontally-
13
12
  * scrolling row" alternative because vertical wrapping reads better
@@ -13,7 +13,7 @@
13
13
 
14
14
  /* Auto-resize variant — opt-in via the `autoResize` prop, which sets
15
15
  * `style="field-sizing: content"` inline on the <textarea>. The
16
- * inline style is the only documented Vue/Astro path, so the
16
+ * inline style is the only documented path, so the
17
17
  * selector matches it directly. Browsers without `field-sizing`
18
18
  * support fall back gracefully to the default fixed-rows/min-height
19
19
  * box. */
@@ -5,8 +5,8 @@
5
5
  </template>
6
6
 
7
7
  <script setup lang="ts">
8
- import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
9
- import { mountDisclosure } from '../../../utils/controllers/disclosure.js';
8
+ import { ref } from 'vue';
9
+ import { useDisclosure } from '../../../composables/useDisclosure.js';
10
10
 
11
11
  const props = withDefaults(
12
12
  defineProps<{
@@ -25,32 +25,11 @@ const props = withDefaults(
25
25
  );
26
26
 
27
27
  const rootRef = ref<HTMLDivElement | null>(null);
28
- let teardown: (() => void) | null = null;
29
28
 
30
- function mount(): void {
31
- if (!rootRef.value) return;
32
- teardown = mountDisclosure(rootRef.value, {
33
- singleOpen: props.singleOpen,
34
- });
35
- }
36
-
37
- onMounted(mount);
38
-
39
- // `singleOpen` is read once at mount time. If a consumer toggles it
40
- // at runtime we re-mount so the new policy takes effect immediately.
41
- // This is rare (the prop is typically static per surface) but cheap.
42
- watch(
43
- () => props.singleOpen,
44
- () => {
45
- teardown?.();
46
- teardown = null;
47
- mount();
48
- },
49
- );
50
-
51
- onBeforeUnmount(() => {
52
- teardown?.();
53
- teardown = null;
29
+ // `singleOpen` is read per toggle, so a runtime change takes effect on
30
+ // the next interaction without re-wiring.
31
+ useDisclosure(rootRef, {
32
+ singleOpen: () => props.singleOpen,
54
33
  });
55
34
  </script>
56
35
 
@@ -17,8 +17,8 @@
17
17
  </template>
18
18
 
19
19
  <script setup lang="ts">
20
- import { onBeforeUnmount, onMounted, ref } from 'vue';
21
- import { mountDisclosure } from '../../../utils/controllers/disclosure.js';
20
+ import { ref } from 'vue';
21
+ import { useDisclosure } from '../../../composables/useDisclosure.js';
22
22
  import Icon from '../../display/icon/Icon.vue';
23
23
 
24
24
  withDefaults(
@@ -51,24 +51,14 @@ const emit = defineEmits<{
51
51
  }>();
52
52
 
53
53
  const rootRef = ref<HTMLDetailsElement | null>(null);
54
- let teardown: (() => void) | null = null;
55
54
 
56
- onMounted(() => {
57
- if (!rootRef.value) return;
58
- // Collapsible is single-disclosure: no `singleOpen` coordination
59
- // needed. The controller's only role here is to provide the
60
- // standard mount/teardown lifecycle and a normalized callback —
61
- // the native `<details>` element does the open/close work itself.
62
- teardown = mountDisclosure(rootRef.value, {
63
- onToggle: (_target, open) => {
64
- emit('update:open', open);
65
- },
66
- });
67
- });
68
-
69
- onBeforeUnmount(() => {
70
- teardown?.();
71
- teardown = null;
55
+ // Collapsible is single-disclosure: no `singleOpen` coordination
56
+ // needed. The composable's only role here is a normalized callback —
57
+ // the native `<details>` element does the open/close work itself.
58
+ useDisclosure(rootRef, {
59
+ onToggle: (_target, open) => {
60
+ emit('update:open', open);
61
+ },
72
62
  });
73
63
  </script>
74
64
 
@@ -93,8 +93,8 @@
93
93
 
94
94
  /* ===== sort affordance ===== */
95
95
 
96
- /* One rule for both tiers — a <button> in Vue, an <a> in Astro — so the resets below
97
- deliberately cover both elements. */
96
+ /* Rendered as a <button>; the resets below also cover <a> so a
97
+ consumer-supplied link header behaves identically. */
98
98
  .pui-table__sort {
99
99
  display: inline-flex;
100
100
  align-items: center;
@@ -167,7 +167,7 @@
167
167
  .pui-table__loading {
168
168
  position: absolute;
169
169
  inset: 0;
170
- z-index: 1;
170
+ z-index: var(--layer-1);
171
171
 
172
172
  /* the tint gives the blur a surface to sit in; without it the frosting
173
173
  reads as a smudge rather than a pane */
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <nav aria-label="Pagination">
2
+ <nav class="pui-pagination-nav" aria-label="Pagination">
3
3
  <ul class="pui-pagination">
4
4
  <li>
5
5
  <button
@@ -0,0 +1,23 @@
1
+ <template>
2
+ <div class="pui-pagination-footer">
3
+ <div v-if="$slots.summary" class="pui-pagination-footer__summary">
4
+ <slot name="summary" />
5
+ </div>
6
+ <div class="pui-pagination-footer__control">
7
+ <slot />
8
+ </div>
9
+ </div>
10
+ </template>
11
+
12
+ <script setup lang="ts">
13
+ // Pure markup with slots — no props, no state, no ARIA of its own. The
14
+ // landmark lives on the `<nav>` that `Pagination` renders inside the default
15
+ // slot; a second landmark here would be noise.
16
+ //
17
+ // A plain `<div>`, not `<footer>`: dropped at body level a `<footer>` becomes
18
+ // the `contentinfo` landmark, which this is not.
19
+ </script>
20
+
21
+ <style>
22
+ @import './pagination.css';
23
+ </style>
@@ -1,53 +1,152 @@
1
+ /* `.pui-pagination` is the bare control, `.pui-pagination-footer` the bar that
2
+ seats it opposite a result summary — one visual family, one file. Rationale
3
+ for the less obvious rules lives in AUDIT.md. */
1
4
  @layer components {
5
+ /* ===== bare control ===== */
6
+
2
7
  .pui-pagination {
8
+ /* The STRIP owns the control height, not the cells: `box-sizing` is
9
+ border-box, so this matches a `sm` Button's 30px outer box instead of
10
+ standing 2px taller once the strip's own border is added. Cells then
11
+ stretch to fill whatever is left inside it.
12
+ No `overflow: hidden` to clip the cell corners — it clips the focus
13
+ ring too, so the end cells carry the radius instead. */
3
14
  display: inline-flex;
4
- align-items: center;
5
- gap: var(--space-3xs);
6
- list-style: none;
7
- padding: 0;
15
+ align-items: stretch;
16
+ height: var(--control-height-sm);
8
17
  margin: 0;
18
+ padding: 0;
19
+ list-style: none;
20
+ border: var(--stroke-sm) solid var(--border-clr-base);
21
+ border-radius: var(--radius-sm);
22
+ background: var(--bg-clr-surface);
23
+
24
+ & > li {
25
+ display: flex;
26
+ }
27
+
28
+ & > li + li :is(.pui-pagination__item, .pui-pagination__ellipsis) {
29
+ border-inline-start: var(--stroke-sm) solid var(--border-clr-base);
30
+ }
31
+
32
+ /* First and last are always the prev / next buttons. */
33
+ & > li:first-child .pui-pagination__item {
34
+ border-start-start-radius: var(--radius-sm);
35
+ border-end-start-radius: var(--radius-sm);
36
+ }
37
+
38
+ & > li:last-child .pui-pagination__item {
39
+ border-start-end-radius: var(--radius-sm);
40
+ border-end-end-radius: var(--radius-sm);
41
+ }
9
42
  }
10
43
 
11
44
  .pui-pagination__item {
45
+ /* box → typography → colour → transition */
46
+ display: inline-grid;
47
+ place-items: center;
48
+ min-width: var(--control-height-sm);
49
+ height: 100%;
50
+ padding-inline: var(--space-2xs);
12
51
  appearance: none;
13
- border: var(--stroke-sm) solid var(--border-clr-base);
14
- background: var(--bg-clr-surface);
52
+ border: 0;
53
+ background: transparent;
15
54
  font: inherit;
16
55
  font-size: var(--step--1);
17
- font-weight: var(--fw-semibold);
18
- min-width: 2.2rem;
19
- height: 2.2rem;
20
- display: inline-grid;
21
- place-items: center;
22
- cursor: pointer;
23
- border-radius: var(--radius-sm);
24
- text-decoration: none;
56
+ font-weight: var(--fw-control);
57
+ font-variant-numeric: tabular-nums;
25
58
  color: var(--text-clr-base);
59
+ text-decoration: none;
60
+ cursor: pointer;
61
+ transition: background-color var(--duration-base) var(--ease-base);
62
+
63
+ &:hover:not(:disabled, [aria-disabled='true']) {
64
+ background: var(--bg-clr-surface-2);
65
+ }
66
+
67
+ &:focus-visible {
68
+ /* Inset and lifted, or the neighbouring cell overdraws the ring. */
69
+ position: relative;
70
+ z-index: 1;
71
+ outline: var(--outline-width) solid var(--outline-clr-base);
72
+ outline-offset: calc(var(--outline-offset) * -1);
73
+ }
74
+
75
+ /* Never `--text-clr-inverse` here: it is near-white in both themes and
76
+ only pairs with a fill or an inverted bar. See AUDIT.md. */
77
+ &[aria-current='page'] {
78
+ background: var(--bg-clr-selected);
79
+ color: var(--text-clr-base);
80
+ }
81
+
82
+ &:disabled,
83
+ &[aria-disabled='true'] {
84
+ opacity: var(--opacity-disabled);
85
+ cursor: not-allowed;
86
+ }
26
87
  }
27
88
 
28
- .pui-pagination__item:hover {
29
- background: var(--bg-clr-surface-2);
89
+ .pui-pagination__ellipsis {
90
+ display: inline-grid;
91
+ place-items: center;
92
+ min-width: var(--control-height-sm);
93
+ height: 100%;
94
+ font-size: var(--step--1);
95
+ color: var(--text-clr-muted);
30
96
  }
31
97
 
32
- .pui-pagination__item:focus-visible {
33
- outline: 3px solid var(--outline-clr-base);
34
- outline-offset: 2px;
98
+ /* ===== narrow reduction ===== */
99
+
100
+ /* The `<nav>` is the query container, not the `<ul>` — the `<ul>` is
101
+ inline-flex, so `inline-size` containment would collapse it. */
102
+ .pui-pagination-nav {
103
+ container-type: inline-size;
35
104
  }
36
105
 
37
- .pui-pagination__item[aria-current='page'] {
38
- background: var(--bg-clr-dark);
39
- color: var(--text-clr-inverse);
40
- border-color: var(--border-clr-strong);
106
+ /* Keep the arrows and the current page, drop the rest of the run. */
107
+ @container (max-width: 18rem) {
108
+ .pui-pagination
109
+ > li:not(:first-child, :last-child, :has(> [aria-current='page'])) {
110
+ display: none;
111
+ }
41
112
  }
42
113
 
43
- .pui-pagination__item:disabled,
44
- .pui-pagination__item[aria-disabled='true'] {
45
- opacity: 0.4;
46
- cursor: not-allowed;
114
+ /* ===== footer bar ===== */
115
+
116
+ .pui-pagination-footer {
117
+ container-type: inline-size;
118
+ display: flex;
119
+ flex-wrap: wrap;
120
+ align-items: center;
121
+ gap: var(--space-xs) var(--space-s);
122
+ padding: var(--space-xs) var(--space-s);
123
+ border-block-start: var(--stroke-sm) solid var(--border-clr-base);
124
+ background: var(--bg-clr-surface);
47
125
  }
48
126
 
49
- .pui-pagination__ellipsis {
127
+ .pui-pagination-footer__summary {
128
+ font-size: var(--step--1);
50
129
  color: var(--text-clr-muted);
51
- padding-inline: var(--space-2xs);
130
+ font-variant-numeric: tabular-nums;
131
+ }
132
+
133
+ .pui-pagination-footer__control {
134
+ /* Block, so the `<nav>` inside inherits a width to query; `flex: 1`
135
+ holds it at the trailing edge with or without a summary. */
136
+ flex: 1;
137
+ text-align: end;
138
+ }
139
+
140
+ @container (max-width: 30rem) {
141
+ .pui-pagination-footer__summary,
142
+ .pui-pagination-footer__control {
143
+ flex: 1 0 100%;
144
+ text-align: center;
145
+ }
146
+
147
+ /* Stacked, the summary reads as a caption under the control. */
148
+ .pui-pagination-footer__summary {
149
+ order: 1;
150
+ }
52
151
  }
53
152
  }