@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.
- package/.claude/skills/svelte-ui/SKILL.md +17 -12
- package/dist/components/ButtonGroup/ButtonGroup.svelte +47 -2
- package/dist/components/ButtonGroup/ButtonGroup.svelte.d.ts +11 -1
- package/dist/components/Combobox/Combobox.svelte +62 -31
- package/dist/components/Combobox/Combobox.svelte.d.ts +1 -1
- package/dist/components/Menu.svelte +35 -27
- package/dist/components/Pagination/Pagination.svelte +58 -43
- package/dist/components/Pagination/Pagination.svelte.d.ts +7 -2
- package/dist/components/Rating.svelte +169 -30
- package/dist/components/Rating.svelte.d.ts +22 -4
- package/dist/components/Slider/Slider.svelte +5 -3
- package/dist/components/TokenInput/TokenInput.svelte +12 -6
- package/dist/data/table/types.d.ts +9 -3
- package/dist/index.d.ts +1 -4
- package/dist/index.js +0 -2
- package/docs/components.md +31 -26
- package/docs/usage.md +27 -13
- package/package.json +1 -1
- package/dist/components/DateRangePicker/DateRangePicker.svelte +0 -157
- package/dist/components/DateRangePicker/DateRangePicker.svelte.d.ts +0 -31
- package/dist/components/DateRangePicker/index.d.ts +0 -2
- package/dist/components/DateRangePicker/index.js +0 -1
|
@@ -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` `
|
|
156
|
-
`Toggle` `Radio` `Rating`
|
|
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`,`
|
|
166
|
-
or a presentational default slot; `select`
|
|
167
|
-
selected = variant-swap to filled,
|
|
168
|
-
(`MenuItem[]`) makes a split
|
|
169
|
-
for
|
|
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
|
-
|
|
175
|
-
`Calendar mode="range"
|
|
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
|
|
179
|
-
`
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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) =>
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
class="inline-flex items-center justify-center opacity-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
{#
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
|
42
|
-
const
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
92
|
-
{
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
127
|
-
{
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
aria-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
{/each}
|
|
150
|
+
inputClass="text-center px-1"
|
|
151
|
+
aria-label="Page number"
|
|
152
|
+
onchange={commitJump}
|
|
153
|
+
/>
|
|
154
|
+
</span>
|
|
155
|
+
<span aria-hidden="true">/ {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
|
-
|
|
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
|
};
|