@spaethtech/svelte-ui 0.15.1-dev.67.fbf3875 → 0.15.1-dev.69.15831f2

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.
@@ -153,7 +153,8 @@ All from `@spaethtech/svelte-ui` (see the shipped `docs/components.md` + `docs/u
153
153
  examples):
154
154
 
155
155
  - **Form:** `Button` `ButtonDropdown` `Input` `Select` `Combobox` `Slider` `List` `TextArea` `Checkbox`
156
- `Toggle` `Radio` `Rating` · **`Combobox`** (free-text autocomplete arbitrary value + suggestions,
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
@@ -170,13 +171,15 @@ examples):
170
171
  - **Specialized inputs:** `PasswordInput` `EmailInput` `SearchInput` `NumberInput` (formatting +
171
172
  `percent`/`stepper`/`clamp`/`liveFormat`) `PhoneInput` (stores E.164; dep-free, inject
172
173
  `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.)
174
+ - **Date / time:** `DatePicker` `Calendar` `TimePicker` `TimeSpinner` `TimeRangeInput` `DateTimeInput`.
175
+ For a start–end range use **`DatePicker range`** (`bind:value` = `{ start, end }`) it composes
176
+ `Calendar mode="range"`.
176
177
  - **Data:** `DataTable` `Query` — driven by the headless layer at **`@spaethtech/svelte-ui/data`**
177
178
  (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).
179
+ pager styled like the DataTable footer: tinted band, per-page `Select`, range total, and a
180
+ `First·Prev·[page]/N·Next·Last` stepper whose indicator is an editable `NumberInput` quick-jump;
181
+ `bind:page`/`bind:perPage`, `total`, `perPageOptions`, `showEdges`/`showTotal`, `borderless`;
182
+ `variant` tints band+hover only; built from `Button`+`Select`+`NumberInput`).
180
183
  - **Overlays:** `Dialog` (pure modal shell — BYO content; `width`/`height` take any CSS value;
181
184
  `onShow(ctx)`/`onHide(ctx)` control focus — set `ctx.autoFocus` to a selector/element/`false`,
182
185
  `ctx.restoreFocus` likewise) · `ConfirmDialog` (title + message + Confirm/Cancel, built on Dialog)
@@ -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[];
@@ -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
  };
@@ -1,47 +1,168 @@
1
+ <!--
2
+ /**
3
+ * Rating — star rating in two modes:
4
+ * • Display (default): a read-only average. `average` is a 0–10 score (TMDB-style), shown as 5 stars
5
+ * with halves, plus an optional `count`.
6
+ * • Interactive (`interactive`): a form control — click / hover / arrow-key a `bind:value` (0..max
7
+ * stars), optional half-steps. `role="slider"` (same a11y pattern as our Slider).
8
+ * See usage docs.
9
+ */
10
+ -->
1
11
  <script lang="ts">
2
12
  import { variantToken, type Variant } from "../types/variants.js";
3
13
  import type { Size } from "../types/sizes.js";
4
14
 
5
15
  export type RatingProps = {
6
- average: number;
7
- count: number;
16
+ /** Display mode: a 0–10 average score (TMDB-style), rendered as 5 stars (average / 2). */
17
+ average?: number;
18
+ /** Display mode: number of ratings, shown in parentheses. */
19
+ count?: number;
20
+ /** Interactive mode: the selected rating in stars (0..max), bindable. */
21
+ value?: number;
22
+ /** Enable selection (click / hover / keyboard). Off = read-only display. */
23
+ interactive?: boolean;
24
+ /** Interactive but not editable (shows `value`, no input). */
25
+ readonly?: boolean;
26
+ disabled?: boolean;
27
+ /** Number of stars in interactive mode (default 5). */
28
+ max?: number;
29
+ /** Allow half-star selection/display (interactive). */
30
+ allowHalf?: boolean;
31
+ /** Show the numeric value beside the stars (default: true in display mode, false interactive). */
32
+ showValue?: boolean;
33
+ /** Fired on change (interactive). */
34
+ onchange?: (value: number) => void;
8
35
  /** Filled-star colour. Defaults to gold; a variant tints from its token (e.g. `warning`, `primary`). */
9
36
  variant?: Variant;
10
- /** Star size (sm/md/lg). */
11
37
  size?: Size;
38
+ /** Accessible label for the interactive slider. */
39
+ label?: string;
12
40
  };
