@spaethtech/svelte-ui 0.15.1-dev.67.fbf3875 → 0.15.1-dev.71.e357f8b

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.
@@ -152,8 +152,9 @@ Mount `<Toaster />` once at the app root, then push from anywhere with `toast.su
152
152
  All from `@spaethtech/svelte-ui` (see the shipped `docs/components.md` + `docs/usage.md` for props +
153
153
  examples):
154
154
 
155
- - **Form:** `Button` `ButtonDropdown` `Input` `Select` `Combobox` `Slider` `List` `TextArea` `Checkbox`
156
- `Toggle` `Radio` `Rating` · **`Combobox`** (free-text autocomplete arbitrary value + suggestions,
155
+ - **Form:** `Button` `ButtonGroup` `Input` `Select` `Combobox` `Slider` `List` `TextArea` `Checkbox`
156
+ `Toggle` `Radio` `Rating` (display `average`/`count`, OR `interactive` + `bind:value` star picker
157
+ with `allowHalf`, click/hover/arrow-keys) · **`Combobox`** (free-text autocomplete — arbitrary value + suggestions,
157
158
  client `options` or async `suggest`; vs `Select` which is a closed choice. Composes Input + Popup.) ·
158
159
  **`TokenInput`** (multi-value entry — Enter/comma adds a removable `Chip` token, Backspace-on-empty
159
160
  removes; `bind:values`, `max`, `allowDuplicates`; FieldChrome.) · **`FileUpload`** (drag-drop + click
@@ -162,21 +163,25 @@ examples):
162
163
  `showValue`/`format`; FieldChrome + `variant`/`size`; keyboard + `role="slider"`) · **`FieldGroup`**
163
164
  (fieldset wrapper for radio/checkbox/toggle sets) · **`ButtonGroup`**
164
165
  (joined row/`col` of `<Button>`s as one unit — an action toolbar or a **controlled** segmented
165
- selector; `items` of `ButtonGroupItem` {`value`,`text`,`icon`,`variant`,`disabled`,`menu`,`onclick`}
166
- or a presentational default slot; `select` `'none'|'single'|'multi'` with `bind:value`/`bind:values`;
167
- selected = variant-swap to filled, unselected = `ghost`; the group owns the frame; an item `menu`
168
- (`MenuItem[]`) makes a split-button. Not a form field — for legend/`name`/submit use `FieldGroup`;
169
- for view switching use `TabStrip`.)
166
+ selector; `items` of `ButtonGroupItem` {`value`,`text`,`icon`,`variant`,`disabled`,`loading`,`href`/
167
+ `target`,`menu`,`menuOnly`,`onclick`} or a presentational default slot; `select`
168
+ `'none'|'single'|'multi'` with `bind:value`/`bind:values`; selected = variant-swap to filled,
169
+ unselected = `ghost`; the group owns the frame. An item `menu` (`MenuItem[]`) makes a **split
170
+ button** (action + caret); add `menuOnly` for a caret-only **menu-trigger** cell (e.g. an
171
+ `[action][▾]` pair = a plain item + a `menuOnly` item). This replaces the old `ButtonDropdown`. Not
172
+ a form field — for legend/`name`/submit use `FieldGroup`; for view switching use `TabStrip`.)
170
173
  - **Specialized inputs:** `PasswordInput` `EmailInput` `SearchInput` `NumberInput` (formatting +
171
174
  `percent`/`stepper`/`clamp`/`liveFormat`) `PhoneInput` (stores E.164; dep-free, inject
172
175
  `parse`/`format` for per-country)
173
- - **Date / time:** `DatePicker` `Calendar` `TimePicker` `TimeSpinner` `TimeRangeInput` `DateTimeInput`
174
- · **`DateRangePicker`** (start–end range + presets rail; `bind:start`/`bind:end` ISO; composes
175
- `Calendar mode="range"` + `Input` + `Popup`. `DatePicker range` is the bare, preset-less variant.)
176
+ - **Date / time:** `DatePicker` `Calendar` `TimePicker` `TimeSpinner` `TimeRangeInput` `DateTimeInput`.
177
+ For a start–end range use **`DatePicker range`** (`bind:value` = `{ start, end }`) it composes
178
+ `Calendar mode="range"`.
176
179
  - **Data:** `DataTable` `Query` — driven by the headless layer at **`@spaethtech/svelte-ui/data`**
177
180
  (query-language parser/AST, `createGrid`, `DataGrid<T>`, `DataSet`). · **`Pagination`** (standalone
178
- pager: numbered buttons + ellipsis, prev/next/first/last, `bind:page`/`bind:perPage`, `total`,
179
- `perPageOptions`, `showTotal`; built from `Button`+`Select` the reusable DataTable footer pager).
181
+ pager styled like the DataTable footer: tinted band, per-page `Select`, range total, and a
182
+ `First·Prev·[page]/N·Next·Last` stepper whose indicator is an editable `NumberInput` quick-jump;
183
+ `bind:page`/`bind:perPage`, `total`, `perPageOptions`, `showEdges`/`showTotal`, `borderless`;
184
+ `variant` tints band+hover only; built from `Button`+`Select`+`NumberInput`).
180
185
  - **Overlays:** `Dialog` (pure modal shell — BYO content; `width`/`height` take any CSS value;
181
186
  `onShow(ctx)`/`onHide(ctx)` control focus — set `ctx.autoFocus` to a selector/element/`false`,
182
187
  `ctx.restoreFocus` likewise) · `ConfirmDialog` (title + message + Confirm/Cancel, built on Dialog)
@@ -28,10 +28,20 @@
28
28
  /** Icon slot — a consumer-rendered component (e.g. `~icons/mdi/*`), like `Button`'s `icon`. */
