@marianmeres/stuic 3.172.0 → 3.173.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/AGENTS.md +2 -2
  2. package/API.md +56 -2
  3. package/README.md +14 -2
  4. package/dist/actions/resizable-width.svelte.d.ts +14 -28
  5. package/dist/actions/resizable-width.svelte.js +16 -171
  6. package/dist/attachments/index.d.ts +1 -0
  7. package/dist/attachments/index.js +1 -0
  8. package/dist/attachments/resizable.d.ts +113 -0
  9. package/dist/attachments/resizable.fixture.svelte +53 -0
  10. package/dist/attachments/resizable.fixture.svelte.d.ts +10 -0
  11. package/dist/attachments/resizable.js +295 -0
  12. package/dist/components/RangeSlider/README.md +291 -0
  13. package/dist/components/RangeSlider/RangeSlider.svelte +763 -0
  14. package/dist/components/RangeSlider/RangeSlider.svelte.d.ts +130 -0
  15. package/dist/components/RangeSlider/i18n-sk.d.ts +17 -0
  16. package/dist/components/RangeSlider/i18n-sk.js +19 -0
  17. package/dist/components/RangeSlider/i18n.d.ts +33 -0
  18. package/dist/components/RangeSlider/i18n.js +41 -0
  19. package/dist/components/RangeSlider/index.css +430 -0
  20. package/dist/components/RangeSlider/index.d.ts +3 -0
  21. package/dist/components/RangeSlider/index.js +3 -0
  22. package/dist/components/Rating/README.md +206 -0
  23. package/dist/components/Rating/Rating.svelte +355 -0
  24. package/dist/components/Rating/Rating.svelte.d.ts +82 -0
  25. package/dist/components/Rating/i18n-sk.d.ts +17 -0
  26. package/dist/components/Rating/i18n-sk.js +21 -0
  27. package/dist/components/Rating/i18n.d.ts +34 -0
  28. package/dist/components/Rating/i18n.js +42 -0
  29. package/dist/components/Rating/index.css +170 -0
  30. package/dist/components/Rating/index.d.ts +3 -0
  31. package/dist/components/Rating/index.js +3 -0
  32. package/dist/components/SplitPane/README.md +169 -0
  33. package/dist/components/SplitPane/SplitPane.svelte +202 -0
  34. package/dist/components/SplitPane/SplitPane.svelte.d.ts +67 -0
  35. package/dist/components/SplitPane/i18n-sk.d.ts +17 -0
  36. package/dist/components/SplitPane/i18n-sk.js +18 -0
  37. package/dist/components/SplitPane/i18n.d.ts +31 -0
  38. package/dist/components/SplitPane/i18n.js +39 -0
  39. package/dist/components/SplitPane/index.css +153 -0
  40. package/dist/components/SplitPane/index.d.ts +3 -0
  41. package/dist/components/SplitPane/index.js +3 -0
  42. package/dist/components/WithSidePanel/README.md +19 -16
  43. package/dist/icons/index.d.ts +2 -0
  44. package/dist/icons/index.js +3 -0
  45. package/dist/index.css +3 -0
  46. package/dist/index.d.ts +3 -0
  47. package/dist/index.js +3 -0
  48. package/docs/{maybe-todo.md → _archive/maybe-todo.md} +18 -7
  49. package/docs/architecture.md +1 -1
  50. package/docs/domains/actions.md +18 -18
  51. package/docs/domains/attachments.md +72 -9
  52. package/docs/domains/components.md +175 -32
  53. package/package.json +5 -5