13
41
 
14
- let { average, count, variant, size = "md" }: RatingProps = $props();
42
+ let {
43
+ average,
44
+ count,
45
+ value = $bindable(0),
46
+ interactive = false,
47
+ readonly = false,
48
+ disabled = false,
49
+ max = 5,
50
+ allowHalf = false,
51
+ showValue,
52
+ onchange,
53
+ variant,
54
+ size = "md",
55
+ label = "Rating",
56
+ }: RatingProps = $props();
15
57
 
16
58
  const starColor = $derived(variant ? `var(${variantToken[variant]})` : "#ffd700");
17
59
  const starSize: Record<Size, string> = { sm: "1rem", md: "1.2rem", lg: "1.5rem" };
18
60
 
19
- // Validate and sanitize the average rating
20
- const validAverage = isNaN(average) || average < 0 ? 0 : Math.min(average, 10);
61
+ const active = $derived(interactive && !readonly && !disabled);
62
+ const step = $derived(allowHalf ? 0.5 : 1);
21
63
 
22
- // Convert TMDB 10-point scale to 5-star scale
23
- const fiveStarRating = validAverage / 2;
64
+ // Display mode: sanitize the 0–10 average and convert to a 5-point scale.
65
+ const displayFive = $derived.by(() => {
66
+ const a = isNaN(average ?? NaN) || (average ?? 0) < 0 ? 0 : Math.min(average ?? 0, 10);
67
+ return a / 2;
68
+ });
24
69
 
25
- // Calculate how many stars should be filled (ensure they're valid numbers)
26
- const filledStars = Math.max(0, Math.min(5, Math.floor(fiveStarRating)));
27
- const hasHalfStar = fiveStarRating % 1 >= 0.5;
28
- const emptyStars = Math.max(0, 5 - filledStars - (hasHalfStar ? 1 : 0));
70
+ // Hover preview (interactive) overrides `value` while pointing.
71
+ let hover = $state<number | null>(null);
72
+
73
+ const starCount = $derived(interactive ? max : 5);
74
+ // The numeric value the stars are painted from.
75
+ const paint = $derived(interactive ? (hover ?? value) : displayFive);
76
+ const readout = $derived(interactive ? value : displayFive);
77
+ const wantValue = $derived(showValue ?? !interactive);
78
+
79
+ // Fill state for the i-th star (1-based).
80
+ function fillOf(i: number): "full" | "half" | "empty" {
81
+ if (paint >= i) return "full";
82
+ if (paint >= i - 0.5) return "half";
83
+ return "empty";
84
+ }
85
+ // Half-step from the pointer's position within a star (left half = i-0.5).
86
+ function valueAt(i: number, e: MouseEvent): number {
87
+ if (!allowHalf) return i;
88
+ const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
89
+ return e.clientX - r.left < r.width / 2 ? i - 0.5 : i;
90
+ }
91
+ function pick(i: number, e: MouseEvent) {
92
+ if (!active) return;
93
+ value = valueAt(i, e);
94
+ onchange?.(value);
95
+ }
96
+ function preview(i: number, e: MouseEvent) {
97
+ if (!active) return;
98
+ hover = valueAt(i, e);
99
+ }
100
+ function onkeydown(e: KeyboardEvent) {
101
+ if (!active) return;
102
+ let v = value;
103
+ switch (e.key) {
104
+ case "ArrowRight":
105
+ case "ArrowUp":
106
+ v = Math.min(max, value + step);
107
+ break;
108
+ case "ArrowLeft":
109
+ case "ArrowDown":
110
+ v = Math.max(0, value - step);
111
+ break;
112
+ case "Home":
113
+ v = 0;
114
+ break;
115
+ case "End":
116
+ v = max;
117
+ break;
118
+ default:
119
+ return;
120
+ }
121
+ e.preventDefault();
122
+ value = v;
123
+ onchange?.(v);
124
+ }
29
125
  </script>
