@marianmeres/stuic 3.164.0 → 3.166.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/API.md +66 -11
- package/dist/components/Circle/Circle.svelte +27 -12
- package/dist/components/Circle/Circle.svelte.d.ts +12 -4
- package/dist/components/Circle/README.md +54 -18
- package/dist/components/Circle/index.d.ts +1 -0
- package/dist/components/Circle/index.js +1 -0
- package/dist/components/FieldsBuilder/FieldsBuilder.svelte +140 -29
- package/dist/components/FieldsBuilder/README.md +62 -9
- package/dist/components/FieldsBuilder/i18n-sk.js +1 -0
- package/dist/components/FieldsBuilder/i18n.d.ts +1 -0
- package/dist/components/FieldsBuilder/i18n.js +1 -0
- package/dist/components/FieldsBuilder/index.d.ts +1 -1
- package/dist/components/FieldsBuilder/types.d.ts +41 -5
- package/dist/components/FieldsBuilder/utils.d.ts +4 -2
- package/dist/components/FieldsBuilder/utils.js +14 -1
- package/dist/components/Input/FieldSwitch.svelte +46 -0
- package/dist/components/Input/FieldSwitch.svelte.d.ts +17 -0
- package/dist/components/Input/README.md +32 -0
- package/dist/components/Input/_internal/InputWrap.svelte +5 -0
- package/dist/components/Switch/README.md +28 -1
- package/dist/components/Switch/Switch.svelte +29 -1
- package/dist/components/Switch/Switch.svelte.d.ts +6 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/docs/domains/utils.md +1 -1
- package/package.json +1 -1
package/API.md
CHANGED
|
@@ -582,11 +582,13 @@ Closeable message banner with intent styling.
|
|
|
582
582
|
|
|
583
583
|
#### `Progress`
|
|
584
584
|
|
|
585
|
-
Progress bar.
|
|
585
|
+
Progress bar or circle.
|
|
586
586
|
|
|
587
|
-
| Prop
|
|
588
|
-
|
|
|
589
|
-
| `
|
|
587
|
+
| Prop | Type | Default | Description |
|
|
588
|
+
| ---------- | ------------------- | ------- | --------------------------------- |
|
|
589
|
+
| `progress` | `number` | `0` | Current progress (0-100, clamped) |
|
|
590
|
+
| `type` | `"bar" \| "circle"` | `"bar"` | Rendering variant |
|
|
591
|
+
| `classBar` | `string` | - | Classes for the inner fill (bar) |
|
|
590
592
|
|
|
591
593
|
#### `Spinner`
|
|
592
594
|
|
|
@@ -1206,12 +1208,26 @@ Responsive wrapper around Book that intelligently switches between book mode (du
|
|
|
1206
1208
|
|
|
1207
1209
|
#### `Circle`
|
|
1208
1210
|
|
|
1209
|
-
SVG-based circular progress indicator
|
|
1211
|
+
SVG-based circular progress indicator (a Svelte wrapper around the [`svgCircle`](#svgcircleoptions)
|
|
1212
|
+
utility). The svg fills its container over a fixed `100x100` viewBox, so the container
|
|
1213
|
+
sets the size and `strokeWidth` is in viewBox units. The stroke is `currentColor`.
|
|
1214
|
+
|
|
1215
|
+
| Prop | Type | Default | Description |
|
|
1216
|
+
| ----------------------- | --------- | ------- | ---------------------------------------------------------------- |
|
|
1217
|
+
| `completeness` | `number` | `1` | Progress from 0 to 1 (clamped) |
|
|
1218
|
+
| `strokeWidth` | `number` | `10` | Stroke width in viewBox units (radius = `50 - strokeWidth / 2`) |
|
|
1219
|
+
| `strokeWidthRatio` | `number` | `0` | Caps `strokeWidth` at this fraction of the radius (`0` = no cap) |
|
|
1220
|
+
| `bgStrokeColor` | `string` | - | CSS color of the background ring behind the arc |
|
|
1221
|
+
| `roundedEdges` | `boolean` | `true` | Rounded (vs butt) stroke line caps |
|
|
1222
|
+
| `rotate` | `number` | `0` | Rotation in degrees (arc starts at 3 o'clock; `-90` = top) |
|
|
1223
|
+
| `animateCompletenessMs` | `number` | `0` | CSS transition duration on `stroke-dashoffset` (ms) |
|
|
1224
|
+
| `class` / `style` | `string` | - | Container div class / inline style |
|
|
1225
|
+
| `circleClass` | `string` | - | Classes for the `<svg>` element |
|
|
1226
|
+
| `circleStyle` | `string` | - | Inline styles for the `<circle>` element |
|
|
1210
1227
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
| `strokeWidth` | `number` | `8` | Stroke width |
|
|
1228
|
+
```svelte
|
|
1229
|
+
<Circle completeness={0.75} rotate={-90} bgStrokeColor="#e5e5e5" class="size-16" />
|
|
1230
|
+
```
|
|
1215
1231
|
|
|
1216
1232
|
#### `H`
|
|
1217
1233
|
|
|
@@ -1935,9 +1951,48 @@ Generate deterministic avatar colors from a name string.
|
|
|
1935
1951
|
|
|
1936
1952
|
HSL color generation.
|
|
1937
1953
|
|
|
1938
|
-
#### `svgCircle(
|
|
1954
|
+
#### `svgCircle(options?)`
|
|
1955
|
+
|
|
1956
|
+
Build an SVG ring as a plain DOM node, with setters to update it in place. Framework
|
|
1957
|
+
agnostic - it returns the element, mounting it is up to the caller. Used by `Circle` and
|
|
1958
|
+
`Progress` internally.
|
|
1959
|
+
|
|
1960
|
+
The svg is `width/height: 100%` over a fixed `100x100` viewBox, so the container decides
|
|
1961
|
+
the rendered size; `strokeWidth` is in viewBox units and the radius is derived from it
|
|
1962
|
+
(`50 - strokeWidth / 2`).
|
|
1963
|
+
|
|
1964
|
+
**Options** (all optional):
|
|
1965
|
+
|
|
1966
|
+
| Option | Type | Default | Description |
|
|
1967
|
+
| ------------------ | --------- | ------- | ---------------------------------------------------------------- |
|
|
1968
|
+
| `completeness` | `number` | `1` | Arc length from 0 to 1 (clamped) |
|
|
1969
|
+
| `strokeWidth` | `number` | `10` | Stroke width in viewBox units |
|
|
1970
|
+
| `strokeWidthRatio` | `number` | `0` | Caps `strokeWidth` at this fraction of the radius (`0` = no cap) |
|
|
1971
|
+
| `rotate` | `number` | `0` | Rotation in degrees, modulo 360 (arc starts at 3 o'clock) |
|
|
1972
|
+
| `roundedEdges` | `boolean` | `true` | Rounded (vs butt) line caps |
|
|
1973
|
+
| `bgStrokeColor` | `string` | - | CSS color of a full background ring behind the arc |
|
|
1974
|
+
| `class` | `string` | - | Classes for the `<svg>` element |
|
|
1975
|
+
| `circleStyle` | `string` | - | Inline styles for the `<circle>` element |
|
|
1976
|
+
| `radius` | `number` | - | Currently ignored (derived from the viewBox and stroke width) |
|
|
1939
1977
|
|
|
1940
|
-
|
|
1978
|
+
**Returns:** `{ svg: SVGSVGElement, setCompleteness(v: number): void, setRotate(deg: number): void }`
|
|
1979
|
+
|
|
1980
|
+
The setters mutate the existing element - reach for them instead of rebuilding when only
|
|
1981
|
+
the progress or rotation changes.
|
|
1982
|
+
|
|
1983
|
+
```ts
|
|
1984
|
+
import { svgCircle } from "@marianmeres/stuic";
|
|
1985
|
+
|
|
1986
|
+
const { svg, setCompleteness } = svgCircle({
|
|
1987
|
+
completeness: 0.75,
|
|
1988
|
+
strokeWidth: 8,
|
|
1989
|
+
rotate: -90,
|
|
1990
|
+
bgStrokeColor: "#e5e5e5",
|
|
1991
|
+
});
|
|
1992
|
+
container.appendChild(svg);
|
|
1993
|
+
|
|
1994
|
+
setCompleteness(0.9); // no rebuild
|
|
1995
|
+
```
|
|
1941
1996
|
|
|
1942
1997
|
#### `oscillate(min, max, step)`
|
|
1943
1998
|
|
|
@@ -1,5 +1,24 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { SvgCircleOptions } from "../../utils/svg-circle.js";
|
|
3
|
+
|
|
4
|
+
export interface Props extends Partial<SvgCircleOptions> {
|
|
5
|
+
/** Inline styles for the container element */
|
|
6
|
+
style?: string;
|
|
7
|
+
/**
|
|
8
|
+
* CSS classes for the `<svg>` element (forwarded to the helper's `class`
|
|
9
|
+
* option). Since the stroke is `currentColor`, a text color utility here
|
|
10
|
+
* colors the ring.
|
|
11
|
+
*/
|
|
12
|
+
circleClass?: string;
|
|
13
|
+
/** Inline styles for the `<circle>` element */
|
|
14
|
+
circleStyle?: string;
|
|
15
|
+
/** Transition duration in ms on the stroke-dashoffset */
|
|
16
|
+
animateCompletenessMs?: number;
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
1
20
|
<script lang="ts">
|
|
2
|
-
import { svgCircle
|
|
21
|
+
import { svgCircle } from "../../utils/svg-circle.js";
|
|
3
22
|
import { twMerge } from "../../utils/tw-merge.js";
|
|
4
23
|
|
|
5
24
|
let {
|
|
@@ -14,13 +33,7 @@
|
|
|
14
33
|
circleClass,
|
|
15
34
|
circleStyle = "",
|
|
16
35
|
animateCompletenessMs = 0,
|
|
17
|
-
}:
|
|
18
|
-
style?: string;
|
|
19
|
-
circleClass?: string;
|
|
20
|
-
circleStyle?: string;
|
|
21
|
-
// transition duration in ms on the stroke-dashoffset
|
|
22
|
-
animateCompletenessMs?: number;
|
|
23
|
-
} = $props();
|
|
36
|
+
}: Props = $props();
|
|
24
37
|
|
|
25
38
|
let container: HTMLDivElement = $state()!;
|
|
26
39
|
|
|
@@ -42,10 +55,12 @@
|
|
|
42
55
|
);
|
|
43
56
|
|
|
44
57
|
$effect(() => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
};
|
|
58
|
+
// Capture the current instance: `circle` is a derived, so reading it again in the
|
|
59
|
+
// teardown would resolve to the freshly rebuilt svg and leave the previous node
|
|
60
|
+
// behind (stacking one <svg> per structural prop change).
|
|
61
|
+
const { svg } = circle;
|
|
62
|
+
container.appendChild(svg);
|
|
63
|
+
return () => svg.remove();
|
|
49
64
|
});
|
|
50
65
|
|
|
51
66
|
$effect(() => {
|
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import type { SvgCircleOptions } from "../../utils/svg-circle.js";
|
|
2
|
+
export interface Props extends Partial<SvgCircleOptions> {
|
|
3
|
+
/** Inline styles for the container element */
|
|
3
4
|
style?: string;
|
|
5
|
+
/**
|
|
6
|
+
* CSS classes for the `<svg>` element (forwarded to the helper's `class`
|
|
7
|
+
* option). Since the stroke is `currentColor`, a text color utility here
|
|
8
|
+
* colors the ring.
|
|
9
|
+
*/
|
|
4
10
|
circleClass?: string;
|
|
11
|
+
/** Inline styles for the `<circle>` element */
|
|
5
12
|
circleStyle?: string;
|
|
13
|
+
/** Transition duration in ms on the stroke-dashoffset */
|
|
6
14
|
animateCompletenessMs?: number;
|
|
7
|
-
}
|
|
8
|
-
declare const Circle: import("svelte").Component
|
|
15
|
+
}
|
|
16
|
+
declare const Circle: import("svelte").Component<Props, {}, "">;
|
|
9
17
|
type Circle = ReturnType<typeof Circle>;
|
|
10
18
|
export default Circle;
|
|
@@ -1,22 +1,31 @@
|
|
|
1
1
|
# Circle
|
|
2
2
|
|
|
3
|
-
An SVG circle progress indicator with configurable stroke, rotation, and animated
|
|
3
|
+
An SVG circle progress indicator with configurable stroke, rotation, and animated
|
|
4
|
+
transitions. A thin Svelte wrapper around the [`svgCircle`](../../utils/svg-circle.ts)
|
|
5
|
+
utility.
|
|
6
|
+
|
|
7
|
+
The svg is rendered at `width/height: 100%` over a fixed `100x100` viewBox, so **the
|
|
8
|
+
container decides the size** (default `size-6`) and `strokeWidth` is expressed in viewBox
|
|
9
|
+
units - i.e. it scales with the box. The stroke defaults to `currentColor`.
|
|
4
10
|
|
|
5
11
|
## Props
|
|
6
12
|
|
|
7
|
-
| Prop | Type | Default | Description
|
|
8
|
-
| ----------------------- | --------- | ------- |
|
|
9
|
-
| `completeness` | `number` | `1` | Progress
|
|
10
|
-
| `strokeWidth` | `number` | `10` | Stroke width in
|
|
11
|
-
| `
|
|
12
|
-
| `
|
|
13
|
-
| `
|
|
14
|
-
| `
|
|
15
|
-
| `animateCompletenessMs` | `number` | `0` |
|
|
16
|
-
| `class` | `string` | - | CSS for container div
|
|
17
|
-
| `style` | `string` | - | Inline styles for container
|
|
18
|
-
| `circleClass` | `string` | - | CSS for
|
|
19
|
-
| `circleStyle` | `string` | - | Inline styles for circle
|
|
13
|
+
| Prop | Type | Default | Description |
|
|
14
|
+
| ----------------------- | --------- | ------- | ---------------------------------------------------------------------- |
|
|
15
|
+
| `completeness` | `number` | `1` | Progress from 0 to 1 (clamped) |
|
|
16
|
+
| `strokeWidth` | `number` | `10` | Stroke width in viewBox units (radius = `50 - strokeWidth / 2`) |
|
|
17
|
+
| `strokeWidthRatio` | `number` | `0` | Caps `strokeWidth` at this fraction of the radius; `0` means no cap |
|
|
18
|
+
| `bgStrokeColor` | `string` | - | Any CSS color; adds a full background ring behind the arc |
|
|
19
|
+
| `roundedEdges` | `boolean` | `true` | Rounded (vs butt) stroke line caps |
|
|
20
|
+
| `rotate` | `number` | `0` | Rotation in degrees; the arc starts at 3 o'clock, so use `-90` for top |
|
|
21
|
+
| `animateCompletenessMs` | `number` | `0` | CSS transition duration on `stroke-dashoffset` (ms) |
|
|
22
|
+
| `class` | `string` | - | CSS classes for the container div |
|
|
23
|
+
| `style` | `string` | - | Inline styles for the container div |
|
|
24
|
+
| `circleClass` | `string` | - | CSS classes for the `<svg>` element |
|
|
25
|
+
| `circleStyle` | `string` | - | Inline styles for the `<circle>` element |
|
|
26
|
+
|
|
27
|
+
`completeness` and `rotate` are applied through the helper's setters, so changing them
|
|
28
|
+
only rewrites two attributes. Every other prop rebuilds the svg.
|
|
20
29
|
|
|
21
30
|
## Usage
|
|
22
31
|
|
|
@@ -24,17 +33,17 @@ An SVG circle progress indicator with configurable stroke, rotation, and animate
|
|
|
24
33
|
|
|
25
34
|
```svelte
|
|
26
35
|
<script lang="ts">
|
|
27
|
-
import { Circle } from "stuic";
|
|
36
|
+
import { Circle } from "@marianmeres/stuic";
|
|
28
37
|
</script>
|
|
29
38
|
|
|
30
|
-
<Circle completeness={0.75} class="size-16" />
|
|
39
|
+
<Circle completeness={0.75} rotate={-90} class="size-16" />
|
|
31
40
|
```
|
|
32
41
|
|
|
33
42
|
### Animated Progress
|
|
34
43
|
|
|
35
44
|
```svelte
|
|
36
45
|
<script lang="ts">
|
|
37
|
-
import { Circle } from "stuic";
|
|
46
|
+
import { Circle } from "@marianmeres/stuic";
|
|
38
47
|
|
|
39
48
|
let progress = $state(0);
|
|
40
49
|
|
|
@@ -54,13 +63,40 @@ An SVG circle progress indicator with configurable stroke, rotation, and animate
|
|
|
54
63
|
|
|
55
64
|
### Custom Styling
|
|
56
65
|
|
|
66
|
+
The ring's stroke is `currentColor`, so a text color on the container (or on the svg via
|
|
67
|
+
`circleClass`) colors it. To set the stroke directly, use `circleStyle` - it lands on the
|
|
68
|
+
`<circle>` element, which is the only place that beats the `stroke="currentColor"`
|
|
69
|
+
presentation attribute.
|
|
70
|
+
|
|
57
71
|
```svelte
|
|
72
|
+
<!-- via currentColor -->
|
|
58
73
|
<Circle
|
|
59
74
|
completeness={0.5}
|
|
60
75
|
strokeWidth={8}
|
|
61
76
|
rotate={-90}
|
|
62
77
|
bgStrokeColor="rgba(0,0,0,0.1)"
|
|
78
|
+
class="size-24 text-blue-500"
|
|
79
|
+
/>
|
|
80
|
+
|
|
81
|
+
<!-- or explicitly on the circle element -->
|
|
82
|
+
<Circle
|
|
83
|
+
completeness={0.5}
|
|
84
|
+
rotate={-90}
|
|
63
85
|
class="size-24"
|
|
64
|
-
|
|
86
|
+
circleStyle="stroke: var(--stuic-color-primary);"
|
|
65
87
|
/>
|
|
66
88
|
```
|
|
89
|
+
|
|
90
|
+
### Content Inside the Ring
|
|
91
|
+
|
|
92
|
+
```svelte
|
|
93
|
+
<div class="relative size-24">
|
|
94
|
+
<Circle
|
|
95
|
+
completeness={0.62}
|
|
96
|
+
rotate={-90}
|
|
97
|
+
bgStrokeColor="#e5e5e5"
|
|
98
|
+
class="absolute inset-0"
|
|
99
|
+
/>
|
|
100
|
+
<div class="absolute inset-0 flex items-center justify-center">62%</div>
|
|
101
|
+
</div>
|
|
102
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Circle, type Props as CircleProps } from "./Circle.svelte";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Circle } from "./Circle.svelte";
|
|
@@ -420,11 +420,35 @@
|
|
|
420
420
|
syncToValue();
|
|
421
421
|
}
|
|
422
422
|
|
|
423
|
-
|
|
424
|
-
|
|
423
|
+
// `undefined` REMOVES the key (and an emptied bag removes `extras` itself):
|
|
424
|
+
// "no value" must have exactly one representation downstream — a consumer
|
|
425
|
+
// reading `extras.unit` to decide whether to render a suffix should never
|
|
426
|
+
// have to special-case `""`, nor a `{}` that means nothing.
|
|
427
|
+
function setExtra(row: Row, key: string, value: unknown) {
|
|
428
|
+
const next = { ...(row.def.extras ?? {}) };
|
|
429
|
+
if (value === undefined) delete next[key];
|
|
430
|
+
else next[key] = value;
|
|
431
|
+
row.def.extras = Object.keys(next).length ? next : undefined;
|
|
425
432
|
syncToValue();
|
|
426
433
|
}
|
|
427
434
|
|
|
435
|
+
/** Display value of a string/select extra (a non-string is shown, not eaten). */
|
|
436
|
+
function extraText(row: Row, key: string): string {
|
|
437
|
+
const v = row.def.extras?.[key];
|
|
438
|
+
return v == null ? "" : String(v);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// while typing, the RAW value is stored (trimming here would fight the
|
|
442
|
+
// caret: a written-back trimmed value makes a trailing space untypable) —
|
|
443
|
+
// only the emptiness test is trimmed; `onchange` normalizes on commit
|
|
444
|
+
function onExtraStringInput(row: Row, key: string, raw: string) {
|
|
445
|
+
setExtra(row, key, raw.trim() ? raw : undefined);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function onExtraStringChange(row: Row, key: string, raw: string) {
|
|
449
|
+
setExtra(row, key, raw.trim() || undefined);
|
|
450
|
+
}
|
|
451
|
+
|
|
428
452
|
function typeChanged(row: Row): boolean {
|
|
429
453
|
return !!row.initialType && row.def.type !== row.initialType;
|
|
430
454
|
}
|
|
@@ -642,6 +666,8 @@
|
|
|
642
666
|
".fb-options .fb-option-value, .fb-options .fb-add-option-btn"
|
|
643
667
|
)
|
|
644
668
|
?.focus?.();
|
|
669
|
+
} else if (errs.extras) {
|
|
670
|
+
rowEls[row.rid]?.querySelector<HTMLElement>(".fb-extra-input")?.focus?.();
|
|
645
671
|
}
|
|
646
672
|
});
|
|
647
673
|
}
|
|
@@ -734,6 +760,7 @@
|
|
|
734
760
|
{@const showLabelError = !!(attempted && errs?.label)}
|
|
735
761
|
{@const showKeyError = !!(errs?.key && (attempted || row.keyEdited))}
|
|
736
762
|
{@const showOptionsError = !!(attempted && errs?.options)}
|
|
763
|
+
{@const showExtrasError = !!(attempted && errs?.extras)}
|
|
737
764
|
{@const canDrag =
|
|
738
765
|
!disabled && !row.deleted && !row.def.lock?.reorder && rows.length > 1}
|
|
739
766
|
<div
|
|
@@ -807,7 +834,7 @@
|
|
|
807
834
|
{getLocalizedText(entry.label, _defaultLanguage)}
|
|
808
835
|
</span>
|
|
809
836
|
{/if}
|
|
810
|
-
{#if (showLabelError || showKeyError || showOptionsError) && !row.deleted}
|
|
837
|
+
{#if (showLabelError || showKeyError || showOptionsError || showExtrasError) && !row.deleted}
|
|
811
838
|
<span class="fb-error-text shrink-0">
|
|
812
839
|
<span aria-hidden="true"
|
|
813
840
|
>{@html iconAlertWarning({ size: 14 })}</span
|
|
@@ -1035,36 +1062,120 @@
|
|
|
1035
1062
|
{/if}
|
|
1036
1063
|
|
|
1037
1064
|
{#if entry.extras?.length}
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
{
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1065
|
+
<!--
|
|
1066
|
+
Every arm displays the ACTUAL def value only (no
|
|
1067
|
+
`?? ex.default` fallback): defaults are materialized into
|
|
1068
|
+
`extras` on add/type-change, but a def loaded without the
|
|
1069
|
+
key must not render as if it held the default while
|
|
1070
|
+
emitting nothing — the control must always match what
|
|
1071
|
+
`value` says.
|
|
1072
|
+
-->
|
|
1073
|
+
<div class="fb-extras fb-field flex flex-col gap-2.5">
|
|
1074
|
+
{#each entry.extras as ex, exIdx (ex.key)}
|
|
1075
|
+
{#if ex.type === "string" || ex.type === "select"}
|
|
1076
|
+
{@const exId = `${id}-extra-${row.rid}-${exIdx}`}
|
|
1077
|
+
{@const exValue = extraText(row, ex.key)}
|
|
1078
|
+
<div class="fb-extra">
|
|
1079
|
+
<label class="fb-sub-label" for={exId}>
|
|
1080
|
+
{getLocalizedText(ex.label, _defaultLanguage)}
|
|
1081
|
+
</label>
|
|
1082
|
+
{#if ex.type === "string"}
|
|
1083
|
+
<input
|
|
1084
|
+
id={exId}
|
|
1085
|
+
type="text"
|
|
1086
|
+
class={twMerge(INPUT_CLS, "fb-extra-input w-full")}
|
|
1087
|
+
value={exValue}
|
|
1088
|
+
maxlength={ex.maxlength}
|
|
1089
|
+
placeholder={getLocalizedText(
|
|
1090
|
+
ex.placeholder,
|
|
1091
|
+
_defaultLanguage
|
|
1092
|
+
) || undefined}
|
|
1093
|
+
oninput={(e) =>
|
|
1094
|
+
onExtraStringInput(
|
|
1095
|
+
row,
|
|
1096
|
+
ex.key,
|
|
1097
|
+
e.currentTarget.value
|
|
1098
|
+
)}
|
|
1099
|
+
onchange={(e) =>
|
|
1100
|
+
onExtraStringChange(
|
|
1101
|
+
row,
|
|
1102
|
+
ex.key,
|
|
1103
|
+
e.currentTarget.value
|
|
1104
|
+
)}
|
|
1105
|
+
{disabled}
|
|
1106
|
+
{tabindex}
|
|
1107
|
+
aria-invalid={showExtrasError || undefined}
|
|
1108
|
+
aria-describedby={showExtrasError
|
|
1109
|
+
? `${id}-extras-err-${row.rid}`
|
|
1110
|
+
: undefined}
|
|
1111
|
+
/>
|
|
1112
|
+
{:else}
|
|
1113
|
+
<select
|
|
1114
|
+
id={exId}
|
|
1115
|
+
class={twMerge(INPUT_CLS, "fb-extra-input w-full")}
|
|
1116
|
+
value={exValue}
|
|
1117
|
+
onchange={(e) =>
|
|
1118
|
+
setExtra(
|
|
1119
|
+
row,
|
|
1120
|
+
ex.key,
|
|
1121
|
+
e.currentTarget.value || undefined
|
|
1122
|
+
)}
|
|
1123
|
+
{disabled}
|
|
1124
|
+
{tabindex}
|
|
1125
|
+
>
|
|
1126
|
+
<option value="">
|
|
1127
|
+
{getLocalizedText(ex.placeholder, _defaultLanguage)}
|
|
1128
|
+
</option>
|
|
1129
|
+
{#each ex.options as opt (opt.value)}
|
|
1130
|
+
<option value={opt.value}>
|
|
1131
|
+
{getLocalizedText(opt.label, _defaultLanguage)}
|
|
1132
|
+
</option>
|
|
1133
|
+
{/each}
|
|
1134
|
+
<!-- a stored value outside the declared list stays
|
|
1135
|
+
visible and round-trips (same stance as an
|
|
1136
|
+
unknown field type) -->
|
|
1137
|
+
{#if exValue && !ex.options.some((o) => o.value === exValue)}
|
|
1138
|
+
<option value={exValue}>{exValue}</option>
|
|
1139
|
+
{/if}
|
|
1140
|
+
</select>
|
|
1141
|
+
{/if}
|
|
1060
1142
|
{#if ex.description}
|
|
1061
|
-
<
|
|
1143
|
+
<div class="fb-hint text-xs mt-0.5">
|
|
1062
1144
|
{getLocalizedText(ex.description, _defaultLanguage)}
|
|
1063
|
-
</
|
|
1145
|
+
</div>
|
|
1064
1146
|
{/if}
|
|
1065
|
-
</
|
|
1066
|
-
|
|
1147
|
+
</div>
|
|
1148
|
+
{:else}
|
|
1149
|
+
<label
|
|
1150
|
+
class="stuic-checkbox fb-extra flex items-start gap-2 cursor-pointer"
|
|
1151
|
+
>
|
|
1152
|
+
<input
|
|
1153
|
+
type="checkbox"
|
|
1154
|
+
checked={!!row.def.extras?.[ex.key]}
|
|
1155
|
+
onchange={(e) =>
|
|
1156
|
+
setExtra(row, ex.key, e.currentTarget.checked)}
|
|
1157
|
+
{disabled}
|
|
1158
|
+
{tabindex}
|
|
1159
|
+
/>
|
|
1160
|
+
<span class="text-sm">
|
|
1161
|
+
{getLocalizedText(ex.label, _defaultLanguage)}
|
|
1162
|
+
{#if ex.description}
|
|
1163
|
+
<span class="fb-hint block text-xs">
|
|
1164
|
+
{getLocalizedText(ex.description, _defaultLanguage)}
|
|
1165
|
+
</span>
|
|
1166
|
+
{/if}
|
|
1167
|
+
</span>
|
|
1168
|
+
</label>
|
|
1169
|
+
{/if}
|
|
1067
1170
|
{/each}
|
|
1171
|
+
{#if showExtrasError}
|
|
1172
|
+
<div
|
|
1173
|
+
id="{id}-extras-err-{row.rid}"
|
|
1174
|
+
class="fb-error-text text-sm"
|
|
1175
|
+
>
|
|
1176
|
+
{errs?.extras}
|
|
1177
|
+
</div>
|
|
1178
|
+
{/if}
|
|
1068
1179
|
</div>
|
|
1069
1180
|
{/if}
|
|
1070
1181
|
|
|
@@ -60,8 +60,9 @@ Value membership rules:
|
|
|
60
60
|
**retained** on the def (switching back restores them); consumers compiling the list
|
|
61
61
|
should ignore `options` on non-choice types.
|
|
62
62
|
- Palette `extras` defaults are materialized into `def.extras` when a field is added
|
|
63
|
-
or its type changes; a def loaded _without_ an extra's key renders unchecked —
|
|
64
|
-
|
|
63
|
+
or its type changes; a def loaded _without_ an extra's key renders empty/unchecked —
|
|
64
|
+
the control always reflects what `value` actually contains, never a phantom default.
|
|
65
|
+
Extras are **retained** across a type change too (same rule as `options`).
|
|
65
66
|
|
|
66
67
|
## The palette
|
|
67
68
|
|
|
@@ -72,17 +73,69 @@ interface FieldTypeDef {
|
|
|
72
73
|
description?: LocalizedText;
|
|
73
74
|
icon?: string | Snippet; // html string or snippet, shown in the row's type chip
|
|
74
75
|
supportsOptions?: boolean; // renders the option editor
|
|
75
|
-
extras?:
|
|
76
|
-
key: string;
|
|
77
|
-
label: LocalizedText;
|
|
78
|
-
description?: LocalizedText;
|
|
79
|
-
type: "boolean"; // v1: booleans only (a checkbox per extra)
|
|
80
|
-
default?: boolean;
|
|
81
|
-
}[];
|
|
76
|
+
extras?: FieldTypeExtraDef[]; // per-type controls, see below
|
|
82
77
|
preview?: Snippet<[FieldDef]>; // per-type preview of a single field
|
|
83
78
|
}
|
|
84
79
|
```
|
|
85
80
|
|
|
81
|
+
## Extras
|
|
82
|
+
|
|
83
|
+
`extras` declares extra per-field controls, each stored under `FieldDef.extras[key]`.
|
|
84
|
+
The component never interprets the values — it renders a control per declaration and
|
|
85
|
+
round-trips whatever is there. Discriminated by `type`:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
type FieldTypeExtraDef =
|
|
89
|
+
| { key; label; description?; type: "boolean"; default?: boolean }
|
|
90
|
+
| {
|
|
91
|
+
key;
|
|
92
|
+
label;
|
|
93
|
+
description?;
|
|
94
|
+
type: "string";
|
|
95
|
+
default?: string;
|
|
96
|
+
placeholder?: LocalizedText;
|
|
97
|
+
maxlength?: number;
|
|
98
|
+
}
|
|
99
|
+
| {
|
|
100
|
+
key;
|
|
101
|
+
label;
|
|
102
|
+
description?;
|
|
103
|
+
type: "select";
|
|
104
|
+
default?: string;
|
|
105
|
+
placeholder?: LocalizedText;
|
|
106
|
+
options: { value: string; label: LocalizedText }[];
|
|
107
|
+
};
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
{
|
|
112
|
+
type: "number",
|
|
113
|
+
label: "Number",
|
|
114
|
+
extras: [
|
|
115
|
+
// rendered after the number on the consumer's own page: "12.5 % vol"
|
|
116
|
+
{ key: "unit", label: "Unit", type: "string", placeholder: "e.g. % vol", maxlength: 16 },
|
|
117
|
+
{ key: "group", label: "Group", type: "select", placeholder: "— none —",
|
|
118
|
+
options: [{ value: "nutrition", label: "Nutrition declaration" }] },
|
|
119
|
+
],
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- **Empty means absent.** Emptying a `string` extra or picking a `select`'s blank entry
|
|
124
|
+
**removes the key** (and removes `extras` itself once the bag is empty) rather than
|
|
125
|
+
storing `""` — "no unit" has exactly one representation downstream. Consequence worth
|
|
126
|
+
knowing: a `default` becomes eligible for re-seeding again, so a cleared extra
|
|
127
|
+
reappears if the field's type is changed away and back.
|
|
128
|
+
- **Whitespace is trimmed on commit, not while typing** — trimming on every keystroke
|
|
129
|
+
would make a trailing space untypable.
|
|
130
|
+
- **`maxlength` is checked twice** — the input's attribute bounds typing, and
|
|
131
|
+
`validateFieldDefs` re-checks the stored value (the attribute does not constrain a
|
|
132
|
+
programmatically seeded or imported one) and reports it as a row error.
|
|
133
|
+
- **A `select` value outside the declared `options` is preserved** and rendered as its
|
|
134
|
+
own entry, the same stance the component takes on an unknown field `type` — it stays
|
|
135
|
+
visible and round-trips instead of being silently coerced to blank.
|
|
136
|
+
- Extras have **no per-extra lock**; `FieldLock` does not cover them (the whole editor
|
|
137
|
+
still honours `disabled`).
|
|
138
|
+
|
|
86
139
|
`FIELDS_BUILDER_DEFAULT_TYPES` ships a small general-purpose palette
|
|
87
140
|
(text / longtext / number / checkbox / select / date) for demos and unopinionated
|
|
88
141
|
consumers — `types` is still a required prop, so nobody gets it by accident.
|
|
@@ -59,6 +59,7 @@ export const FIELDS_BUILDER_MESSAGES_SK = {
|
|
|
59
59
|
err_options_required: "Pridajte aspoň jednu možnosť",
|
|
60
60
|
err_option_value_required: "Každá možnosť musí mať hodnotu",
|
|
61
61
|
err_option_value_duplicate: "Hodnoty možností musia byť jedinečné",
|
|
62
|
+
err_extra_maxlength: "{{label}} — hodnota je príliš dlhá (max. {{max}} znakov)",
|
|
62
63
|
err_max_fields: "Maximálny počet polí je {{max}}",
|
|
63
64
|
};
|
|
64
65
|
/**
|
|
@@ -53,6 +53,7 @@ export declare const FIELDS_BUILDER_MESSAGES_EN: {
|
|
|
53
53
|
err_options_required: string;
|
|
54
54
|
err_option_value_required: string;
|
|
55
55
|
err_option_value_duplicate: string;
|
|
56
|
+
err_extra_maxlength: string;
|
|
56
57
|
err_max_fields: string;
|
|
57
58
|
};
|
|
58
59
|
/** Every message key `FieldsBuilder` (and its internals) may look up. */
|
|
@@ -54,6 +54,7 @@ export const FIELDS_BUILDER_MESSAGES_EN = {
|
|
|
54
54
|
err_options_required: "Add at least one choice",
|
|
55
55
|
err_option_value_required: "Every choice needs a value",
|
|
56
56
|
err_option_value_duplicate: "Choice values must be unique",
|
|
57
|
+
err_extra_maxlength: "{{label}} is too long (max {{max}} characters)",
|
|
57
58
|
err_max_fields: "Maximum number of fields is {{max}}",
|
|
58
59
|
};
|
|
59
60
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { default as FieldsBuilder, type Props as FieldsBuilderProps, } from "./FieldsBuilder.svelte";
|
|
2
|
-
export type { FieldDef, FieldLock, FieldOptionDef, FieldTypeDef, FieldTypeExtraDef, LocalizedText, } from "./types.js";
|
|
2
|
+
export type { FieldDef, FieldLock, FieldOptionDef, FieldTypeDef, FieldTypeExtraBaseDef, FieldTypeExtraBooleanDef, FieldTypeExtraDef, FieldTypeExtraSelectDef, FieldTypeExtraStringDef, LocalizedText, } from "./types.js";
|
|
3
3
|
export { DEFAULT_FIELD_TYPES as FIELDS_BUILDER_DEFAULT_TYPES, DEFAULT_KEY_PATTERN as FIELDS_BUILDER_DEFAULT_KEY_PATTERN, getLocalizedText, slugifyKey, uniqueKey, validateFieldDefs, type FieldDefRowErrors, type FieldDefsValidationResult, type ValidateFieldDefsOptions, } from "./utils.js";
|
|
4
4
|
export { createFieldsBuilderT, FIELDS_BUILDER_MESSAGES_EN, type FieldsBuilderMessageKey, type FieldsBuilderMessages, } from "./i18n.js";
|
|
5
5
|
export { FIELDS_BUILDER_MESSAGES_SK, FIELDS_BUILDER_DEFAULT_TYPES_SK, } from "./i18n-sk.js";
|
|
@@ -40,24 +40,60 @@ export interface FieldDef {
|
|
|
40
40
|
*/
|
|
41
41
|
options?: FieldOptionDef[];
|
|
42
42
|
/**
|
|
43
|
-
* Per-type extra
|
|
43
|
+
* Per-type extra values, driven by the palette entry's `extras`. An open bag
|
|
44
44
|
* on purpose — the component never interprets these, it only renders a
|
|
45
|
-
* control per declared extra and round-trips the value.
|
|
45
|
+
* control per declared extra and round-trips the value. Like `options`,
|
|
46
|
+
* extras of a previous type are RETAINED when the type changes.
|
|
46
47
|
*/
|
|
47
48
|
extras?: Record<string, unknown>;
|
|
48
49
|
/** What the user may NOT change. Absent = fully editable. */
|
|
49
50
|
lock?: FieldLock;
|
|
50
51
|
}
|
|
51
|
-
/**
|
|
52
|
-
export interface
|
|
52
|
+
/** Shared shape of every extra per-field control declared by a palette entry. */
|
|
53
|
+
export interface FieldTypeExtraBaseDef {
|
|
53
54
|
/** Stored under `FieldDef.extras[key]`. */
|
|
54
55
|
key: string;
|
|
55
56
|
label: LocalizedText;
|
|
56
57
|
description?: LocalizedText;
|
|
57
|
-
|
|
58
|
+
}
|
|
59
|
+
/** A yes/no extra — rendered as a checkbox. */
|
|
60
|
+
export interface FieldTypeExtraBooleanDef extends FieldTypeExtraBaseDef {
|
|
58
61
|
type: "boolean";
|
|
59
62
|
default?: boolean;
|
|
60
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* A short free-text extra (a unit, a suffix, a format hint...) — rendered as a
|
|
66
|
+
* single-line text input. An emptied value REMOVES `extras[key]` rather than
|
|
67
|
+
* storing `""`, so "no value" has exactly one representation downstream.
|
|
68
|
+
*/
|
|
69
|
+
export interface FieldTypeExtraStringDef extends FieldTypeExtraBaseDef {
|
|
70
|
+
type: "string";
|
|
71
|
+
default?: string;
|
|
72
|
+
placeholder?: LocalizedText;
|
|
73
|
+
/**
|
|
74
|
+
* Enforced by the input's `maxlength` attribute AND re-checked by
|
|
75
|
+
* `validateFieldDefs` (the attribute does not bound a programmatically
|
|
76
|
+
* seeded value).
|
|
77
|
+
*/
|
|
78
|
+
maxlength?: number;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* One of a fixed list of values — rendered as a select. Selecting the empty
|
|
82
|
+
* entry REMOVES `extras[key]`. A stored value that is not among `options` is
|
|
83
|
+
* kept and rendered as-is (never silently coerced away).
|
|
84
|
+
*/
|
|
85
|
+
export interface FieldTypeExtraSelectDef extends FieldTypeExtraBaseDef {
|
|
86
|
+
type: "select";
|
|
87
|
+
default?: string;
|
|
88
|
+
options: FieldOptionDef[];
|
|
89
|
+
/** Label of the "nothing selected" entry. Default: blank. */
|
|
90
|
+
placeholder?: LocalizedText;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* An extra per-field control declared by a palette entry, rendered into
|
|
94
|
+
* `FieldDef.extras[key]`. Discriminated by `type`.
|
|
95
|
+
*/
|
|
96
|
+
export type FieldTypeExtraDef = FieldTypeExtraBooleanDef | FieldTypeExtraStringDef | FieldTypeExtraSelectDef;
|
|
61
97
|
/** One entry of the type palette (the `types` prop). */
|
|
62
98
|
export interface FieldTypeDef {
|
|
63
99
|
/** Stored in `FieldDef.type`. */
|
|
@@ -26,6 +26,8 @@ export interface FieldDefRowErrors {
|
|
|
26
26
|
label?: string;
|
|
27
27
|
key?: string;
|
|
28
28
|
options?: string;
|
|
29
|
+
/** First offending extra of the row (its label is in the message). */
|
|
30
|
+
extras?: string;
|
|
29
31
|
}
|
|
30
32
|
export interface FieldDefsValidationResult {
|
|
31
33
|
valid: boolean;
|
|
@@ -41,7 +43,7 @@ export interface ValidateFieldDefsOptions {
|
|
|
41
43
|
* must never block the rest of the list), but their keys still count toward
|
|
42
44
|
* uniqueness.
|
|
43
45
|
*/
|
|
44
|
-
types?: Pick<FieldTypeDef, "type" | "supportsOptions">[];
|
|
46
|
+
types?: Pick<FieldTypeDef, "type" | "supportsOptions" | "extras">[];
|
|
45
47
|
keyPattern?: RegExp;
|
|
46
48
|
keyMaxLength?: number;
|
|
47
49
|
reservedKeys?: string[] | ((key: string) => boolean);
|
|
@@ -53,7 +55,7 @@ export interface ValidateFieldDefsOptions {
|
|
|
53
55
|
/**
|
|
54
56
|
* Validate a list of field defs: label non-empty, key present/pattern/length/
|
|
55
57
|
* unique/not-reserved, choice types have at least one option with non-empty
|
|
56
|
-
* unique values, `maxFields` not exceeded.
|
|
58
|
+
* unique values, string extras within `maxlength`, `maxFields` not exceeded.
|
|
57
59
|
*
|
|
58
60
|
* This is client-side convenience only — a consumer persisting the list MUST
|
|
59
61
|
* re-validate server-side; this function is not a security boundary.
|
|
@@ -65,7 +65,7 @@ export function isKeyReserved(key, reservedKeys) {
|
|
|
65
65
|
/**
|
|
66
66
|
* Validate a list of field defs: label non-empty, key present/pattern/length/
|
|
67
67
|
* unique/not-reserved, choice types have at least one option with non-empty
|
|
68
|
-
* unique values, `maxFields` not exceeded.
|
|
68
|
+
* unique values, string extras within `maxlength`, `maxFields` not exceeded.
|
|
69
69
|
*
|
|
70
70
|
* This is client-side convenience only — a consumer persisting the list MUST
|
|
71
71
|
* re-validate server-side; this function is not a security boundary.
|
|
@@ -125,6 +125,19 @@ export function validateFieldDefs(defs, opts = {}) {
|
|
|
125
125
|
put(i, "options", t("err_option_value_duplicate"));
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
|
+
// `maxlength` is re-checked here on purpose: the input's attribute stops
|
|
129
|
+
// typing, it does not bound a seeded or pasted-then-mutated value
|
|
130
|
+
for (const ex of typeMap?.get(d.type)?.extras ?? []) {
|
|
131
|
+
if (ex.type !== "string" || !ex.maxlength)
|
|
132
|
+
continue;
|
|
133
|
+
const v = d.extras?.[ex.key];
|
|
134
|
+
if (typeof v === "string" && v.length > ex.maxlength) {
|
|
135
|
+
put(i, "extras", t("err_extra_maxlength", {
|
|
136
|
+
label: getLocalizedText(ex.label, opts.defaultLanguage),
|
|
137
|
+
max: ex.maxlength,
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
128
141
|
});
|
|
129
142
|
let message = rowErrors.find(Boolean)
|
|
130
143
|
? Object.values(rowErrors.find(Boolean))[0]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
2
|
import type { Snippet } from "svelte";
|
|
3
3
|
import type { ValidateOptions } from "../../actions/validate.svelte.js";
|
|
4
|
+
import type { SwitchIntent } from "../Switch/Switch.svelte";
|
|
4
5
|
import type { THC } from "../Thc/Thc.svelte";
|
|
5
6
|
import type { InputWrapClassProps } from "./types.js";
|
|
6
7
|
|
|
@@ -32,6 +33,25 @@
|
|
|
32
33
|
classInput?: string;
|
|
33
34
|
style?: string;
|
|
34
35
|
renderValue?: (rawValue: any) => string;
|
|
36
|
+
//
|
|
37
|
+
// Below: forwarded as-is to the underlying <Switch>.
|
|
38
|
+
//
|
|
39
|
+
/** Semantic color intent of the switch */
|
|
40
|
+
intent?: SwitchIntent;
|
|
41
|
+
/**
|
|
42
|
+
* Size of the switch itself. Deliberately separate from `renderSize`, which sizes
|
|
43
|
+
* the InputWrap shell (label, description, spacing) around it.
|
|
44
|
+
*/
|
|
45
|
+
switchSize?: "xs" | "sm" | "md" | "lg" | string;
|
|
46
|
+
/** Classes for the switch's toggle dot/knob element */
|
|
47
|
+
dotClass?: string;
|
|
48
|
+
/** Snippet to render inside the dot when checked */
|
|
49
|
+
on?: Snippet;
|
|
50
|
+
/** Snippet to render inside the dot when unchecked */
|
|
51
|
+
off?: Snippet;
|
|
52
|
+
/** Async validation before toggle - return false to prevent change */
|
|
53
|
+
preHook?: (current: boolean) => Promise<false | any>;
|
|
54
|
+
onclick?: (event: MouseEvent) => void;
|
|
35
55
|
}
|
|
36
56
|
</script>
|
|
37
57
|
|
|
@@ -72,6 +92,15 @@
|
|
|
72
92
|
labelLeftBreakpoint = 480,
|
|
73
93
|
//
|
|
74
94
|
classInput,
|
|
95
|
+
//
|
|
96
|
+
intent,
|
|
97
|
+
switchSize = "md",
|
|
98
|
+
dotClass,
|
|
99
|
+
on,
|
|
100
|
+
off,
|
|
101
|
+
preHook,
|
|
102
|
+
onclick,
|
|
103
|
+
//
|
|
75
104
|
classLabel,
|
|
76
105
|
classLabelBox,
|
|
77
106
|
classInputBox,
|
|
@@ -150,13 +179,30 @@
|
|
|
150
179
|
classInputBoxWrap={twMerge("input-wrap-transparent", classInputBoxWrap)}
|
|
151
180
|
{style}
|
|
152
181
|
>
|
|
182
|
+
<!--
|
|
183
|
+
`aria-labelledby` (not the InputWrap's `for={id}`) is what names the switch: the
|
|
184
|
+
<Switch> root is itself a <label>, so a `for` pointing at it would not associate,
|
|
185
|
+
and its inner checkbox is aria-hidden. Matches InputWrap's own `{id}-label`, and
|
|
186
|
+
only when there is a label to point at.
|
|
187
|
+
-->
|
|
153
188
|
<Switch
|
|
154
189
|
bind:this={switchRef}
|
|
155
190
|
bind:checked
|
|
156
191
|
{name}
|
|
157
192
|
{required}
|
|
158
193
|
{disabled}
|
|
194
|
+
{intent}
|
|
195
|
+
{dotClass}
|
|
196
|
+
{on}
|
|
197
|
+
{off}
|
|
198
|
+
{preHook}
|
|
199
|
+
{onclick}
|
|
200
|
+
{tabindex}
|
|
201
|
+
size={switchSize}
|
|
202
|
+
class={classInput}
|
|
203
|
+
aria-labelledby={label ? `${id}-label` : undefined}
|
|
159
204
|
validate={validateProp}
|
|
160
205
|
{setValidationResult}
|
|
206
|
+
{...rest}
|
|
161
207
|
/>
|
|
162
208
|
</InputWrap>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Snippet } from "svelte";
|
|
2
2
|
import type { ValidateOptions } from "../../actions/validate.svelte.js";
|
|
3
|
+
import type { SwitchIntent } from "../Switch/Switch.svelte";
|
|
3
4
|
import type { THC } from "../Thc/Thc.svelte";
|
|
4
5
|
import type { InputWrapClassProps } from "./types.js";
|
|
5
6
|
type SnippetWithId = Snippet<[{
|
|
@@ -31,6 +32,22 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
|
|
|
31
32
|
classInput?: string;
|
|
32
33
|
style?: string;
|
|
33
34
|
renderValue?: (rawValue: any) => string;
|
|
35
|
+
/** Semantic color intent of the switch */
|
|
36
|
+
intent?: SwitchIntent;
|
|
37
|
+
/**
|
|
38
|
+
* Size of the switch itself. Deliberately separate from `renderSize`, which sizes
|
|
39
|
+
* the InputWrap shell (label, description, spacing) around it.
|
|
40
|
+
*/
|
|
41
|
+
switchSize?: "xs" | "sm" | "md" | "lg" | string;
|
|
42
|
+
/** Classes for the switch's toggle dot/knob element */
|
|
43
|
+
dotClass?: string;
|
|
44
|
+
/** Snippet to render inside the dot when checked */
|
|
45
|
+
on?: Snippet;
|
|
46
|
+
/** Snippet to render inside the dot when unchecked */
|
|
47
|
+
off?: Snippet;
|
|
48
|
+
/** Async validation before toggle - return false to prevent change */
|
|
49
|
+
preHook?: (current: boolean) => Promise<false | any>;
|
|
50
|
+
onclick?: (event: MouseEvent) => void;
|
|
34
51
|
}
|
|
35
52
|
import type { ValidationResult } from "../../actions/validate.svelte.js";
|
|
36
53
|
declare const FieldSwitch: import("svelte").Component<Props, {
|
|
@@ -148,6 +148,38 @@ Component-specific targets (e.g. `classInput` for the inner `<input>`/`<select>`
|
|
|
148
148
|
<FieldCheckbox label="I agree to the terms" bind:checked={agreed} required />
|
|
149
149
|
```
|
|
150
150
|
|
|
151
|
+
### Switch
|
|
152
|
+
|
|
153
|
+
`FieldSwitch` wraps a [`Switch`](../Switch/README.md) in the standard field scaffolding
|
|
154
|
+
(label, description, validation box) and forwards the switch's own props to it.
|
|
155
|
+
|
|
156
|
+
```svelte
|
|
157
|
+
<script lang="ts">
|
|
158
|
+
import { FieldSwitch } from "stuic";
|
|
159
|
+
|
|
160
|
+
let published = $state(false);
|
|
161
|
+
</script>
|
|
162
|
+
|
|
163
|
+
<FieldSwitch
|
|
164
|
+
label="Published"
|
|
165
|
+
description="Whether the public page resolves."
|
|
166
|
+
intent="success"
|
|
167
|
+
bind:checked={published}
|
|
168
|
+
/>
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
| Prop | Goes to |
|
|
172
|
+
| ------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
|
173
|
+
| `intent`, `dotClass`, `on`, `off`, `preHook`, `onclick`, `tabindex` | the inner `<Switch>` |
|
|
174
|
+
| `classInput` | the inner `<Switch>`'s class |
|
|
175
|
+
| `switchSize` | the switch's own size (`"xs" \| "sm" \| "md" \| "lg"`, default `md`) |
|
|
176
|
+
| `renderSize` | the surrounding field shell only — it does **not** size the switch |
|
|
177
|
+
| everything else unrecognized (`...rest`) | the inner `<Switch>` |
|
|
178
|
+
|
|
179
|
+
The visible label names the control via `aria-labelledby` (the switch is announced as
|
|
180
|
+
`switch, on/off` with that name). Clicking the label text does not toggle — the switch's
|
|
181
|
+
own root is a `<label>`, so an HTML `for` association is not possible; click the switch.
|
|
182
|
+
|
|
151
183
|
### Input with Addons
|
|
152
184
|
|
|
153
185
|
```svelte
|
|
@@ -117,8 +117,13 @@
|
|
|
117
117
|
)}
|
|
118
118
|
>
|
|
119
119
|
{#if label}
|
|
120
|
+
<!--
|
|
121
|
+
The `{id}-label` id is what controls that cannot be reached by `for`
|
|
122
|
+
(e.g. FieldSwitch's <Switch>) point their `aria-labelledby` at.
|
|
123
|
+
-->
|
|
120
124
|
<label
|
|
121
125
|
for={id}
|
|
126
|
+
id="{id}-label"
|
|
122
127
|
class={twMerge(
|
|
123
128
|
"block flex-1 px-2 mb-1 text-base",
|
|
124
129
|
required && "after:content-['*'] after:opacity-40 after:pl-1",
|
|
@@ -10,7 +10,7 @@ A toggle switch component with size variants, semantic intents, keyboard support
|
|
|
10
10
|
| `size` | `"sm" \| "md" \| "lg" \| "xl" \| string` | `"lg"` | Switch size |
|
|
11
11
|
| `intent` | `"primary" \| "accent" \| "success" \| "warning" \| "destructive"` | - | Semantic color intent |
|
|
12
12
|
| `name` | `string` | - | Form field name for hidden checkbox |
|
|
13
|
-
| `label` | `string` | - |
|
|
13
|
+
| `label` | `string` | - | Accessible name (rendered as `aria-label`) |
|
|
14
14
|
| `required` | `boolean` | `false` | Mark as required |
|
|
15
15
|
| `disabled` | `boolean` | `false` | Disable toggle |
|
|
16
16
|
| `tabindex` | `number` | `0` | Tab index |
|
|
@@ -142,3 +142,30 @@ A toggle switch component with size variants, semantic intents, keyboard support
|
|
|
142
142
|
|
|
143
143
|
- **Space**: Toggle switch
|
|
144
144
|
- **Enter**: Toggle switch
|
|
145
|
+
|
|
146
|
+
## Accessibility
|
|
147
|
+
|
|
148
|
+
The interactive element is the switch track itself (the root `<label class="stuic-switch">`):
|
|
149
|
+
it is focusable, keyboard operable, and carries `role="switch"` plus the state - `aria-checked`,
|
|
150
|
+
and `aria-disabled` / `aria-required` / `aria-invalid` when they apply.
|
|
151
|
+
|
|
152
|
+
**Always give it a name.** `role="switch"` takes its accessible name from the author only, never
|
|
153
|
+
from its content, so a switch without one is announced as an unnamed control:
|
|
154
|
+
|
|
155
|
+
```svelte
|
|
156
|
+
<!-- name it directly … -->
|
|
157
|
+
<Switch label="Published" bind:checked={published} />
|
|
158
|
+
|
|
159
|
+
<!-- … or point at existing visible text -->
|
|
160
|
+
<span id="pub-label">Published</span>
|
|
161
|
+
<Switch aria-labelledby="pub-label" bind:checked={published} />
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
An explicit `aria-label` / `aria-labelledby` wins over the `label` prop.
|
|
165
|
+
[`FieldSwitch`](../Input/README.md#switch) wires its own visible label up automatically.
|
|
166
|
+
|
|
167
|
+
The inner `<input type="checkbox">` is the form value carrier only (submission, `name`,
|
|
168
|
+
`required`, native validation) and is `aria-hidden` so that the switch is the single announced
|
|
169
|
+
control. It is therefore **not** reachable via role-based queries - locate it structurally
|
|
170
|
+
(`container.querySelector('input[type="checkbox"]')`) and use `getByRole("switch")` for the
|
|
171
|
+
control itself.
|
|
@@ -19,7 +19,12 @@
|
|
|
19
19
|
class?: string;
|
|
20
20
|
/** Classes for the toggle dot/knob element */
|
|
21
21
|
dotClass?: string;
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Accessible name for the switch, rendered as `aria-label` on the interactive
|
|
24
|
+
* element. An explicit `aria-label`/`aria-labelledby` passed by the caller wins.
|
|
25
|
+
* Required unless the switch is named some other way - `role="switch"` takes its
|
|
26
|
+
* name from the author only, never from its content.
|
|
27
|
+
*/
|
|
23
28
|
label?: string;
|
|
24
29
|
required?: boolean;
|
|
25
30
|
disabled?: boolean;
|
|
@@ -125,11 +130,27 @@
|
|
|
125
130
|
}
|
|
126
131
|
</script>
|
|
127
132
|
|
|
133
|
+
<!--
|
|
134
|
+
The <label> is the interactive element here: it is focusable, keyboard operable and
|
|
135
|
+
carries the whole toggle interaction, so it - not the hidden checkbox - is what must
|
|
136
|
+
be announced. Hence `role="switch"` + the aria state below. The inner checkbox stays
|
|
137
|
+
as the form value carrier only (aria-hidden, see there).
|
|
138
|
+
|
|
139
|
+
Note that `role` on a <label> is not allowed by ARIA-in-HTML (the element maps to no
|
|
140
|
+
role); it is nevertheless honoured by browsers/AT and is the least invasive way to
|
|
141
|
+
name and announce this control without restructuring the markup.
|
|
142
|
+
-->
|
|
128
143
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
129
144
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
130
145
|
<label
|
|
131
146
|
bind:this={wrap}
|
|
132
147
|
class={twMerge("stuic-switch m-2", _preset.size[size], classProp)}
|
|
148
|
+
role="switch"
|
|
149
|
+
aria-checked={!!checked}
|
|
150
|
+
aria-disabled={disabled || undefined}
|
|
151
|
+
aria-required={required || undefined}
|
|
152
|
+
aria-invalid={_validation && !_validation.valid ? "true" : undefined}
|
|
153
|
+
aria-label={label}
|
|
133
154
|
data-checked={checked}
|
|
134
155
|
data-disabled={disabled}
|
|
135
156
|
data-intent={intent}
|
|
@@ -165,11 +186,18 @@
|
|
|
165
186
|
{@render off?.()}
|
|
166
187
|
{/if}
|
|
167
188
|
</span>
|
|
189
|
+
<!--
|
|
190
|
+
Value carrier only: it holds the checked state for form submission and native
|
|
191
|
+
validation. `aria-hidden` keeps it out of the a11y tree so the switch above is
|
|
192
|
+
the single announced control (it is never focused - `tabindex="-1"` and every
|
|
193
|
+
focus() call targets the wrapper).
|
|
194
|
+
-->
|
|
168
195
|
<input
|
|
169
196
|
bind:checked
|
|
170
197
|
bind:this={checkbox}
|
|
171
198
|
type="checkbox"
|
|
172
199
|
class="opacity-0 size-0"
|
|
200
|
+
aria-hidden="true"
|
|
173
201
|
{disabled}
|
|
174
202
|
{required}
|
|
175
203
|
{name}
|
|
@@ -13,7 +13,12 @@ export interface Props extends Omit<HTMLLabelAttributes, "children" | "onchange"
|
|
|
13
13
|
class?: string;
|
|
14
14
|
/** Classes for the toggle dot/knob element */
|
|
15
15
|
dotClass?: string;
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Accessible name for the switch, rendered as `aria-label` on the interactive
|
|
18
|
+
* element. An explicit `aria-label`/`aria-labelledby` passed by the caller wins.
|
|
19
|
+
* Required unless the switch is named some other way - `role="switch"` takes its
|
|
20
|
+
* name from the author only, never from its content.
|
|
21
|
+
*/
|
|
17
22
|
label?: string;
|
|
18
23
|
required?: boolean;
|
|
19
24
|
disabled?: boolean;
|
package/dist/index.d.ts
CHANGED
|
@@ -35,6 +35,7 @@ export * from "./components/Cart/index.js";
|
|
|
35
35
|
export * from "./components/Card/index.js";
|
|
36
36
|
export * from "./components/Carousel/index.js";
|
|
37
37
|
export * from "./components/Checkout/index.js";
|
|
38
|
+
export * from "./components/Circle/index.js";
|
|
38
39
|
export * from "./components/Collapsible/index.js";
|
|
39
40
|
export * from "./components/ColorScheme/index.js";
|
|
40
41
|
export * from "./components/CommandMenu/index.js";
|
package/dist/index.js
CHANGED
|
@@ -36,6 +36,7 @@ export * from "./components/Cart/index.js";
|
|
|
36
36
|
export * from "./components/Card/index.js";
|
|
37
37
|
export * from "./components/Carousel/index.js";
|
|
38
38
|
export * from "./components/Checkout/index.js";
|
|
39
|
+
export * from "./components/Circle/index.js";
|
|
39
40
|
export * from "./components/Collapsible/index.js";
|
|
40
41
|
export * from "./components/ColorScheme/index.js";
|
|
41
42
|
export * from "./components/CommandMenu/index.js";
|
package/docs/domains/utils.md
CHANGED
|
@@ -110,7 +110,7 @@ const search = debounce((query: string) => {
|
|
|
110
110
|
| `colors` | Color manipulation |
|
|
111
111
|
| `avatarColors` | Deterministic avatar colors |
|
|
112
112
|
| `paint` | HSL color generation |
|
|
113
|
-
| `svgCircle` | SVG
|
|
113
|
+
| `svgCircle` | SVG progress ring (DOM node) |
|
|
114
114
|
| `oscillate` | Value oscillation for animation |
|
|
115
115
|
|
|
116
116
|
### Example: Class Merging
|