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

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,8 +153,7 @@ 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` (display `average`/`count`, OR `interactive` + `bind:value` star picker
157
- with `allowHalf`, click/hover/arrow-keys) · **`Combobox`** (free-text autocomplete — arbitrary value + suggestions,
156
+ `Toggle` `Radio` `Rating` · **`Combobox`** (free-text autocomplete arbitrary value + suggestions,
158
157
  client `options` or async `suggest`; vs `Select` which is a closed choice. Composes Input + Popup.) ·
159
158
  **`TokenInput`** (multi-value entry — Enter/comma adds a removable `Chip` token, Backspace-on-empty
160
159
  removes; `bind:values`, `max`, `allowDuplicates`; FieldChrome.) · **`FileUpload`** (drag-drop + click
@@ -171,15 +170,13 @@ examples):
171
170
  - **Specialized inputs:** `PasswordInput` `EmailInput` `SearchInput` `NumberInput` (formatting +
172
171
  `percent`/`stepper`/`clamp`/`liveFormat`) `PhoneInput` (stores E.164; dep-free, inject
173
172
  `parse`/`format` for per-country)
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"`.
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.)
177
176
  - **Data:** `DataTable` `Query` — driven by the headless layer at **`@spaethtech/svelte-ui/data`**
178
177
  (query-language parser/AST, `createGrid`, `DataGrid<T>`, `DataSet`). · **`Pagination`** (standalone
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`).
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).
183
180
  - **Overlays:** `Dialog` (pure modal shell — BYO content; `width`/`height` take any CSS value;
184
181
  `onShow(ctx)`/`onHide(ctx)` control focus — set `ctx.autoFocus` to a selector/element/`false`,
185
182
  `ctx.restoreFocus` likewise) · `ConfirmDialog` (title + message + Confirm/Cancel, built on Dialog)
@@ -10,10 +10,9 @@
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";
14
13
  import type { Variant } from "../../types/variants.js";
15
14
  import type { Size } from "../../types/sizes.js";
16
- import { responsiveClasses, type Responsive } from "../../types/responsive.js";
15
+ import type { Responsive } from "../../types/responsive.js";
17
16
 
18
17
  let {
19
18
  value = $bindable(""),
@@ -66,10 +65,6 @@
66
65
  let fieldEl = $state<HTMLDivElement>();
67
66
  let inputEl = $state<HTMLInputElement>();
68
67
  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;
73
68
 
74
69
  // Suggestions: async `suggest` (debounced) or client-side substring filter of `options`.
75
70
  const filtered = $derived.by(() => {
@@ -83,37 +78,23 @@
83
78
  return maxResults != null ? out.slice(0, maxResults) : out;
84
79
  });
85
80
 
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").
81
+ // Debounced async fetch — re-runs as `value` changes; gated by `minChars`.
88
82
  $effect(() => {
89
83
  if (!suggest) return;
90
84
  const q = value;
91
85
  if (q.trim().length < minChars) {
92
86
  asyncResults = [];
93
- loading = false;
94
87
  return;
95
88
  }
96
- const seq = ++reqSeq;
97
- loading = true;
98
89
  const t = setTimeout(() => {
99
90
  suggest(q)
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
- });
91
+ .then((r) => (asyncResults = r))
92
+ .catch(() => (asyncResults = []));
110
93
  }, debounceMs);
111
94
  return () => clearTimeout(t);
112
95
  });
113
96
 
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" }));
97
+ const canOpen = $derived(open && !disabled && filtered.length > 0);
117
98
 
118
99
  function choose(v: string) {
119
100
  value = v;
@@ -185,20 +166,14 @@
185
166
  }}
186
167
  >
187
168
  {#snippet actions()}
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}
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>
202
177
  {/snippet}
203
178
  </Input>
204
179
 
@@ -206,31 +181,25 @@
206
181
  <ul
207
182
  id={listId}
208
183
  role="listbox"
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}"
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"
210
185
  >
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…
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}
214
202
  </li>
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}
203
+ {/each}
235
204
  </ul>
236
205
  </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,18 +1,14 @@
1
1
  <!--
2
2
  /**
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.
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.
8
6
  */
9
7
  -->
10
8
  <script lang="ts">
11
9
  import Button from "../Button.svelte";
12
10
  import Select from "../Select.svelte";
13
- import NumberInput from "../NumberInput.svelte";
14
11
  import type { Variant } from "../../types/variants.js";
15
- import { variantToken } from "../../types/variants.js";
16
12
  import type { Size } from "../../types/sizes.js";
17
13
  import { responsiveClasses, type Responsive } from "../../types/responsive.js";
18
14
  import IconFirst from "~icons/mdi/page-first";
@@ -25,29 +21,25 @@
25
21
  total,
26
22
  perPage = $bindable(10),
27
23
  perPageOptions,
28
- showEdges = true,
29
- showTotal = true,
30
- variant,
24
+ siblingCount = 1,
25
+ boundaryCount = 1,
26
+ showEdges = false,
27
+ showTotal = false,
28
+ variant = "primary",
31
29
  size = "md",
32
- borderless = false,
33
30
  disabled = false,
34
31
  class: cls = "",
35
32
  }: {
36
33
  page?: number;
37
34
  total: number;
38
35
  perPage?: number;
39
- /** When set, renders the per-page `Select` on the left. */
40
36
  perPageOptions?: number[];
41
- /** Show the First/Last edge buttons (default true, matching DataTable's footer). */
37
+ siblingCount?: number;
38
+ boundaryCount?: number;
42
39
  showEdges?: boolean;
43
- /** Show the "start–end of total" range text (default true). */
44
40
  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. */
47
41
  variant?: Variant;
48
42
  size?: Responsive<Size>;
49
- /** Drop the band border/surface, leaving just the controls. */
50
- borderless?: boolean;
51
43
  disabled?: boolean;
52
44
  class?: string;
53
45
  } = $props();
@@ -59,10 +51,23 @@
59
51
  if (clamped !== page) page = clamped;
60
52
  });
61
53
 
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;
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;
66
71
  });
67
72
 
68
73
  const rangeText = $derived(
@@ -73,38 +78,18 @@
73
78
  const subtle = $derived(
74
79
  `${responsiveClasses(size, { sm: "text-xs", md: "text-sm", lg: "text-base" })} [color:color-mix(in_srgb,var(--ui-color-text)_60%,transparent)]`,
75
80
  );
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);
84
81
 
85
82
  const go = (to: number) => {
86
83
  if (disabled) return;
87
84
  page = Math.min(Math.max(to, 1), pageCount);
88
- pageInput = page;
89
85
  };
90
86
  const setPerPage = (n: number) => {
91
87
  perPage = n;
92
88
  page = 1;
93
89
  };
94
- const commitJump = () => {
95
- if (pageInput == null) {
96
- pageInput = page;
97
- return;
98
- }
99
- go(pageInput);
100
- };
101
90
  </script>
102
91
 
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
- >
92
+ <nav aria-label="Pagination" class="flex flex-wrap items-center justify-between gap-3 {cls}">
108
93
  {#if perPageOptions?.length}
109
94
  <div class="flex items-center gap-2">
110
95
  <Select
@@ -119,7 +104,7 @@
119
104
  </div>
120
105
  {/if}
121
106
 
122
- <div class="flex items-center gap-3 {perPageOptions?.length ? '' : 'ml-auto'}">
107
+ <div class="flex items-center gap-3">
123
108
  {#if showTotal}<span class={subtle}>{rangeText}</span>{/if}
124
109
  <div class="inline-flex items-center gap-0.5">
125
110
  {#snippet navBtn(Icon: typeof IconFirst, to: number, off: boolean, aria: string)}
@@ -138,22 +123,22 @@
138
123
  {#if showEdges}{@render navBtn(IconFirst, 1, page <= 1, "First page")}{/if}
139
124
  {@render navBtn(IconPrev, page - 1, page <= 1, "Previous page")}
140
125
 
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
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"}
148
132
  {size}
149
133
  {disabled}
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>
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}
157
142
 
158
143
  {@render navBtn(IconNext, page + 1, page >= pageCount, "Next page")}
159
144
  {#if showEdges}{@render navBtn(IconLast, pageCount, page >= pageCount, "Last page")}{/if}
@@ -5,18 +5,13 @@ 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. */
9
8
  perPageOptions?: number[];
10
- /** Show the First/Last edge buttons (default true, matching DataTable's footer). */
9
+ siblingCount?: number;
10
+ boundaryCount?: number;
11
11
  showEdges?: boolean;
12
- /** Show the "start–end of total" range text (default true). */
13
12
  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. */
16
13
  variant?: Variant;
17
14
  size?: Responsive<Size>;
18
- /** Drop the band border/surface, leaving just the controls. */
19
- borderless?: boolean;
20
15
  disabled?: boolean;
21
16
  class?: string;
22
17
  };
@@ -1,168 +1,47 @@
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
- -->
11
1
  <script lang="ts">
12
2
  import { variantToken, type Variant } from "../types/variants.js";
13
3
  import type { Size } from "../types/sizes.js";
14
4
 
15
5
  export type RatingProps = {
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;
6
+ average: number;
7
+ count: number;
35
8
  /** Filled-star colour. Defaults to gold; a variant tints from its token (e.g. `warning`, `primary`). */
36
9
  variant?: Variant;
10
+ /** Star size (sm/md/lg). */
37
11
  size?: Size;
38
- /** Accessible label for the interactive slider. */
39
- label?: string;
40
12
  };
41
13
 
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();
14
+ let { average, count, variant, size = "md" }: RatingProps = $props();
57
15
 
58
16
  const starColor = $derived(variant ? `var(${variantToken[variant]})` : "#ffd700");
59
17
  const starSize: Record<Size, string> = { sm: "1rem", md: "1.2rem", lg: "1.5rem" };
60
18
 
61
- const active = $derived(interactive && !readonly && !disabled);
62
- const step = $derived(allowHalf ? 0.5 : 1);
19
+ // Validate and sanitize the average rating
20
+ const validAverage = isNaN(average) || average < 0 ? 0 : Math.min(average, 10);
63
21
 
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
- });
22
+ // Convert TMDB 10-point scale to 5-star scale
23
+ const fiveStarRating = validAverage / 2;
69
24
 
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
- }
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));
125
29
  </script>
126
30
 
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>
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>
158
41
  {/each}
159
42
  </div>
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}
43
+ <span class="rating-value">{fiveStarRating.toFixed(1)}</span>
44
+ <span class="rating-count">({count})</span>
166
45
  </div>
167
46
 
168
47
  <style>
@@ -171,46 +50,28 @@
171
50
  align-items: center;
172
51
  gap: 0.5rem;
173
52
  }
174
- .rating.disabled {
175
- opacity: 0.5;
176
- pointer-events: none;
177
- }
178
53
 
179
54
  .stars {
180
55
  display: flex;
181
56
  gap: 0.1rem;
182
57
  }
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
- }
190
58
 
191
59
  .star {
192
60
  font-size: var(--star-size, 1.2rem);
193
61
  line-height: 1;
194
62
  }
195
63
 
196
- .star.full {
64
+ .star.filled {
197
65
  color: var(--star-color, #ffd700);
198
66
  }
199
67
 
200
- .star.empty {
201
- color: var(--ui-color-secondary);
68
+ .star.half-filled {
69
+ color: var(--star-color, #ffd700);
70
+ opacity: 0.6;
202
71
  }
203
72
 
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;
73
+ .star.empty {
74
+ color: var(--ui-color-secondary);
214
75
  }
215
76
 
216
77
  .rating-value {
@@ -1,31 +1,13 @@
1
1
  import { type Variant } from "../types/variants.js";
2
2
  import type { Size } from "../types/sizes.js";
3
3
  export type RatingProps = {
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;
4
+ average: number;
5
+ count: number;
23
6
  /** Filled-star colour. Defaults to gold; a variant tints from its token (e.g. `warning`, `primary`). */
24
7
  variant?: Variant;
8
+ /** Star size (sm/md/lg). */
25
9
  size?: Size;
26
- /** Accessible label for the interactive slider. */
27
- label?: string;
28
10
  };
29
- declare const Rating: import("svelte").Component<RatingProps, {}, "value">;
11
+ declare const Rating: import("svelte").Component<RatingProps, {}, "">;
30
12
  type Rating = ReturnType<typeof Rating>;
31
13
  export default Rating;
@@ -166,16 +166,14 @@
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 select-none">
169
+ <div class="flex items-center gap-3">
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. -->
174
172
  <div
175
173
  bind:this={trackEl}
176
174
  onpointerdown={onTrackPointerDown}
177
175
  class="relative flex-1 select-none rounded-full {disabled
178
- ? 'opacity-50 cursor-not-allowed'
176
+ ? 'opacity-50 pointer-events-none'
179
177
  : 'cursor-pointer'} {responsiveClasses(size, trackH)}"
180
178
  style="background-color: color-mix(in srgb, var({token}) 16%, transparent);"
181
179
  >
@@ -200,7 +198,7 @@
200
198
  aria-describedby={describedBy}
201
199
  onkeydown={(e) => onThumbKeydown(e, i)}
202
200
  class="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 shadow {disabled
203
- ? 'cursor-not-allowed'
201
+ ? ''
204
202
  : '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(
205
203
  size,
206
204
  thumbSz,
@@ -62,19 +62,14 @@
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.
68
65
  const fieldSize: Record<Size, string> = {
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",
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",
72
69
  };
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" : "");
70
+ const chipSize = $derived<Size>(
71
+ (typeof size === "string" ? size : "md") === "lg" ? "md" : "sm",
72
+ );
78
73
 
79
74
  let draft = $state("");
80
75
 
@@ -120,7 +115,6 @@
120
115
  text={v}
121
116
  variant={variant === "ghost" ? "secondary" : variant}
122
117
  size={chipSize}
123
- class={chipHeightClass}
124
118
  removable
125
119
  {disabled}
126
120
  onremove={() => removeAt(i)}
package/dist/index.d.ts CHANGED
@@ -43,7 +43,9 @@ 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";
46
47
  export { FileUpload } from "./components/FileUpload/index.js";
48
+ export type { RangePreset } from "./components/DateRangePicker/index.js";
47
49
  export { default as ThemeSelector } from "./components/ThemeSelector.svelte";
48
50
  export { default as ThemeToggle } from "./components/ThemeToggle.svelte";
49
51
  export { default as Popup } from "./components/Popup.svelte";
package/dist/index.js CHANGED
@@ -44,6 +44,7 @@ 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";
47
48
  export { FileUpload } from "./components/FileUpload/index.js";
48
49
  // Theme Components
49
50
  export { default as ThemeSelector } from "./components/ThemeSelector.svelte";
@@ -122,6 +122,20 @@ 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
+
125
139
  ### Slider
126
140
 
127
141
  Draggable range input — a single value or a `[min, max]` pair.
@@ -271,15 +285,10 @@ independent. Propagates `size`/`variant`/`disabled` to children.
271
285
 
272
286
  ### Rating
273
287
 
274
- Star rating in two modes: a read-only display, or an interactive picker.
288
+ Read-only star-rating display (5-star, from a 10-point average).
275
289
 
276
290
  - **Location**: `src/lib/components/Rating.svelte`
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).
291
+ - **Props**: `average` (number), `count` (number)
283
292
 
284
293
  ## Specialized Input Components
285
294
 
@@ -390,19 +399,13 @@ dialog (operators, casts, dynamic `now()` dates, and a worked examples table). T
390
399
  Standalone page navigator — the reusable extraction of `DataTable`'s footer pager.
391
400
 
392
401
  - **Location**: `src/lib/components/Pagination/Pagination.svelte`
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)
402
+ - **Axes**: `variant` (active page fill), `size`
396
403
  - **Props**: `page` (bindable), `total`, `perPage` (bindable), `perPageOptions` (→ rows-per-page
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>`)
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>`)
406
409
 
407
410
  ## Overlays
408
411
 
package/docs/usage.md CHANGED
@@ -311,17 +311,21 @@ 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
- ### Date range
314
+ ### DateRangePicker
315
315
 
316
- Use `DatePicker` with the `range` prop its value is a `{ start, end }` object.
316
+ A start–end range with a presets rail (composes `Calendar mode="range"`).
317
317
 
318
318
  ```svelte