30
126
 
31
- <div class="rating select-none" style="--star-color: {starColor}; --star-size: {starSize[size]}">
32
- <div class="stars">
33
- {#each Array(filledStars) as _, i}
34
- <span class="star filled">★</span>
35
- {/each}
36
- {#if hasHalfStar}
37
- <span class="star half-filled">☆</span>
38
- {/if}
39
- {#each Array(emptyStars) as _, i}
40
- <span class="star empty">☆</span>
127
+ <div
128
+ class="rating select-none"
129
+ class:disabled
130
+ style="--star-color: {starColor}; --star-size: {starSize[size]}"
131
+ >
132
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
133
+ <div
134
+ class="stars"
135
+ role={active ? "slider" : undefined}
136
+ tabindex={active ? 0 : undefined}
137
+ aria-label={active ? label : undefined}
138
+ aria-valuemin={active ? 0 : undefined}
139
+ aria-valuemax={active ? max : undefined}
140
+ aria-valuenow={active ? value : undefined}
141
+ aria-valuetext={active ? `${value} of ${max} stars` : undefined}
142
+ aria-readonly={interactive && readonly ? true : undefined}
143
+ aria-disabled={disabled || undefined}
144
+ onkeydown={active ? onkeydown : undefined}
145
+ onpointerleave={active ? () => (hover = null) : undefined}
146
+ >
147
+ {#each Array(starCount) as _, idx (idx)}
148
+ {@const i = idx + 1}
149
+ {@const f = fillOf(i)}
150
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
151
+ <span
152
+ class="star {f} {active ? 'cursor-pointer' : ''}"
153
+ onpointermove={active ? (e) => preview(i, e) : undefined}
154
+ onclick={active ? (e) => pick(i, e) : undefined}
155
+ >
156
+ {f === "empty" ? "☆" : "★"}
157
+ </span>
41
158
  {/each}
42
159
  </div>
43
- <span class="rating-value">{fiveStarRating.toFixed(1)}</span>
44
- <span class="rating-count">({count})</span>
160
+ {#if wantValue}
161
+ <span class="rating-value">{readout.toFixed(1)}</span>
162
+ {/if}
163
+ {#if !interactive && count != null}
164
+ <span class="rating-count">({count})</span>
165
+ {/if}
45
166
  </div>
46
167
 
47
168
  <style>
@@ -50,30 +171,48 @@
50
171
  align-items: center;
51
172
  gap: 0.5rem;
52
173
  }
174
+ .rating.disabled {
175
+ opacity: 0.5;
176
+ pointer-events: none;
177
+ }
53
178
 
54
179
  .stars {
55
180
  display: flex;
56
181
  gap: 0.1rem;
57
182
  }
183
+ .stars[role="slider"] {
184
+ border-radius: 4px;
185
+ }
186
+ .stars[role="slider"]:focus-visible {
187
+ outline: 2px solid color-mix(in srgb, var(--ui-color-text) 70%, transparent);
188
+ outline-offset: 2px;
189
+ }
58
190
 
59
191
  .star {
60
192
  font-size: var(--star-size, 1.2rem);
61
193
  line-height: 1;
62
194
  }
63
195
 
64
- .star.filled {
65
- color: var(--star-color, #ffd700);
66
- }
67
-
68
- .star.half-filled {
196
+ .star.full {
69
197
  color: var(--star-color, #ffd700);
70
- opacity: 0.6;
71
198
  }
72
199
 
73
200
  .star.empty {
74
201
  color: var(--ui-color-secondary);
75
202
  }
76
203
 
204
+ /* Half star: a full ★ glyph clipped to a left-gold / right-muted gradient. */
205
+ .star.half {
206
+ background: linear-gradient(
207
+ 90deg,
208
+ var(--star-color, #ffd700) 50%,
209
+ var(--ui-color-secondary) 50%
210
+ );
211
+ -webkit-background-clip: text;
212
+ background-clip: text;
213
+ color: transparent;
214
+ }
215
+
77
216
  .rating-value {
78
217
  font-weight: 600;
79
218
  color: var(--ui-color-text);
@@ -1,13 +1,31 @@
1
1
  import { type Variant } from "../types/variants.js";
2
2
  import type { Size } from "../types/sizes.js";
3
3
  export type RatingProps = {
4
- average: number;
5
- count: number;
4
+ /** Display mode: a 0–10 average score (TMDB-style), rendered as 5 stars (average / 2). */
5
+ average?: number;
6
+ /** Display mode: number of ratings, shown in parentheses. */
7
+ count?: number;
8
+ /** Interactive mode: the selected rating in stars (0..max), bindable. */
9
+ value?: number;
10
+ /** Enable selection (click / hover / keyboard). Off = read-only display. */
11
+ interactive?: boolean;
12
+ /** Interactive but not editable (shows `value`, no input). */
13
+ readonly?: boolean;
14
+ disabled?: boolean;
15
+ /** Number of stars in interactive mode (default 5). */
16
+ max?: number;
17
+ /** Allow half-star selection/display (interactive). */
18
+ allowHalf?: boolean;
19
+ /** Show the numeric value beside the stars (default: true in display mode, false interactive). */
20
+ showValue?: boolean;
21
+ /** Fired on change (interactive). */
22
+ onchange?: (value: number) => void;
6
23
  /** Filled-star colour. Defaults to gold; a variant tints from its token (e.g. `warning`, `primary`). */
7
24
  variant?: Variant;
8
- /** Star size (sm/md/lg). */
9
25
  size?: Size;
26
+ /** Accessible label for the interactive slider. */
27
+ label?: string;
10
28
  };
11
- declare const Rating: import("svelte").Component<RatingProps, {}, "">;
29
+ declare const Rating: import("svelte").Component<RatingProps, {}, "value">;
12
30
  type Rating = ReturnType<typeof Rating>;
13
31
  export default Rating;
@@ -166,14 +166,16 @@
166
166
 
167
167
  <FieldChrome {label} {aside} {error} {description} {required} {controlId} {size} class={cls}>
168
168
  {#snippet control({ describedBy })}
169
- <div class="flex items-center gap-3">
169
+ <div class="flex items-center gap-3 select-none">
170
170
  <!-- The track is the click-to-position surface; the thumbs below carry role="slider". -->
171
171
  <!-- svelte-ignore a11y_no_static_element_interactions -->
172
+ <!-- Disabled keeps pointer events (so the not-allowed cursor shows) but every handler no-ops on
173
+ `disabled`, so it can't be dragged/keyed. -->
172
174
  <div
173
175
  bind:this={trackEl}
174
176
  onpointerdown={onTrackPointerDown}
175
177
  class="relative flex-1 select-none rounded-full {disabled
176
- ? 'opacity-50 pointer-events-none'
178
+ ? 'opacity-50 cursor-not-allowed'
177
179
  : 'cursor-pointer'} {responsiveClasses(size, trackH)}"
178
180
  style="background-color: color-mix(in srgb, var({token}) 16%, transparent);"
179
181
  >
@@ -198,7 +200,7 @@
198
200
  aria-describedby={describedBy}
199
201
  onkeydown={(e) => onThumbKeydown(e, i)}
200
202
  class="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 shadow {disabled
201
- ? ''
203
+ ? 'cursor-not-allowed'
202
204
  : 'cursor-grab active:cursor-grabbing'} focus-visible:[outline:2px_solid_color-mix(in_srgb,var(--ui-color-text)_70%,transparent)] focus-visible:[outline-offset:2px] {responsiveClasses(
203
205
  size,
204
206
  thumbSz,
@@ -62,14 +62,19 @@
62
62
  : `color-mix(in srgb, var(${token}) var(--ui-tint-border-hover), transparent)`,
63
63
  );
64
64
 
65
+ // Vertical padding is kept small enough that a single token row (chip + 1px border box + padding)
66
+ // never exceeds the tier's `min-h`, so the field height is CONSTANT whether empty or holding tokens.
67
+ // Multi-row wrapping still grows the field; row spacing comes from the flex `gap`, not padding.
65
68
  const fieldSize: Record<Size, string> = {
66
- sm: "min-h-6 px-1.5 py-0.5 gap-1 text-xs",
67
- md: "min-h-8 px-2 py-1 gap-1.5 text-sm",
68
- lg: "min-h-10 px-2.5 py-1.5 gap-2 text-base",
69
+ sm: "min-h-6 px-1.5 py-0 gap-1 text-xs",
70
+ md: "min-h-8 px-2 py-[2px] gap-1.5 text-sm",
71
+ lg: "min-h-10 px-2.5 py-[2px] gap-2 text-base",
69
72
  };
70
- const chipSize = $derived<Size>(
71
- (typeof size === "string" ? size : "md") === "lg" ? "md" : "sm",
72
- );
73
+ const baseSize = $derived<Size>(typeof size === "string" ? size : "md");
74
+ const chipSize = $derived<Size>(baseSize === "lg" ? "md" : "sm");
75
+ // A default `sm` chip is 24px — exactly the sm FIELD height, so it can't fit inside the border box.
76
+ // Only in the sm field, cap the chip to 20px (still fits the min-h with room to center).
77
+ const chipHeightClass = $derived(baseSize === "sm" ? "!h-5" : "");
73
78
 
74
79
  let draft = $state("");
75
80
 
@@ -115,6 +120,7 @@
115
120
  text={v}
116
121
  variant={variant === "ghost" ? "secondary" : variant}
117
122
  size={chipSize}
123
+ class={chipHeightClass}
118
124
  removable
119
125
  {disabled}
120
126
  onremove={() => removeAt(i)}
package/dist/index.d.ts CHANGED
@@ -43,9 +43,7 @@ export { default as DatePicker } from "./components/DatePicker.svelte";
43
43
  export { default as TimeSpinner } from "./components/TimeSpinner.svelte";
44
44
  export { default as TimePicker } from "./components/TimePicker.svelte";
45
45
  export { default as TimeRangeInput } from "./components/TimeRangeInput.svelte";
46
- export { DateRangePicker } from "./components/DateRangePicker/index.js";
47
46
  export { FileUpload } from "./components/FileUpload/index.js";
48
- export type { RangePreset } from "./components/DateRangePicker/index.js";
49
47
  export { default as ThemeSelector } from "./components/ThemeSelector.svelte";
50
48
  export { default as ThemeToggle } from "./components/ThemeToggle.svelte";
51
49
  export { default as Popup } from "./components/Popup.svelte";
package/dist/index.js CHANGED
@@ -44,7 +44,6 @@ export { default as DatePicker } from "./components/DatePicker.svelte";
44
44
  export { default as TimeSpinner } from "./components/TimeSpinner.svelte";
45
45
  export { default as TimePicker } from "./components/TimePicker.svelte";
46
46
  export { default as TimeRangeInput } from "./components/TimeRangeInput.svelte";
47
- export { DateRangePicker } from "./components/DateRangePicker/index.js";
48
47
  export { FileUpload } from "./components/FileUpload/index.js";
49
48
  // Theme Components
50
49
  export { default as ThemeSelector } from "./components/ThemeSelector.svelte";
@@ -122,20 +122,6 @@ Drag-and-drop + click file field with thumbnails, remove, and optional progress.
122
122
  `<input type=file>`; labelled remove ×s
123
123
  - **Composition**: `Button` + `Progress`; FieldChrome for label/error
124
124
 
125
- ### DateRangePicker
126
-
127
- Start–end date range with a presets rail; composes `Calendar mode="range"` + `Input` + `Popup`.
128
-
129
- - **Location**: `src/lib/components/DateRangePicker/DateRangePicker.svelte`
130
- - **Axes**: `variant`, `size`
131
- - **Props**: `start`/`end` (bindable ISO), `min`/`max`, `presets` (`RangePreset[]`; default set, `[]`
132
- hides the rail), `format`, `placeholder`, plus FieldChrome (`label`/`aside`/`error`/`description`/
133
- `id`/`required`), `class`
134
- - **Behavior**: readonly `Input` trigger shows the formatted range; the popup pairs a presets column
135
- (`Button` ghost) with the range calendar; a preset or the second calendar pick closes it
136
- - **Note**: `DatePicker range` covers a bare range (object value, no presets); this is the
137
- presets-first, `bind:start`/`bind:end` variant
138
-
139
125
  ### Slider
140
126
 
141
127
  Draggable range input — a single value or a `[min, max]` pair.
@@ -285,10 +271,15 @@ independent. Propagates `size`/`variant`/`disabled` to children.
285
271
 
286
272
  ### Rating
287
273
 
288
- Read-only star-rating display (5-star, from a 10-point average).
274
+ Star rating in two modes: a read-only display, or an interactive picker.
289
275
 
290
276
  - **Location**: `src/lib/components/Rating.svelte`
291
- - **Props**: `average` (number), `count` (number)
277
+ - **Display mode** (default): `average` (0–10 score, shown as 5 stars = average/2), `count` (votes).
278
+ - **Interactive mode**: `interactive` + `value` (bindable, 0..`max`). Click a star, hover to preview,
279
+ or focus and use ←/→ · ↑/↓ · Home/End. `allowHalf` enables half-star selection; `onchange`,
280
+ `readonly`, `disabled`. Rendered as a `role="slider"` (same a11y pattern as `Slider`).
281
+ - **Shared**: `variant` (filled-star tint; default gold), `size`, `showValue` (numeric readout —
282
+ defaults on in display mode, off interactive), `label` (slider a11y label).
292
283
 
293
284
  ## Specialized Input Components
294
285
 
@@ -399,13 +390,19 @@ dialog (operators, casts, dynamic `now()` dates, and a worked examples table). T
399
390
  Standalone page navigator — the reusable extraction of `DataTable`'s footer pager.
400
391
 
401
392
  - **Location**: `src/lib/components/Pagination/Pagination.svelte`
402
- - **Axes**: `variant` (active page fill), `size`
393
+ - **Axes**: `variant` (tints the band **surface + button hover**, like DataTable's footer — NOT a
394
+ filled button; omit for a neutral band, `ghost` for a transparent one), `size` (scales band, Select,
395
+ buttons, and the page field)
403
396
  - **Props**: `page` (bindable), `total`, `perPage` (bindable), `perPageOptions` (→ rows-per-page
404
- `Select`), `siblingCount`/`boundaryCount`, `showEdges` (first/last), `showTotal`, `disabled`, `class`
405
- - **Behavior**: numbered buttons with ellipsis for long ranges (active = filled, rest `ghost`);
406
- prev/next/first/last disable at the ends; choosing `perPage` resets to page 1; `page` clamps to range
407
- - **Accessibility**: `<nav aria-label="Pagination">`; active page `aria-current="page"`
408
- - **Composition**: `Button` + `Select` only (no raw `<button>`)
397
+ `Select` on the left), `showEdges` (first/last, default **true**), `showTotal` (range text, default
398
+ **true**), `borderless` (drop the band border/surface), `disabled`, `class`
399
+ - **Layout**: a tinted, bordered band (matches `DataTable`'s footer) per-page `Select` left; range
400
+ text + a compact `First · Prev · [page]/N · Next · Last` stepper right. The page indicator is an
401
+ **editable `NumberInput`** (min 1, max N, clamped) — type a page + Enter to jump; ↑/↓ step it.
402
+ - **Behavior**: prev/next/first/last disable at the ends; choosing `perPage` resets to page 1; `page`
403
+ clamps to range; jump commits on change (Enter/blur)
404
+ - **Accessibility**: `<nav aria-label="Pagination">`; the jump field is `aria-label="Page number"`
405
+ - **Composition**: `Button` + `Select` + `NumberInput` only (no raw `<button>`/`<input>`)
409
406
 
410
407
  ## Overlays
411
408
 
package/docs/usage.md CHANGED
@@ -311,21 +311,17 @@ A drag-and-drop + click file field (thumbnails, remove, optional progress).
311
311
  label="Attachments" onfiles={(f) => console.log(f)} />
312
312
  ```
313
313
 
314
- ### DateRangePicker
314
+ ### Date range
315
315
 
316
- A start–end range with a presets rail (composes `Calendar mode="range"`).
316
+ Use `DatePicker` with the `range` prop — its value is a `{ start, end }` object.
317
317
 
318
318
  ```svelte
319
319
  <script>
320
- import { DateRangePicker } from "@spaethtech/svelte-ui";
321
- let start = $state("");
322
- let end = $state("");
320
+ import { DatePicker } from "@spaethtech/svelte-ui";
321
+ let range = $state({ start: null, end: null });
323
322
  </script>
324
323
 
325
- <DateRangePicker bind:start bind:end label="Reporting period" />
326
-
327
- <!-- custom presets, or presets={[]} for calendar-only -->
328
- <DateRangePicker bind:start bind:end presets={[{ label: "Q1", start: "2026-01-01", end: "2026-03-31" }]} />
324
+ <DatePicker range bind:value={range} label="Reporting period" />
329
325
  ```
330
326
 
331
327
  ### Stepper
@@ -740,7 +736,9 @@ A thin separating rule — horizontal (optionally labelled) or vertical (toolbar
740
736
 
741
737
  ### Pagination
742
738
 
743
- A standalone page navigator (numbered buttons + ellipsis, prev/next, optional per-page + total).
739
+ A standalone page navigator laid out like `DataTable`'s footer: a tinted band with an optional
740
+ per-page `Select`, a range total, and a `First · Prev · [page]/N · Next · Last` stepper whose page
741
+ indicator is an editable `NumberInput` (type a page + Enter to jump).
744
742
 
745
743
  ```svelte
746
744
  <script>
@@ -749,10 +747,13 @@ A standalone page navigator (numbered buttons + ellipsis, prev/next, optional pe
749
747
  let perPage = $state(10);
750
748
  </script>
751
749
 
752
- <Pagination bind:page bind:perPage total={247} perPageOptions={[10, 25, 50, 100]} showTotal />
750
+ <Pagination bind:page bind:perPage total={247} perPageOptions={[10, 25, 50, 100]} />
751
+
752
+ <!-- Compact: just the stepper (no per-page Select) -->
753
+ <Pagination bind:page total={200} showTotal={false} />
753
754
 
754
- <!-- Compact: numbers only, with first/last controls -->
755
- <Pagination bind:page total={200} showEdges />
755
+ <!-- variant tints the band surface + button hover (not the buttons); ghost = transparent band -->
756
+ <Pagination bind:page total={200} variant="primary" />
756
757
  ```
757
758
 
758
759
  ### Breadcrumbs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.15.1-dev.67.fbf3875",
3
+ "version": "0.15.1-dev.69.15831f2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"