@@ -0,0 +1,206 @@
1
+ # Rating
2
+
3
+ Star rating — an input and a display in one component. Input mode is an accessible
4
+ `role="radiogroup"` of per-star (or per-half-star) radios with hover preview, roving
5
+ keyboard navigation, click-again-to-clear, a hidden input for form participation and
6
+ the stuic `validate` action (with `required` enforced). `readonly` turns it into a
7
+ display (`role="img"`) that renders any fraction — 4.2 is a 20%-filled fifth star.
8
+
9
+ Symbols are two stacked copies of an svg: the empty one underneath (muted) and the
10
+ filled one on top clipped to the value, so any fill-based icon works as-is (stars by
11
+ default; hearts, flames, anything drawn with `currentColor`). Adjacent but different:
12
+ `FieldLikeButton` (a field that opens something), `Slider` (a continuous range).
13
+
14
+ ## Props
15
+
16
+ | Prop | Type | Default | Description |
17
+ | --------------------- | --------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------- |
18
+ | `value` | `number` | `0` | Current value (bindable); 0 = no rating. Rendered clamped to `[0, max]`, snapped to whole/half symbols in input mode |
19
+ | `max` | `number` | `5` | Number of symbols (integer ≥ 1) |
20
+ | `allowHalf` | `boolean` | `false` | Half-symbol values: each symbol gets two hit zones, arrows step by 0.5 |
21
+ | `allowClear` | `boolean` | `true` | Clicking the selected symbol again, or Delete / Backspace, resets to 0 |
22
+ | `readonly` | `boolean` | `false` | Display variant: `role="img"`, no interaction, no hidden input, fractional values rendered as-is |
23
+ | `disabled` | `boolean` | `false` | No interaction; the hidden input is disabled too (nothing submits) |
24
+ | `size` | `"sm" \| "md" \| "lg"` | `"md"` | Symbol size preset (`--stuic-rating-size` overrides any preset) |
25
+ | `intent` | `IntentColorKey` | - | Theme color of the filled symbols; unset = the fixed "star amber" (`--stuic-rating-icon-color`) |
26
+ | `icon` | `string` | star | Raw svg/html string of the filled symbol (e.g. an `@marianmeres/icons-fns` result); must draw with `currentColor` |
27
+ | `iconEmpty` | `string` | = `icon` | Raw svg/html string of the empty symbol (defaults to the same icon, muted — pass an outline for the outline-empty look) |
28
+ | `label` | `string` | `"Rating"` | Accessible name of the group (via `t("rating")` by default); in `readonly` it prefixes the value announcement |
29
+ | `name` | `string` | - | Form field name of the hidden input (input mode) |
30
+ | `required` | `boolean` | `false` | Require a non-zero value — enforced by the built-in validator (hidden inputs skip native constraint validation) |
31
+ | `t` | `TranslateFn` | English | i18n translate function (see below) |
32
+ | `onchange` | `(value: number) => void` | - | Fires when the user changes the value (click, keyboard, clear) |
33
+ | `validate` | `boolean \| ValidateOptions` | - | Validation (stuic `validate` action): omitted/`true` = on, `false` = off, object = options (`customValidator`, …) |
34
+ | `setValidationResult` | `(res: ValidationResult) => void` | - | Receives every validation result |
35
+ | `unstyled` | `boolean` | `false` | Skip all default styling |
36
+ | `class` | `string` | - | Additional CSS classes (merged via twMerge) |
37
+ | `classItem` | `string` | - | Class for every symbol wrapper |
38
+ | `classIcon` | `string` | - | Class for the icon stack inside each symbol |
39
+ | `el` | `HTMLDivElement` | - | Root element reference (bindable) |
40
+ | `inputEl` | `HTMLInputElement` | - | Hidden input reference (bindable; input mode) |
41
+
42
+ Other attributes (`id`, `style`, `data-*`, …) are passed to the root `div`.
43
+ `onkeydown` and `onpointerleave` are reserved by the component.
44
+
45
+ ### Imperative API (via `bind:this`)
46
+
47
+ | Method | Description |
48
+ | ------------------- | -------------------------------------------------------------------- |
49
+ | `validate()` | Trigger validation now; returns the `ValidationResult` |
50
+ | `clearValidation()` | Clear the stored result and the hidden input's custom validity |
51
+ | `getValidation()` | The last validation result (also reported via `setValidationResult`) |
52
+
53
+ ## Usage
54
+
55
+ ### Input
56
+
57
+ ```svelte
58
+ <script lang="ts">
59
+ import { Rating } from "@marianmeres/stuic";
60
+
61
+ let value = $state(0);
62
+ </script>
63
+
64
+ <Rating bind:value />
65
+ <Rating bind:value allowHalf />
66
+ <Rating bind:value max={10} size="sm" />
67
+ <Rating bind:value allowClear={false} onchange={(v) => console.log("rated", v)} />
68
+ ```
69
+
70
+ ### Display
71
+
72
+ ```svelte
73
+ <div class="flex items-center gap-2">
74
+ <Rating value={4.2} readonly label="Average rating" />
75
+ <span>4.2</span>
76
+ <span class="text-neutral-500">(128 reviews)</span>
77
+ </div>
78
+ ```
79
+
80
+ ### In a form
81
+
82
+ The hidden input submits `name=value`. `required` is enforced by the built-in
83
+ validator; call `validate()` on submit so a never-touched field reports as well.
84
+
85
+ ```svelte
86
+ <script lang="ts">
87
+ import { Rating, type ValidationResult } from "@marianmeres/stuic";
88
+
89
+ let rating: Rating;
90
+ let validation: ValidationResult | undefined = $state();
91
+
92
+ function onsubmit(e: SubmitEvent) {
93
+ if (!rating.validate()?.valid) e.preventDefault();
94
+ }
95
+ </script>
96
+
97
+ <form {onsubmit}>
98
+ <Rating
99
+ bind:this={rating}
100
+ name="score"
101
+ required
102
+ setValidationResult={(r) => (validation = r)}
103
+ />
104
+ {#if validation && !validation.valid}
105
+ <p class="text-red-600">{validation.message}</p>
106
+ {/if}
107
+ <button>Submit</button>
108
+ </form>
109
+ ```
110
+
111
+ Custom rules go through the action's `customValidator` (it receives the value as a
112
+ string; the built-in `required` check runs first):
113
+
114
+ ```svelte
115
+ <Rating
116
+ name="score"
117
+ validate={{ customValidator: (v) => (Number(v) < 2 ? "Be kinder" : "") }}
118
+ />
119
+ ```
120
+
121
+ ### Custom symbols
122
+
123
+ ```svelte
124
+ <script lang="ts">
125
+ import { Rating, iconStar } from "@marianmeres/stuic";
126
+ import { iconBsHeartFill } from "@marianmeres/icons-fns/bootstrap/iconBsHeartFill.js";
127
+ import { iconBsHeart } from "@marianmeres/icons-fns/bootstrap/iconBsHeart.js";
128
+ </script>
129
+
130
+ <!-- outline when empty, filled star when set -->
131
+ <Rating value={3.5} readonly iconEmpty={iconStar()} />
132
+
133
+ <!-- hearts -->
134
+ <Rating
135
+ bind:value
136
+ icon={iconBsHeartFill()}
137
+ iconEmpty={iconBsHeart()}
138
+ intent="destructive"
139
+ />
140
+ ```
141
+
142
+ Fill-based icons work best: the filled layer is clipped horizontally to the value, so
143
+ the symbol has to be drawn with `currentColor` (stroke-only icons render as a colored
144
+ outline — fine as `iconEmpty`, thin as `icon`).
145
+
146
+ ### i18n
147
+
148
+ Built-in English; bundled Slovak (`RATING_MESSAGES_SK`) is opt-in. Localized: the
149
+ group label (`rating`), the per-symbol radio / display announcement (`value_of_max`,
150
+ "{value} of {max} stars") and the required message (`required`). With non-star
151
+ symbols override `value_of_max` ("{value} of {max} hearts").
152
+
153
+ ```svelte
154
+ <script lang="ts">
155
+ import { Rating, createRatingT, RATING_MESSAGES_SK } from "@marianmeres/stuic";
156
+ const t = createRatingT(RATING_MESSAGES_SK);
157
+ </script>
158
+
159
+ <Rating bind:value {t} />
160
+ ```
161
+
162
+ ## Accessibility
163
+
164
+ - Input mode is a `role="radiogroup"` (named by `label` / `t("rating")`) of
165
+ `role="radio"` buttons, one per symbol (two per symbol with `allowHalf`), each
166
+ labeled "N of M stars" and `aria-checked` when selected. `aria-required`,
167
+ `aria-disabled` and `aria-invalid` (after a failed validation) are reflected on
168
+ the group.
169
+ - Roving tabindex: the selected symbol is the single tab stop (the first one when
170
+ nothing is selected).
171
+ - Keyboard: `→` / `↑` +1 step, `←` / `↓` −1 step (never below one step), `Home` /
172
+ `End` first / last, `Delete` / `Backspace` clear (when `allowClear`), `Space` /
173
+ `Enter` select the focused symbol (no toggle — clearing by re-clicking is
174
+ pointer-only, like native radios).
175
+ - Display mode is a `role="img"` with the value announcement as its label; the
176
+ icons themselves are always `aria-hidden`.
177
+ - The hover preview is pointer-only and never changes the value.
178
+
179
+ ## CSS Variables
180
+
181
+ | Variable | Default | Description |
182
+ | --------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------- |
183
+ | `--stuic-rating-size` | unset (`sm` 1.125rem / `md` 1.5rem / `lg` 2rem) | Symbol size; when set it overrides whichever `size` preset is on |
184
+ | `--stuic-rating-gap` | `0.125rem` | Gap between symbols |
185
+ | `--stuic-rating-icon-color` | `#f59e0b` | Filled symbol color (theme-independent on purpose; `intent` overrides) |
186
+ | `--stuic-rating-icon-color-empty` | `color-mix(in srgb, currentColor 20%, transparent)` | Empty symbol color (a tint of the surrounding text, so it adapts) |
187
+ | `--stuic-rating-icon-scale-hover` | `1.15` | Scale of the hovered symbol |
188
+ | `--stuic-rating-ring-width` | `3px` | Focus ring width |
189
+ | `--stuic-rating-ring-color` | `--stuic-color-ring` | Focus ring color |
190
+ | `--stuic-rating-radius` | `--stuic-radius` | Focus ring radius |
191
+ | `--stuic-rating-transition` | `--stuic-transition` | Fill / hover-scale transition duration |
192
+ | `--stuic-rating-opacity-disabled` | `0.5` | Disabled opacity |
193
+
194
+ `--stuic-rating-fill` (`0%`–`100%`) is **set by the component** inline on every
195
+ symbol wrapper — an output, not an input. It survives `unstyled`, so a fully custom
196
+ presentation can read it (e.g. a Tailwind `before:w-(--stuic-rating-fill)` bar).
197
+
198
+ ## Data Attributes
199
+
200
+ - `data-size` - `"sm" | "md" | "lg"` (root)
201
+ - `data-intent` - `IntentColorKey` (root, when set)
202
+ - `data-readonly` / `data-disabled` - empty attributes (root)
203
+ - `data-hover` - empty attribute (root while a symbol is hovered; and on the hovered symbol wrapper)
204
+ - `data-state` - `"full" | "partial" | "empty"` (symbol wrapper, for the displayed — hovered or actual — value)
205
+ - `data-value` - the zone's value, e.g. `"2.5"` (radio buttons)
206
+ - `data-half` - `"start" | "end"` (radio buttons, with `allowHalf`)
@@ -0,0 +1,355 @@
1
+ <script lang="ts" module>
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ import type { TranslateFn } from "../../types.js";
4
+ import type { IntentColorKey } from "../../utils/design-tokens.js";
5
+ import type {
6
+ ValidateOptions,
7
+ ValidationResult,
8
+ } from "../../actions/validate.svelte.js";
9
+
10
+ export type RatingSize = "sm" | "md" | "lg";
11
+
12
+ /** Fill state of one symbol (its `data-state`). */
13
+ export type RatingItemState = "full" | "partial" | "empty";
14
+
15
+ export interface Props extends Omit<
16
+ HTMLAttributes<HTMLDivElement>,
17
+ | "children"
18
+ | "onchange"
19
+ // Reserved by the rating's own keyboard / hover machinery (compile-time
20
+ // guard against silently clobbering them via the rest spread).
21
+ | "onkeydown"
22
+ | "onpointerleave"
23
+ > {
24
+ /**
25
+ * Current value (bindable), 0 = no rating. Rendered clamped to `[0, max]`;
26
+ * in input mode also snapped to whole (or, with `allowHalf`, half) symbols.
27
+ * Display mode (`readonly`) renders any fraction (4.2 → a 20% fifth star).
28
+ */
29
+ value?: number;
30
+ /** Number of symbols (default 5; integer ≥ 1) */
31
+ max?: number;
32
+ /** Allow half-symbol values (each symbol gets two hit zones) */
33
+ allowHalf?: boolean;
34
+ /**
35
+ * Clicking the currently selected symbol (or pressing Delete / Backspace)
36
+ * resets the value to 0 (default true).
37
+ */
38
+ allowClear?: boolean;
39
+ /**
40
+ * Display variant: no interaction, no form participation, fractional
41
+ * values rendered as-is. The root becomes `role="img"` with an accessible
42
+ * "{value} of {max} stars" label.
43
+ */
44
+ readonly?: boolean;
45
+ /** Disable interaction (the hidden input is disabled too, so nothing submits) */
46
+ disabled?: boolean;
47
+ /** Symbol size preset (default "md"); `--stuic-rating-size` overrides any preset */
48
+ size?: RatingSize;
49
+ /**
50
+ * Semantic color of the filled symbols. Unset (default) uses the fixed
51
+ * "star amber" (`--stuic-rating-icon-color`) regardless of theme.
52
+ */
53
+ intent?: IntentColorKey;
54
+ /**
55
+ * Raw svg/html string of the filled symbol (e.g. an `@marianmeres/icons-fns`
56
+ * render result). Default: a filled star. Fill-based icons work best — the
57
+ * fill layer is clipped to the value, so the symbol must draw with
58
+ * `currentColor`.
59
+ */
60
+ icon?: string;
61
+ /** Raw svg/html string of the empty symbol (default: same as `icon`, muted) */
62
+ iconEmpty?: string;
63
+ /**
64
+ * Accessible name of the control (default `t("rating")`, "Rating"). In
65
+ * `readonly` mode it prefixes the value announcement.
66
+ */
67
+ label?: string;
68
+ /** Form field name (hidden input; input mode only) */
69
+ name?: string;
70
+ /** Require a non-zero value (enforced by the built-in validator) */
71
+ required?: boolean;
72
+ /** i18n translate function (see `createRatingT`) */
73
+ t?: TranslateFn;
74
+ /** Fires when the user changes the value (click, keyboard, clear) */
75
+ onchange?: (value: number) => void;
76
+ validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
77
+ setValidationResult?: (res: ValidationResult) => void;
78
+ /** Skip all default styling */
79
+ unstyled?: boolean;
80
+ /** Additional CSS classes */
81
+ class?: string;
82
+ /** Class for every symbol wrapper */
83
+ classItem?: string;
84
+ /** Class for the icon stack inside each symbol */
85
+ classIcon?: string;
86
+ /** Bindable root element reference */
87
+ el?: HTMLDivElement;
88
+ /** Bindable hidden input reference (input mode only) */
89
+ inputEl?: HTMLInputElement;
90
+ }
91
+ </script>
92
+
93
+ <script lang="ts">
94
+ import { twMerge } from "../../utils/tw-merge.js";
95
+ import { validate as validateAction } from "../../actions/validate.svelte.js";
96
+ import { iconStarFill } from "../../icons/index.js";
97
+ import { t_default } from "./i18n.js";
98
+
99
+ let {
100
+ value = $bindable(0),
101
+ max = 5,
102
+ allowHalf = false,
103
+ allowClear = true,
104
+ readonly = false,
105
+ disabled = false,
106
+ size = "md",
107
+ intent,
108
+ icon,
109
+ iconEmpty,
110
+ label,
111
+ name,
112
+ required = false,
113
+ t = t_default,
114
+ onchange,
115
+ validate: validateProp,
116
+ setValidationResult,
117
+ unstyled = false,
118
+ class: classProp,
119
+ classItem: classItemProp,
120
+ classIcon: classIconProp,
121
+ el = $bindable(),
122
+ inputEl = $bindable(),
123
+ ...rest
124
+ }: Props = $props();
125
+
126
+ const DEFAULT_ICON = iconStarFill();
127
+
128
+ let _max = $derived(Number.isFinite(max) && max >= 1 ? Math.floor(max) : 5);
129
+ let _step = $derived(allowHalf ? 0.5 : 1);
130
+
131
+ // The rendered (and submitted) value: clamped, and snapped to the step grid
132
+ // in input mode. External writes are NOT normalized back into the binding —
133
+ // what the consumer wrote stays theirs until the user interacts.
134
+ let _value = $derived.by(() => {
135
+ if (!Number.isFinite(value)) return 0;
136
+ const clamped = Math.min(Math.max(0, value), _max);
137
+ return readonly ? clamped : Math.round(clamped / _step) * _step;
138
+ });
139
+
140
+ let _interactive = $derived(!readonly && !disabled);
141
+
142
+ // Pointer hover preview (input mode only)
143
+ let _hover: number | null = $state(null);
144
+ let _display = $derived(_hover ?? _value);
145
+
146
+ let _icon = $derived(icon || DEFAULT_ICON);
147
+ let _iconEmpty = $derived(iconEmpty || _icon);
148
+
149
+ let _items = $derived(Array.from({ length: _max }, (_, i) => i + 1));
150
+
151
+ // Hit zones per symbol: one (whole) or two (half + whole)
152
+ function zonesOf(i: number): number[] {
153
+ return allowHalf ? [i - 0.5, i] : [i];
154
+ }
155
+
156
+ /** 0..1 fill ratio of the i-th (1-based) symbol for the displayed value */
157
+ function fillOf(i: number): number {
158
+ return Math.min(Math.max(_display - (i - 1), 0), 1);
159
+ }
160
+
161
+ function stateOf(fill: number): RatingItemState {
162
+ if (fill >= 1) return "full";
163
+ if (fill <= 0) return "empty";
164
+ return "partial";
165
+ }
166
+
167
+ // Format numbers without float noise (2.5, not 2.5000000000000004)
168
+ function fmt(n: number): string {
169
+ return String(Math.round(n * 100) / 100);
170
+ }
171
+
172
+ function valueText(v: number): string {
173
+ return t("value_of_max", { value: fmt(v), max: _max });
174
+ }
175
+
176
+ let _class = $derived(unstyled ? classProp : twMerge("stuic-rating", classProp));
177
+ let _classItem = $derived(
178
+ unstyled ? classItemProp : twMerge("stuic-rating-item", classItemProp)
179
+ );
180
+ let _classIcon = $derived(
181
+ unstyled ? classIconProp : twMerge("stuic-rating-icon", classIconProp)
182
+ );
183
+
184
+ // Roving tabindex: the selected zone is the tab stop (the first one when
185
+ // nothing is selected), arrows move between the others.
186
+ function tabindexOf(v: number, first: boolean): 0 | -1 {
187
+ if (_value === v) return 0;
188
+ return _value === 0 && first ? 0 : -1;
189
+ }
190
+
191
+ function commit(v: number, toggle = false) {
192
+ if (!_interactive) return;
193
+ const next = toggle && allowClear && v === _value ? 0 : v;
194
+ if (next === _value) return;
195
+ value = next;
196
+ // The hidden input's DOM value is only synced on the next flush, but the
197
+ // validate action reads it synchronously in its "change" listener — so
198
+ // write it by hand before dispatching. Svelte re-applies the same string
199
+ // later (a no-op).
200
+ if (inputEl) {
201
+ inputEl.value = String(next);
202
+ inputEl.dispatchEvent(new Event("change", { bubbles: true }));
203
+ }
204
+ onchange?.(next);
205
+ }
206
+
207
+ function focusZone(v: number) {
208
+ el?.querySelector<HTMLButtonElement>(`[data-value="${fmt(v)}"]`)?.focus();
209
+ }
210
+
211
+ function onkeydown(e: KeyboardEvent) {
212
+ if (!_interactive) return;
213
+ let next: number;
214
+ switch (e.key) {
215
+ case "ArrowRight":
216
+ case "ArrowUp":
217
+ next = Math.min(_max, _value + _step);
218
+ break;
219
+ case "ArrowLeft":
220
+ case "ArrowDown":
221
+ next = Math.max(_step, _value - _step);
222
+ break;
223
+ case "Home":
224
+ next = _step;
225
+ break;
226
+ case "End":
227
+ next = _max;
228
+ break;
229
+ case "Delete":
230
+ case "Backspace":
231
+ if (!allowClear) return;
232
+ next = 0;
233
+ break;
234
+ default:
235
+ return;
236
+ }
237
+ e.preventDefault();
238
+ commit(next);
239
+ if (next > 0) focusZone(next);
240
+ }
241
+
242
+ //
243
+ let _doValidate: (() => void) | undefined = $state();
244
+ // Local copy of the last validation result so getValidation() works even
245
+ // when no external setValidationResult was provided.
246
+ let _validation: ValidationResult | undefined = $state();
247
+
248
+ /** Trigger validation now. Reaches the parent via `setValidationResult`. */
249
+ export function validate(): ValidationResult | undefined {
250
+ _doValidate?.();
251
+ return _validation;
252
+ }
253
+
254
+ /** Clear the inline validation message and reset `setCustomValidity`. */
255
+ export function clearValidation(): void {
256
+ _validation = undefined;
257
+ inputEl?.setCustomValidity?.("");
258
+ }
259
+
260
+ /** Current validation state. */
261
+ export function getValidation(): ValidationResult | undefined {
262
+ return _validation;
263
+ }
264
+ </script>
265
+
266
+ <div
267
+ bind:this={el}
268
+ class={_class}
269
+ role={readonly ? "img" : "radiogroup"}
270
+ aria-label={readonly
271
+ ? [label, valueText(_value)].filter(Boolean).join(", ")
272
+ : label || t("rating", null, "Rating")}
273
+ aria-required={!readonly && required ? "true" : undefined}
274
+ aria-disabled={!readonly && disabled ? "true" : undefined}
275
+ aria-invalid={_validation && !_validation.valid ? "true" : undefined}
276
+ data-size={!unstyled ? size : undefined}
277
+ data-intent={!unstyled ? intent : undefined}
278
+ data-readonly={!unstyled && readonly ? "" : undefined}
279
+ data-disabled={!unstyled && disabled ? "" : undefined}
280
+ data-hover={!unstyled && _hover !== null ? "" : undefined}
281
+ {onkeydown}
282
+ onpointerleave={() => (_hover = null)}
283
+ {...rest}
284
+ >
285
+ {#each _items as i (i)}
286
+ {@const fill = fillOf(i)}
287
+ <span
288
+ class={_classItem}
289
+ style="--stuic-rating-fill: {Math.round(fill * 100)}%"
290
+ data-state={!unstyled ? stateOf(fill) : undefined}
291
+ data-hover={!unstyled && _hover !== null && Math.ceil(_hover) === i
292
+ ? ""
293
+ : undefined}
294
+ >
295
+ <span class={_classIcon} aria-hidden="true">
296
+ <span class={unstyled ? undefined : "stuic-rating-icon-empty"}>
297
+ {@html _iconEmpty}
298
+ </span>
299
+ <span class={unstyled ? undefined : "stuic-rating-icon-fill"}>
300
+ {@html _icon}
301
+ </span>
302
+ </span>
303
+ {#if !readonly}
304
+ {#each zonesOf(i) as v, zi (v)}
305
+ <button
306
+ type="button"
307
+ role="radio"
308
+ class={unstyled ? undefined : "stuic-rating-zone"}
309
+ data-value={fmt(v)}
310
+ data-half={!unstyled && allowHalf ? (zi === 0 ? "start" : "end") : undefined}
311
+ aria-checked={_value === v}
312
+ aria-label={valueText(v)}
313
+ tabindex={tabindexOf(v, i === 1 && zi === 0)}
314
+ {disabled}
315
+ onclick={(e) => commit(v, e.detail !== 0)}
316
+ onpointerenter={() => {
317
+ if (_interactive) _hover = v;
318
+ }}
319
+ ></button>
320
+ {/each}
321
+ {/if}
322
+ </span>
323
+ {/each}
324
+ {#if !readonly}
325
+ <input
326
+ bind:this={inputEl}
327
+ type="hidden"
328
+ {name}
329
+ value={_value}
330
+ {disabled}
331
+ use:validateAction={() => {
332
+ const customOpts =
333
+ typeof validateProp === "object" && validateProp ? validateProp : {};
334
+ const userValidator = customOpts.customValidator;
335
+ return {
336
+ enabled: validateProp !== false,
337
+ ...customOpts,
338
+ // Hidden inputs are barred from native constraint validation, so
339
+ // `required` is enforced here, then the consumer's validator runs.
340
+ customValidator(val, ctx, input) {
341
+ if (required && !(Number(val) > 0)) {
342
+ return t("required", null, "Please select a rating");
343
+ }
344
+ return userValidator?.(val, ctx, input) || "";
345
+ },
346
+ setValidationResult: (res) => {
347
+ _validation = res;
348
+ setValidationResult?.(res);
349
+ },
350
+ setDoValidate: (fn) => (_doValidate = fn),
351
+ };
352
+ }}
353
+ />
354
+ {/if}
355
+ </div>
@@ -0,0 +1,82 @@
1
+ import type { HTMLAttributes } from "svelte/elements";
2
+ import type { TranslateFn } from "../../types.js";
3
+ import type { IntentColorKey } from "../../utils/design-tokens.js";
4
+ import type { ValidateOptions, ValidationResult } from "../../actions/validate.svelte.js";
5
+ export type RatingSize = "sm" | "md" | "lg";
6
+ /** Fill state of one symbol (its `data-state`). */
7
+ export type RatingItemState = "full" | "partial" | "empty";
8
+ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children" | "onchange" | "onkeydown" | "onpointerleave"> {
9
+ /**
10
+ * Current value (bindable), 0 = no rating. Rendered clamped to `[0, max]`;
11
+ * in input mode also snapped to whole (or, with `allowHalf`, half) symbols.
12
+ * Display mode (`readonly`) renders any fraction (4.2 → a 20% fifth star).
13
+ */
14
+ value?: number;
15
+ /** Number of symbols (default 5; integer ≥ 1) */
16
+ max?: number;
17
+ /** Allow half-symbol values (each symbol gets two hit zones) */
18
+ allowHalf?: boolean;
19
+ /**
20
+ * Clicking the currently selected symbol (or pressing Delete / Backspace)
21
+ * resets the value to 0 (default true).
22
+ */
23
+ allowClear?: boolean;
24
+ /**
25
+ * Display variant: no interaction, no form participation, fractional
26
+ * values rendered as-is. The root becomes `role="img"` with an accessible
27
+ * "{value} of {max} stars" label.
28
+ */
29
+ readonly?: boolean;
30
+ /** Disable interaction (the hidden input is disabled too, so nothing submits) */
31
+ disabled?: boolean;
32
+ /** Symbol size preset (default "md"); `--stuic-rating-size` overrides any preset */
33
+ size?: RatingSize;
34
+ /**
35
+ * Semantic color of the filled symbols. Unset (default) uses the fixed
36
+ * "star amber" (`--stuic-rating-icon-color`) regardless of theme.
37
+ */
38
+ intent?: IntentColorKey;
39
+ /**
40
+ * Raw svg/html string of the filled symbol (e.g. an `@marianmeres/icons-fns`
41
+ * render result). Default: a filled star. Fill-based icons work best — the
42
+ * fill layer is clipped to the value, so the symbol must draw with
43
+ * `currentColor`.
44
+ */
45
+ icon?: string;
46
+ /** Raw svg/html string of the empty symbol (default: same as `icon`, muted) */
47
+ iconEmpty?: string;
48
+ /**
49
+ * Accessible name of the control (default `t("rating")`, "Rating"). In
50
+ * `readonly` mode it prefixes the value announcement.
51
+ */
52
+ label?: string;
53
+ /** Form field name (hidden input; input mode only) */
54
+ name?: string;
55
+ /** Require a non-zero value (enforced by the built-in validator) */
56
+ required?: boolean;
57
+ /** i18n translate function (see `createRatingT`) */
58
+ t?: TranslateFn;
59
+ /** Fires when the user changes the value (click, keyboard, clear) */
60
+ onchange?: (value: number) => void;
61
+ validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
62
+ setValidationResult?: (res: ValidationResult) => void;
63
+ /** Skip all default styling */
64
+ unstyled?: boolean;
65
+ /** Additional CSS classes */
66
+ class?: string;
67
+ /** Class for every symbol wrapper */
68
+ classItem?: string;
69
+ /** Class for the icon stack inside each symbol */
70
+ classIcon?: string;
71
+ /** Bindable root element reference */
72
+ el?: HTMLDivElement;
73
+ /** Bindable hidden input reference (input mode only) */
74
+ inputEl?: HTMLInputElement;
75
+ }
76
+ declare const Rating: import("svelte").Component<Props, {
77
+ validate: () => ValidationResult | undefined;
78
+ clearValidation: () => void;
79
+ getValidation: () => ValidationResult | undefined;
80
+ }, "el" | "value" | "inputEl">;
81
+ type Rating = ReturnType<typeof Rating>;
82
+ export default Rating;
@@ -0,0 +1,17 @@
1
+ import type { RatingMessages } from "./i18n.js";
2
+ /**
3
+ * Slovak message catalog for `Rating`. Opt-in — English stays the built-in default,
4
+ * and this module is only pulled into a bundle when it is actually imported (the
5
+ * component itself never references it).
6
+ *
7
+ * @example
8
+ * ```svelte
9
+ * <script>
10
+ * import { Rating, createRatingT, RATING_MESSAGES_SK } from "@marianmeres/stuic";
11
+ * const t = createRatingT(RATING_MESSAGES_SK);
12
+ * </script>
13
+ *
14
+ * <Rating bind:value {t} />
15
+ * ```
16
+ */
17
+ export declare const RATING_MESSAGES_SK: RatingMessages;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Slovak message catalog for `Rating`. Opt-in — English stays the built-in default,
3
+ * and this module is only pulled into a bundle when it is actually imported (the
4
+ * component itself never references it).
5
+ *
6
+ * @example
7
+ * ```svelte
8
+ * <script>
9
+ * import { Rating, createRatingT, RATING_MESSAGES_SK } from "@marianmeres/stuic";
10
+ * const t = createRatingT(RATING_MESSAGES_SK);
11
+ * </script>
12
+ *
13
+ * <Rating bind:value {t} />
14
+ * ```
15
+ */
16
+ export const RATING_MESSAGES_SK = {
17
+ rating: "Hodnotenie",
18
+ // "z {max}" governs the genitive plural, so the noun is right for any count
19
+ value_of_max: "{value} z {max} hviezdičiek",
20
+ required: "Prosím, vyberte hodnotenie",
21
+ };