29
29
  icon?: Snippet;
30
30
  disabled?: boolean;
31
+ /** Show a spinner in place of the icon (forwarded to `Button`). */
32
+ loading?: boolean;
31
33
  /** Per-item colour override; otherwise the group `variant`. */
32
34
  variant?: Variant;
33
- /** Split-button dropdown: a trailing caret opens a `Menu` with these items. */
35
+ /** Anchor mode the item renders as a link (forwarded to `Button`). */
36
+ href?: string;
37
+ target?: string;
38
+ /** A `Menu` opened from this item. By default the item becomes a **split button** (its own action
39
+ * + a trailing caret that opens the menu). With `menuOnly`, the whole item is the menu trigger
40
+ * (no split, no caret) — the click opens the menu. Use a `menuOnly` item as the chevron half of a
41
+ * hand-built [action][▾] pair. */
34
42
  menu?: MenuItem[];
43
+ /** Make `menu` open on the item's own click, with no split caret (menu-trigger mode). */
44
+ menuOnly?: boolean;
35
45
  /** Action-mode click. Fires in `select="none"`; in selection modes it runs after the select. */
36
46
  onclick?: (e: MouseEvent) => void;
37
47
  };
@@ -179,7 +189,22 @@
179
189
  <div role={groupRole} class={frameClass} onkeydown={onKeydown}>
