@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.
- package/AGENTS.md +2 -2
- package/API.md +56 -2
- package/README.md +14 -2
- package/dist/actions/resizable-width.svelte.d.ts +14 -28
- package/dist/actions/resizable-width.svelte.js +16 -171
- package/dist/attachments/index.d.ts +1 -0
- package/dist/attachments/index.js +1 -0
- package/dist/attachments/resizable.d.ts +113 -0
- package/dist/attachments/resizable.fixture.svelte +53 -0
- package/dist/attachments/resizable.fixture.svelte.d.ts +10 -0
- package/dist/attachments/resizable.js +295 -0
- package/dist/components/RangeSlider/README.md +291 -0
- package/dist/components/RangeSlider/RangeSlider.svelte +763 -0
- package/dist/components/RangeSlider/RangeSlider.svelte.d.ts +130 -0
- package/dist/components/RangeSlider/i18n-sk.d.ts +17 -0
- package/dist/components/RangeSlider/i18n-sk.js +19 -0
- package/dist/components/RangeSlider/i18n.d.ts +33 -0
- package/dist/components/RangeSlider/i18n.js +41 -0
- package/dist/components/RangeSlider/index.css +430 -0
- package/dist/components/RangeSlider/index.d.ts +3 -0
- package/dist/components/RangeSlider/index.js +3 -0
- package/dist/components/Rating/README.md +206 -0
- package/dist/components/Rating/Rating.svelte +355 -0
- package/dist/components/Rating/Rating.svelte.d.ts +82 -0
- package/dist/components/Rating/i18n-sk.d.ts +17 -0
- package/dist/components/Rating/i18n-sk.js +21 -0
- package/dist/components/Rating/i18n.d.ts +34 -0
- package/dist/components/Rating/i18n.js +42 -0
- package/dist/components/Rating/index.css +170 -0
- package/dist/components/Rating/index.d.ts +3 -0
- package/dist/components/Rating/index.js +3 -0
- package/dist/components/SplitPane/README.md +169 -0
- package/dist/components/SplitPane/SplitPane.svelte +202 -0
- package/dist/components/SplitPane/SplitPane.svelte.d.ts +67 -0
- package/dist/components/SplitPane/i18n-sk.d.ts +17 -0
- package/dist/components/SplitPane/i18n-sk.js +18 -0
- package/dist/components/SplitPane/i18n.d.ts +31 -0
- package/dist/components/SplitPane/i18n.js +39 -0
- package/dist/components/SplitPane/index.css +153 -0
- package/dist/components/SplitPane/index.d.ts +3 -0
- package/dist/components/SplitPane/index.js +3 -0
- package/dist/components/WithSidePanel/README.md +19 -16
- package/dist/icons/index.d.ts +2 -0
- package/dist/icons/index.js +3 -0
- package/dist/index.css +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/docs/{maybe-todo.md → _archive/maybe-todo.md} +18 -7
- package/docs/architecture.md +1 -1
- package/docs/domains/actions.md +18 -18
- package/docs/domains/attachments.md +72 -9
- package/docs/domains/components.md +175 -32
- 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
|
+
};
|