319
319
  <script>
320
- import { DatePicker } from "@spaethtech/svelte-ui";
321
- let range = $state({ start: null, end: null });
320
+ import { DateRangePicker } from "@spaethtech/svelte-ui";
321
+ let start = $state("");
322
+ let end = $state("");
322
323
  </script>
323
324
 
324
- <DatePicker range bind:value={range} label="Reporting period" />
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" }]} />
325
329
  ```
326
330
 
327
331
  ### Stepper
@@ -736,9 +740,7 @@ A thin separating rule — horizontal (optionally labelled) or vertical (toolbar
736
740
 
737
741
  ### Pagination
738
742
 
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).
743
+ A standalone page navigator (numbered buttons + ellipsis, prev/next, optional per-page + total).
742
744
 
743
745
  ```svelte
744
746
  <script>
@@ -747,13 +749,10 @@ indicator is an editable `NumberInput` (type a page + Enter to jump).
747
749
  let perPage = $state(10);
748
750
  </script>
749
751
 
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} />
752
+ <Pagination bind:page bind:perPage total={247} perPageOptions={[10, 25, 50, 100]} showTotal />
754
753
 
755
- <!-- variant tints the band surface + button hover (not the buttons); ghost = transparent band -->
756
- <Pagination bind:page total={200} variant="primary" />
754
+ <!-- Compact: numbers only, with first/last controls -->
755
+ <Pagination bind:page total={200} showEdges />
757
756
  ```
758
757
 
759
758
  ### Breadcrumbs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.15.0",
3
+ "version": "0.15.1-dev.67.fbf3875",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"