180
190
  {#if items}
181
191
  {#each items as item, i (item.value ?? item.text ?? i)}
182
- {#if item.menu}
192
+ {#if item.menu && item.menuOnly}
193
+ <!-- Menu-trigger cell: the whole button opens the Menu on click (no split, no extra caret).
194
+ A bare item (no icon/text) defaults to a chevron so an [action][▾] pair needs no boilerplate. -->
195
+ <Button
196
+ icon={item.icon ?? (item.text ? undefined : caretIcon)}
197
+ text={item.text}
198
+ variant={variantFor(item)}
199
+ {size}
200
+ disabled={disabled || item.disabled}
201
+ loading={item.loading}
202
+ aria-haspopup="menu"
203
+ aria-expanded={!!menuOpen[i]}
204
+ bind:element={caretRefs[i]}
205
+ onclick={() => (menuOpen[i] = !menuOpen[i])}
206
+ />
207
+ {:else if item.menu}
183
208
  <!-- Split button: main (action/select) + caret (opens the Menu). One cell in the group. -->
184
209
  <span class="inline-flex {isRow ? '[&>button+button]:border-l' : '[&>button+button]:border-t'} [&>button+button]:[border-color:var(--ui-border-color)]">
185
210
  <Button
@@ -188,6 +213,7 @@
188
213
  variant={variantFor(item)}
189
214
  {size}
190
215
  disabled={disabled || item.disabled}
216
+ loading={item.loading}
191
217
  bind:element={mainRefs[i]}
192
218
  tabindex={tabIdxFor(i, item)}
193
219
  {...selectAttrs(item)}
@@ -206,6 +232,22 @@
206
232
  {#snippet icon()}<IconChevronDown />{/snippet}
207
233
  </Button>
208
234
  </span>
235
+ {:else if item.href && !disabled && !item.disabled}
236
+ <!-- Anchor-mode item (renders as a link). A disabled link falls through to the plain button
237
+ branch below — an <a> has no native `disabled`. -->
238
+ <Button
239
+ icon={item.icon}
240
+ text={item.text}
241
+ variant={variantFor(item)}
242
+ {size}
243
+ loading={item.loading}
244
+ href={item.href}
245
+ target={item.target}
246
+ bind:element={mainRefs[i]}
247
+ tabindex={tabIdxFor(i, item)}
248
+ {...selectAttrs(item)}
249
+ onclick={(e) => activate(item, e)}
250
+ />
209
251
  {:else}
210
252
  <Button
211
253
  icon={item.icon}
@@ -213,6 +255,7 @@
213
255
  variant={variantFor(item)}
214
256
  {size}
215
257
  disabled={disabled || item.disabled}
258
+ loading={item.loading}
216
259
  bind:element={mainRefs[i]}
217
260
  tabindex={tabIdxFor(i, item)}
218
261
  {...selectAttrs(item)}
@@ -234,3 +277,5 @@
234
277
  {/if}
235
278
  {/each}
236
279
  {/if}
280
+
281
+ {#snippet caretIcon()}<IconChevronDown />{/snippet}
@@ -10,10 +10,20 @@ export type ButtonGroupItem = {
10
10
  /** Icon slot — a consumer-rendered component (e.g. `~icons/mdi/*`), like `Button`'s `icon`. */
11
11
  icon?: Snippet;
12
12
  disabled?: boolean;
13
+ /** Show a spinner in place of the icon (forwarded to `Button`). */
14
+ loading?: boolean;
13
15
  /** Per-item colour override; otherwise the group `variant`. */
14
16
  variant?: Variant;
15
- /** Split-button dropdown: a trailing caret opens a `Menu` with these items. */
17
+ /** Anchor mode the item renders as a link (forwarded to `Button`). */
18
+ href?: string;
19
+ target?: string;
20
+ /** A `Menu` opened from this item. By default the item becomes a **split button** (its own action
21
+ * + a trailing caret that opens the menu). With `menuOnly`, the whole item is the menu trigger
22
+ * (no split, no caret) — the click opens the menu. Use a `menuOnly` item as the chevron half of a
23
+ * hand-built [action][▾] pair. */
16
24
  menu?: MenuItem[];
25
+ /** Make `menu` open on the item's own click, with no split caret (menu-trigger mode). */
26
+ menuOnly?: boolean;
17
27
  /** Action-mode click. Fires in `select="none"`; in selection modes it runs after the select. */
18
28
  onclick?: (e: MouseEvent) => void;
19
29
  };
@@ -10,9 +10,10 @@
10
10
  import Input from "../Input.svelte";
11
11
  import Popup from "../Popup.svelte";
12
12
  import IconChevronDown from "~icons/mdi/chevron-down";
13
+ import Spinner from "../Spinner/Spinner.svelte";
13
14
  import type { Variant } from "../../types/variants.js";
14
15
  import type { Size } from "../../types/sizes.js";
15
- import type { Responsive } from "../../types/responsive.js";
16
+ import { responsiveClasses, type Responsive } from "../../types/responsive.js";
16
17
 
17
18
  let {
18
19
  value = $bindable(""),
@@ -65,6 +66,10 @@
65
66
  let fieldEl = $state<HTMLDivElement>();
66
67
  let inputEl = $state<HTMLInputElement>();
67
68
  let asyncResults = $state<string[]>([]);
69
+ let loading = $state(false);
70
+ // Monotonic request id — a later query invalidates the results of any earlier in-flight one, so a
71
+ // slow response can't clobber a newer, faster one (out-of-order race).
72
+ let reqSeq = 0;
68
73
 
69
74
  // Suggestions: async `suggest` (debounced) or client-side substring filter of `options`.
70
75
  const filtered = $derived.by(() => {
@@ -78,23 +83,37 @@
78
83
  return maxResults != null ? out.slice(0, maxResults) : out;
79
84
  });
80
85
 
81
- // Debounced async fetch — re-runs as `value` changes; gated by `minChars`.
86
+ // Debounced async fetch — re-runs as `value` changes; gated by `minChars`. Sets `loading` for the
87
+ // whole debounce→resolve window so the field shows feedback (otherwise the delay reads as "broken").
82
88
  $effect(() => {
83
89
  if (!suggest) return;
84
90
  const q = value;
85
91
  if (q.trim().length < minChars) {
86
92
  asyncResults = [];
93
+ loading = false;
87
94
  return;
88
95
  }
96
+ const seq = ++reqSeq;
97
+ loading = true;
89
98
  const t = setTimeout(() => {
90
99
  suggest(q)
91
- .then((r) => (asyncResults = r))
92
- .catch(() => (asyncResults = []));
100
+ .then((r) => {
101
+ if (seq !== reqSeq) return; // a newer query superseded this one
102
+ asyncResults = r;
103
+ loading = false;
104
+ })
105
+ .catch(() => {
106
+ if (seq !== reqSeq) return;
107
+ asyncResults = [];
108
+ loading = false;
109
+ });
93
110
  }, debounceMs);
94
111
  return () => clearTimeout(t);
95
112
  });
96
113
 
97
- const canOpen = $derived(open && !disabled && filtered.length > 0);
114
+ // Open the popup while loading too, so the "Loading…" row can show during the async fetch.
115
+ const canOpen = $derived(open && !disabled && (filtered.length > 0 || loading));
116
+ const optionText = $derived(responsiveClasses(size, { sm: "text-xs", md: "text-sm", lg: "text-base" }));
98
117
 
99
118
  function choose(v: string) {
100
119
  value = v;
@@ -166,14 +185,20 @@
166
185
  }}
167
186
  >
168
187
  {#snippet actions()}
169
- <span
170
- class="inline-flex items-center justify-center opacity-60 transition-transform duration-150 {canOpen
171
- ? 'rotate-180'
172
- : ''} [&_svg]:w-4 [&_svg]:h-4"
173
- aria-hidden="true"
174
- >
175
- <IconChevronDown />
176
- </span>
188
+ {#if loading}
189
+ <span class="inline-flex items-center justify-center opacity-70" aria-hidden="true">
190
+ <Spinner size="sm" />
191
+ </span>
192
+ {:else}
193
+ <span
194
+ class="inline-flex items-center justify-center opacity-60 transition-transform duration-150 {canOpen
195
+ ? 'rotate-180'
196
+ : ''} [&_svg]:w-4 [&_svg]:h-4"
197
+ aria-hidden="true"
198
+ >
199
+ <IconChevronDown />
200
+ </span>
201
+ {/if}
177
202
  {/snippet}
178
203
  </Input>
179
204
 
@@ -181,25 +206,31 @@
181
206
  <ul
182
207
  id={listId}
183
208
  role="listbox"
184
- class="max-h-64 overflow-y-auto rounded-md border shadow-lg [background-color:var(--ui-color-background)] [color:var(--ui-color-text)] [border-color:var(--ui-border-color)] py-1"
209
+ class="max-h-64 overflow-y-auto rounded-md border shadow-lg [background-color:var(--ui-color-background)] [color:var(--ui-color-text)] [border-color:var(--ui-border-color)] py-1 {optionText}"
185
210
  >
186
- {#each filtered as opt, i (opt + i)}
187
- <!-- svelte-ignore a11y_click_events_have_key_events -->
188
- <li
189
- id={optId(i)}
190
- role="option"
191
- aria-selected={i === highlight}
192
- class="cursor-pointer px-3 py-1.5 text-sm {i === highlight
193
- ? '[background-color:var(--ui-color-hover)]'
194
- : ''} hover:[background-color:var(--ui-color-hover)]"
195
- onpointerdown={(e) => {
196
- e.preventDefault(); // keep input focused so the click lands before blur closes the list
197
- choose(opt);
198
- }}
199
- onpointerenter={() => (highlight = i)}
200
- >
201
- {opt}
211
+ {#if filtered.length === 0 && loading}
212
+ <li class="flex items-center gap-2 px-3 py-1.5 [color:color-mix(in_srgb,var(--ui-color-text)_60%,transparent)]" aria-hidden="true">
213
+ <Spinner size="sm" /> Loading…
202
214
  </li>
203
- {/each}
215
+ {:else}
216
+ {#each filtered as opt, i (opt + i)}
217
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
218
+ <li
219
+ id={optId(i)}
220
+ role="option"
221
+ aria-selected={i === highlight}
222
+ class="cursor-pointer px-3 py-1.5 {i === highlight
223
+ ? '[background-color:var(--ui-color-hover)]'
224
+ : ''} hover:[background-color:var(--ui-color-hover)]"
225
+ onpointerdown={(e) => {
226
+ e.preventDefault(); // keep input focused so the click lands before blur closes the list
227
+ choose(opt);
228
+ }}
229
+ onpointerenter={() => (highlight = i)}
230
+ >
231
+ {opt}
232
+ </li>
233
+ {/each}
234
+ {/if}
204
235
  </ul>
205
236
  </Popup>
@@ -1,7 +1,7 @@
1
1
  import type { Snippet } from "svelte";
2
2
  import type { Variant } from "../../types/variants.js";
3
3
  import type { Size } from "../../types/sizes.js";
4
- import type { Responsive } from "../../types/responsive.js";
4
+ import { type Responsive } from "../../types/responsive.js";
5
5
  type $$ComponentProps = {
6
6
  value?: string;
7
7
  options?: string[];
@@ -10,7 +10,7 @@
10
10
  <script lang="ts">
11
11
  import Popup from "./Popup.svelte";
12
12
  import type { Side, Align, Boundary } from "../positioning/anchored.js";
13
- import type { MenuItem } from "../data/table/types.js";
13
+ import type { MenuItem, MenuAction, MenuSeparator } from "../data/table/types.js";
14
14
  import type { Size } from "../types/sizes.js";
15
15
  import { responsiveClasses, type Responsive } from "../types/responsive.js";
16
16
  import { variantToken, type Variant } from "../types/variants.js";
@@ -38,8 +38,12 @@
38
38
  } = $props();
39
39
 
40
40
  let active = $state(-1);
41
- const hasIcons = $derived(items.some((i) => i.icon));
42
- const enabledIdx = $derived(items.map((i, idx) => (i.disabled ? -1 : idx)).filter((i) => i >= 0));
41
+ const isSeparator = (i: MenuItem): i is MenuSeparator => "divider" in i;
42
+ const hasIcons = $derived(items.some((i) => !isSeparator(i) && i.icon));
43
+ // Separators (and disabled items) are never focusable / selectable.
44
+ const enabledIdx = $derived(
45
+ items.map((i, idx) => (isSeparator(i) || i.disabled ? -1 : idx)).filter((i) => i >= 0),
46
+ );
43
47
 
44
48
  // Size axis → item padding / text / icon box. Variant → --ui-accent (re-mixes the item's hover tint).
45
49
  const padMap: Record<Size, string> = { sm: "px-1.5 py-1", md: "px-2 py-1.5", lg: "px-2.5 py-2" };
@@ -50,7 +54,7 @@
50
54
  const iconClass = $derived(responsiveClasses(size, iconMap));
51
55
  const accentVar = $derived(variant ? `--ui-accent: var(${variantToken[variant]});` : "");
52
56
 
53
- function choose(item: MenuItem) {
57
+ function choose(item: MenuAction) {
54
58
  if (item.disabled) return;
55
59
  item.onclick?.();
56
60
  if (item.href) window.location.href = item.href;
@@ -70,7 +74,8 @@
70
74
  enabledIdx[enabledIdx.length - 1];
71
75
  } else if (e.key === "Enter" && active >= 0) {
72
76
  e.preventDefault();
73
- choose(items[active]);
77
+ const it = items[active];
78
+ if (!isSeparator(it)) choose(it);
74
79
  }
75
80
  }
76
81
 
@@ -88,30 +93,33 @@
88
93
  class="ui-accent min-w-48 rounded-md border p-1 shadow-lg outline-none [background-color:var(--ui-color-background)] [border-color:color-mix(in_srgb,var(--ui-color-secondary)_var(--ui-tint-border),transparent)]"
89
94
  style={accentVar}
90
95
  >
91
- {#each items as item, i (item.label)}
92
- {@const danger = item.danger}
93
- <li role="none">
94
- <button
95
- type="button"
96
- role="menuitem"
97
- disabled={item.disabled}
98
- onmouseenter={() => (active = item.disabled ? active : i)}
99
- onclick={() => choose(item)}
100
- class="flex w-full items-center gap-2 rounded {padClass} {textClass} text-left transition-colors
96
+ {#each items as item, i (i)}
97
+ {#if isSeparator(item)}
98
+ <li role="separator" class="my-1 h-px [background-color:color-mix(in_srgb,var(--ui-color-secondary)_var(--ui-tint-border),transparent)]"></li>
99
+ {:else}
100
+ <li role="none">
101
+ <button
102
+ type="button"
103
+ role="menuitem"
104
+ disabled={item.disabled}
105
+ onmouseenter={() => (active = item.disabled ? active : i)}
106
+ onclick={() => choose(item)}
107
+ class="flex w-full items-center gap-2 rounded {padClass} {textClass} text-left transition-colors
101
108
  {item.disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'}
102
109
  {i === active && !item.disabled ? '[background-color:var(--ui-color-surface)]' : ''}
103
- {danger ? '[color:var(--ui-color-error)]' : '[color:var(--ui-color-text)]'}"
104
- >
105
- {#if hasIcons}
106
- <span
107
- class="inline-flex {iconClass} shrink-0 items-center justify-center [&_svg]:w-full [&_svg]:h-full"
108
- >
109
- {#if item.icon}{@const Icon = item.icon}<Icon />{/if}
110
- </span>
111
- {/if}
112
- <span class="truncate">{item.label}</span>
113
- </button>
114
- </li>
110
+ {item.danger ? '[color:var(--ui-color-error)]' : '[color:var(--ui-color-text)]'}"
111
+ >
112
+ {#if hasIcons}
113
+ <span
114
+ class="inline-flex {iconClass} shrink-0 items-center justify-center [&_svg]:w-full [&_svg]:h-full"
115
+ >
116
+ {#if item.icon}{@const Icon = item.icon}<Icon />{/if}
117
+ </span>
118
+ {/if}
119
+ <span class="truncate">{item.label}</span>
120
+ </button>
121
+ </li>
122
+ {/if}
115
123
  {/each}
116
124
  </ul>
117
125
  </Popup>
@@ -1,14 +1,18 @@
1
1
  <!--
2
2
  /**
3
- * Pagination — a standalone page navigator (numbered buttons + ellipsis, prev/next, optional
4
- * first/last, optional rows-per-page Select + total). The reusable extraction of DataTable's footer
5
- * pager; built only from `Button` (ghost, active = filled variant) and `Select`. See Pagination.spec.md.
3
+ * Pagination — a standalone page navigator laid out like DataTable's footer: a tinted, bordered band
4
+ * with an optional per-page `Select` on the left and a compact `First · Prev · [page]/N · Next · Last`
5
+ * stepper on the right. The page indicator is an editable `NumberInput` for quick-jump. `variant` tints
6
+ * the band surface + button hover (via the shared `.ui-accent` override) — it does NOT fill buttons.
7
+ * Built only from `Button`, `Select`, and `NumberInput`. See Pagination.spec.md.
6
8
  */
7
9
  -->
8
10
  <script lang="ts">
9
11
  import Button from "../Button.svelte";
10
12
  import Select from "../Select.svelte";
13
+ import NumberInput from "../NumberInput.svelte";
11
14
  import type { Variant } from "../../types/variants.js";
15
+ import { variantToken } from "../../types/variants.js";
12
16
  import type { Size } from "../../types/sizes.js";
13
17
  import { responsiveClasses, type Responsive } from "../../types/responsive.js";
14
18
  import IconFirst from "~icons/mdi/page-first";
@@ -21,25 +25,29 @@
21
25
  total,
22
26
  perPage = $bindable(10),
23
27
  perPageOptions,
24
- siblingCount = 1,
25
- boundaryCount = 1,
26
- showEdges = false,
27
- showTotal = false,
28
- variant = "primary",
28
+ showEdges = true,
29
+ showTotal = true,
30
+ variant,
29
31
  size = "md",
32
+ borderless = false,
30
33
  disabled = false,
31
34
  class: cls = "",
32
35
  }: {
33
36
  page?: number;
34
37
  total: number;
35
38
  perPage?: number;
39
+ /** When set, renders the per-page `Select` on the left. */
36
40
  perPageOptions?: number[];
37
- siblingCount?: number;
38
- boundaryCount?: number;
41
+ /** Show the First/Last edge buttons (default true, matching DataTable's footer). */
39
42
  showEdges?: boolean;
43
+ /** Show the "start–end of total" range text (default true). */
40
44
  showTotal?: boolean;
45
+ /** Color axis — tints the band surface + button hover (like DataTable), NOT the buttons. Omit for
46
+ * a neutral surface band; `ghost` makes the band transparent. */
41
47
  variant?: Variant;
42
48
  size?: Responsive<Size>;
49
+ /** Drop the band border/surface, leaving just the controls. */
50
+ borderless?: boolean;
43
51
  disabled?: boolean;
44
52
  class?: string;
45
53
  } = $props();
@@ -51,23 +59,10 @@
51
59
  if (clamped !== page) page = clamped;
52
60
  });
53
61
 
54
- // Page-number list with ellipsis gaps: boundaryCount at each end + siblingCount around the current.
55
- const items = $derived.by<(number | "ellipsis")[]>(() => {
56
- const set = new Set<number>();
57
- for (let i = 1; i <= boundaryCount; i++) {
58
- set.add(i);
59
- set.add(pageCount - i + 1);
60
- }
61
- for (let i = page - siblingCount; i <= page + siblingCount; i++) set.add(i);
62
- const sorted = [...set].filter((n) => n >= 1 && n <= pageCount).sort((a, b) => a - b);
63
- const out: (number | "ellipsis")[] = [];
64
- let prev = 0;
65
- for (const n of sorted) {
66
- if (n - prev > 1) out.push("ellipsis");
67
- out.push(n);
68
- prev = n;
69
- }
70
- return out;
62
+ // The quick-jump field mirrors `page`; committed on change (Enter/blur), not on every keystroke.
63
+ let pageInput = $state(page);
64
+ $effect(() => {
65
+ pageInput = page;
71
66
  });
72
67
 
73
68
  const rangeText = $derived(
@@ -78,18 +73,38 @@
78
73
  const subtle = $derived(
79
74
  `${responsiveClasses(size, { sm: "text-xs", md: "text-sm", lg: "text-base" })} [color:color-mix(in_srgb,var(--ui-color-text)_60%,transparent)]`,
80
75
  );
76
+ const bandPad = $derived(responsiveClasses(size, { sm: "px-2 py-1.5", md: "px-3 py-2", lg: "px-3.5 py-2.5" }));
77
+
78
+ // Band surface + border: variant re-points --ui-accent so --ui-color-surface/hover re-mix from it
79
+ // (the shared .ui-accent mechanism DataTable's footer uses). `ghost` ⇒ transparent band.
80
+ const accentVar = $derived(variant ? `--ui-accent: var(${variantToken[variant]});` : "");
81
+ const sectionBorder = "color-mix(in srgb, var(--ui-color-secondary) var(--ui-tint-border), transparent)";
82
+ const bandBg = $derived(variant === "ghost" ? "transparent" : "var(--ui-color-surface)");
83
+ const bandBorder = $derived(borderless ? "transparent" : sectionBorder);
81
84
 
82
85
  const go = (to: number) => {
83
86
  if (disabled) return;
84
87
  page = Math.min(Math.max(to, 1), pageCount);
88
+ pageInput = page;
85
89
  };
86
90
  const setPerPage = (n: number) => {
87
91
  perPage = n;
88
92
  page = 1;
89
93
  };
94
+ const commitJump = () => {
95
+ if (pageInput == null) {
96
+ pageInput = page;
97
+ return;
98
+ }
99
+ go(pageInput);
100
+ };
90
101
  </script>
91
102
 
92
- <nav aria-label="Pagination" class="flex flex-wrap items-center justify-between gap-3 {cls}">
103
+ <nav
104
+ aria-label="Pagination"
105
+ class="ui-accent flex flex-wrap items-center justify-between gap-3 border rounded-[var(--ui-border-radius)] {bandPad} {cls}"
106
+ style="border-color: {bandBorder}; background-color: {bandBg}; {accentVar}"
107
+ >
93
108
  {#if perPageOptions?.length}
94
109
  <div class="flex items-center gap-2">
95
110
  <Select
@@ -104,7 +119,7 @@
104
119
  </div>
105
120
  {/if}
106
121
 
107
- <div class="flex items-center gap-3">
122
+ <div class="flex items-center gap-3 {perPageOptions?.length ? '' : 'ml-auto'}">
108
123
  {#if showTotal}<span class={subtle}>{rangeText}</span>{/if}
109
124
  <div class="inline-flex items-center gap-0.5">
110
125
  {#snippet navBtn(Icon: typeof IconFirst, to: number, off: boolean, aria: string)}
@@ -123,22 +138,22 @@
123
138
  {#if showEdges}{@render navBtn(IconFirst, 1, page <= 1, "First page")}{/if}
124
139
  {@render navBtn(IconPrev, page - 1, page <= 1, "Previous page")}
125
140
 
126
- {#each items as it, i (i)}
127
- {#if it === "ellipsis"}
128
- <span aria-hidden="true" class="px-1.5 {subtle}">…</span>
129
- {:else}
130
- <Button
131
- variant={it === page ? variant : "ghost"}
141
+ <div class="flex items-center gap-1.5 px-1 {subtle}">
142
+ <span class="inline-block {responsiveClasses(size, { sm: 'w-11', md: 'w-12', lg: 'w-14' })}">
143
+ <NumberInput
144
+ bind:value={pageInput}
145
+ min={1}
146
+ max={pageCount}
147
+ clamp
132
148
  {size}
133
149
  {disabled}
134
- aria-label={`Page ${it}`}
135
- aria-current={it === page ? "page" : undefined}
136
- onclick={() => go(it)}
137
- >
138
- {it}
139
- </Button>
140
- {/if}
141
- {/each}
150
+ inputClass="text-center px-1"
151
+ aria-label="Page number"
152
+ onchange={commitJump}
153
+ />
154
+ </span>
155
+ <span aria-hidden="true">/&nbsp;{pageCount}</span>
156
+ </div>
142
157
 
143
158
  {@render navBtn(IconNext, page + 1, page >= pageCount, "Next page")}
144
159
  {#if showEdges}{@render navBtn(IconLast, pageCount, page >= pageCount, "Last page")}{/if}
@@ -5,13 +5,18 @@ type $$ComponentProps = {
5
5
  page?: number;
6
6
  total: number;
7
7
  perPage?: number;
8
+ /** When set, renders the per-page `Select` on the left. */
8
9
  perPageOptions?: number[];
9
- siblingCount?: number;
10
- boundaryCount?: number;
10
+ /** Show the First/Last edge buttons (default true, matching DataTable's footer). */
11
11
  showEdges?: boolean;
12
+ /** Show the "start–end of total" range text (default true). */
12
13
  showTotal?: boolean;
14
+ /** Color axis — tints the band surface + button hover (like DataTable), NOT the buttons. Omit for
15
+ * a neutral surface band; `ghost` makes the band transparent. */
13
16
  variant?: Variant;
14
17
  size?: Responsive<Size>;
18
+ /** Drop the band border/surface, leaving just the controls. */
19
+ borderless?: boolean;
15
20
  disabled?: boolean;
16
21
  class?: string;
17
22
  };