@dorsk/tsumikit 0.32.0 → 0.34.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/dist/components/atoms/Checkbox.svelte +19 -1
- package/dist/components/atoms/Checkbox.svelte.d.ts +3 -0
- package/dist/components/atoms/Input.svelte +15 -1
- package/dist/components/atoms/Input.svelte.d.ts +3 -0
- package/dist/components/atoms/Select.svelte +27 -2
- package/dist/components/atoms/Select.svelte.d.ts +3 -0
- package/dist/components/atoms/Slider.svelte +19 -1
- package/dist/components/atoms/Slider.svelte.d.ts +5 -0
- package/dist/components/atoms/Switch.svelte +18 -2
- package/dist/components/atoms/Switch.svelte.d.ts +3 -0
- package/dist/components/atoms/Textarea.svelte +18 -2
- package/dist/components/atoms/Textarea.svelte.d.ts +3 -0
- package/dist/components/molecules/Field.svelte +28 -10
- package/dist/components/molecules/FilterInput.svelte +13 -0
- package/dist/components/molecules/FilterInput.svelte.d.ts +3 -0
- package/dist/components/molecules/Menu.svelte +55 -4
- package/dist/components/molecules/Menu.svelte.d.ts +5 -2
- package/dist/components/molecules/SegmentedControl.svelte +46 -1
- package/dist/components/molecules/SegmentedControl.svelte.d.ts +5 -1
- package/dist/field-context.d.ts +9 -0
- package/dist/field-context.js +24 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -4,12 +4,16 @@
|
|
|
4
4
|
// label. Supports `bind:checked`, `indeterminate`, and passes through every
|
|
5
5
|
// native attribute via `...rest`.
|
|
6
6
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
7
|
+
import { getFieldContext } from '../../field-context';
|
|
7
8
|
|
|
8
9
|
let {
|
|
9
10
|
checked = $bindable(false),
|
|
10
11
|
indeterminate = false,
|
|
11
12
|
invalid = false,
|
|
12
13
|
label,
|
|
14
|
+
id,
|
|
15
|
+
'aria-describedby': ariaDescribedby,
|
|
16
|
+
'aria-invalid': ariaInvalid,
|
|
13
17
|
class: klass = '',
|
|
14
18
|
el = $bindable(null),
|
|
15
19
|
...rest
|
|
@@ -19,9 +23,15 @@
|
|
|
19
23
|
/** Error state: danger box border + aria-invalid. */
|
|
20
24
|
invalid?: boolean;
|
|
21
25
|
label: string;
|
|
26
|
+
id?: string;
|
|
27
|
+
'aria-describedby'?: string | null;
|
|
28
|
+
'aria-invalid'?: HTMLInputAttributes['aria-invalid'];
|
|
22
29
|
el?: HTMLInputElement | null;
|
|
23
30
|
} = $props();
|
|
24
31
|
|
|
32
|
+
const field = getFieldContext();
|
|
33
|
+
const isInvalid = $derived(invalid || !!field?.invalid);
|
|
34
|
+
|
|
25
35
|
// `indeterminate` is a property, not an attribute — sync it imperatively.
|
|
26
36
|
$effect(() => {
|
|
27
37
|
if (el) el.indeterminate = indeterminate;
|
|
@@ -29,7 +39,15 @@
|
|
|
29
39
|
</script>
|
|
30
40
|
|
|
31
41
|
<label class="checkbox {klass}" data-tsu="Checkbox">
|
|
32
|
-
<input
|
|
42
|
+
<input
|
|
43
|
+
bind:this={el}
|
|
44
|
+
type="checkbox"
|
|
45
|
+
bind:checked
|
|
46
|
+
{...rest}
|
|
47
|
+
id={id ?? field?.id}
|
|
48
|
+
aria-describedby={ariaDescribedby ?? field?.describedBy}
|
|
49
|
+
aria-invalid={ariaInvalid ?? (isInvalid ? 'true' : undefined)}
|
|
50
|
+
/>
|
|
33
51
|
<span class="box" aria-hidden="true"></span>
|
|
34
52
|
<span class="label-text">{label}</span>
|
|
35
53
|
</label>
|
|
@@ -5,6 +5,9 @@ type $$ComponentProps = HTMLInputAttributes & {
|
|
|
5
5
|
/** Error state: danger box border + aria-invalid. */
|
|
6
6
|
invalid?: boolean;
|
|
7
7
|
label: string;
|
|
8
|
+
id?: string;
|
|
9
|
+
'aria-describedby'?: string | null;
|
|
10
|
+
'aria-invalid'?: HTMLInputAttributes['aria-invalid'];
|
|
8
11
|
el?: HTMLInputElement | null;
|
|
9
12
|
};
|
|
10
13
|
declare const Checkbox: import("svelte").Component<$$ComponentProps, {}, "checked" | "el">;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// stays as-is otherwise.
|
|
7
7
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
8
8
|
import Icon, { type IconName } from './Icon.svelte';
|
|
9
|
+
import { getFieldContext, warnUnlabelled } from '../../field-context';
|
|
9
10
|
|
|
10
11
|
// `size` shadows the native char-width attribute (unused in token-sized
|
|
11
12
|
// layouts) to expose a height preset instead.
|
|
@@ -31,6 +32,9 @@
|
|
|
31
32
|
onenter?: (value: string) => void;
|
|
32
33
|
/** Alias of `onenter`; both fire when given. */
|
|
33
34
|
onsubmit?: (value: string) => void;
|
|
35
|
+
id?: string;
|
|
36
|
+
'aria-describedby'?: string | null;
|
|
37
|
+
'aria-invalid'?: HTMLInputAttributes['aria-invalid'];
|
|
34
38
|
class?: string;
|
|
35
39
|
value?: HTMLInputAttributes['value'];
|
|
36
40
|
el?: HTMLInputElement | null;
|
|
@@ -50,12 +54,20 @@
|
|
|
50
54
|
onenter,
|
|
51
55
|
onsubmit,
|
|
52
56
|
onkeydown,
|
|
57
|
+
id,
|
|
58
|
+
'aria-describedby': ariaDescribedby,
|
|
59
|
+
'aria-invalid': ariaInvalid,
|
|
53
60
|
class: klass = '',
|
|
54
61
|
value = $bindable(),
|
|
55
62
|
el = $bindable(null),
|
|
56
63
|
...rest
|
|
57
64
|
}: Props = $props();
|
|
58
65
|
|
|
66
|
+
const field = getFieldContext();
|
|
67
|
+
const isInvalid = $derived(invalid || !!field?.invalid);
|
|
68
|
+
|
|
69
|
+
$effect(() => warnUnlabelled(el, 'Input'));
|
|
70
|
+
|
|
59
71
|
const wrapped = $derived(!!icon || clearable);
|
|
60
72
|
|
|
61
73
|
function handleKeydown(e: KeyboardEvent & { currentTarget: EventTarget & HTMLInputElement }) {
|
|
@@ -87,7 +99,9 @@
|
|
|
87
99
|
bind:value
|
|
88
100
|
{...rest}
|
|
89
101
|
onkeydown={onenter || onsubmit || onkeydown ? handleKeydown : undefined}
|
|
90
|
-
|
|
102
|
+
id={id ?? field?.id}
|
|
103
|
+
aria-describedby={ariaDescribedby ?? field?.describedBy}
|
|
104
|
+
aria-invalid={ariaInvalid ?? (isInvalid ? 'true' : undefined)}
|
|
91
105
|
/>
|
|
92
106
|
{/snippet}
|
|
93
107
|
|
|
@@ -22,6 +22,9 @@ type Props = Omit<HTMLInputAttributes, 'size'> & {
|
|
|
22
22
|
onenter?: (value: string) => void;
|
|
23
23
|
/** Alias of `onenter`; both fire when given. */
|
|
24
24
|
onsubmit?: (value: string) => void;
|
|
25
|
+
id?: string;
|
|
26
|
+
'aria-describedby'?: string | null;
|
|
27
|
+
'aria-invalid'?: HTMLInputAttributes['aria-invalid'];
|
|
25
28
|
class?: string;
|
|
26
29
|
value?: HTMLInputAttributes['value'];
|
|
27
30
|
el?: HTMLInputElement | null;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import type { Snippet } from 'svelte';
|
|
18
18
|
import type { HTMLSelectAttributes } from 'svelte/elements';
|
|
19
19
|
import Icon from './Icon.svelte';
|
|
20
|
+
import { getFieldContext, warnUnlabelled } from '../../field-context';
|
|
20
21
|
|
|
21
22
|
// `size` shadows the native option-count attribute (unused in token layouts) to
|
|
22
23
|
// expose the sm|md height scale instead.
|
|
@@ -36,6 +37,9 @@
|
|
|
36
37
|
width?: 'full' | 'auto';
|
|
37
38
|
/** Fill the available width of a flex/Cluster row (flex: 1). */
|
|
38
39
|
grow?: boolean;
|
|
40
|
+
id?: string;
|
|
41
|
+
'aria-describedby'?: string | null;
|
|
42
|
+
'aria-invalid'?: HTMLSelectAttributes['aria-invalid'];
|
|
39
43
|
class?: string;
|
|
40
44
|
value?: HTMLSelectAttributes['value'];
|
|
41
45
|
children?: Snippet;
|
|
@@ -49,18 +53,36 @@
|
|
|
49
53
|
chevron,
|
|
50
54
|
width = 'full',
|
|
51
55
|
grow = false,
|
|
56
|
+
id,
|
|
57
|
+
'aria-describedby': ariaDescribedby,
|
|
58
|
+
'aria-invalid': ariaInvalid,
|
|
52
59
|
class: klass = '',
|
|
53
60
|
value = $bindable(),
|
|
54
61
|
children,
|
|
55
62
|
...rest
|
|
56
63
|
}: Props = $props();
|
|
57
64
|
|
|
65
|
+
const field = getFieldContext();
|
|
66
|
+
const isInvalid = $derived(invalid || !!field?.invalid);
|
|
67
|
+
let el = $state<HTMLSelectElement | null>(null);
|
|
68
|
+
|
|
69
|
+
$effect(() => warnUnlabelled(el, 'Select'));
|
|
70
|
+
|
|
58
71
|
const small = $derived(compact || size === 'sm');
|
|
59
72
|
const showChevron = $derived(chevron ?? variant !== 'embedded');
|
|
60
73
|
</script>
|
|
61
74
|
|
|
62
75
|
{#if variant === 'ghost'}
|
|
63
|
-
<select
|
|
76
|
+
<select
|
|
77
|
+
bind:this={el}
|
|
78
|
+
class="select ghost {klass}"
|
|
79
|
+
data-tsu="Select"
|
|
80
|
+
bind:value
|
|
81
|
+
{...rest}
|
|
82
|
+
id={id ?? field?.id}
|
|
83
|
+
aria-describedby={ariaDescribedby ?? field?.describedBy}
|
|
84
|
+
aria-invalid={ariaInvalid ?? (isInvalid ? 'true' : undefined)}
|
|
85
|
+
>
|
|
64
86
|
{@render children?.()}
|
|
65
87
|
</select>
|
|
66
88
|
{:else}
|
|
@@ -72,6 +94,7 @@
|
|
|
72
94
|
data-tsu="Select"
|
|
73
95
|
>
|
|
74
96
|
<select
|
|
97
|
+
bind:this={el}
|
|
75
98
|
class="select"
|
|
76
99
|
class:compact={small}
|
|
77
100
|
class:select-sm={size === 'sm'}
|
|
@@ -79,7 +102,9 @@
|
|
|
79
102
|
class:embedded={variant === 'embedded'}
|
|
80
103
|
bind:value
|
|
81
104
|
{...rest}
|
|
82
|
-
|
|
105
|
+
id={id ?? field?.id}
|
|
106
|
+
aria-describedby={ariaDescribedby ?? field?.describedBy}
|
|
107
|
+
aria-invalid={ariaInvalid ?? (isInvalid ? 'true' : undefined)}
|
|
83
108
|
>
|
|
84
109
|
{@render children?.()}
|
|
85
110
|
</select>
|
|
@@ -16,6 +16,9 @@ type Props = Omit<HTMLSelectAttributes, 'size'> & {
|
|
|
16
16
|
width?: 'full' | 'auto';
|
|
17
17
|
/** Fill the available width of a flex/Cluster row (flex: 1). */
|
|
18
18
|
grow?: boolean;
|
|
19
|
+
id?: string;
|
|
20
|
+
'aria-describedby'?: string | null;
|
|
21
|
+
'aria-invalid'?: HTMLSelectAttributes['aria-invalid'];
|
|
19
22
|
class?: string;
|
|
20
23
|
value?: HTMLSelectAttributes['value'];
|
|
21
24
|
children?: Snippet;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// for-association. `bind:value` and all native attrs/events pass through.
|
|
7
7
|
// Recolor per-instance via `--slider-accent` (defaults to the theme accent).
|
|
8
8
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
9
|
+
import { getFieldContext, warnUnlabelled } from '../../field-context';
|
|
9
10
|
|
|
10
11
|
let {
|
|
11
12
|
value = $bindable(0),
|
|
@@ -15,7 +16,10 @@
|
|
|
15
16
|
label,
|
|
16
17
|
showValue = false,
|
|
17
18
|
format = (v: number) => String(v),
|
|
18
|
-
id
|
|
19
|
+
id: idProp,
|
|
20
|
+
'aria-describedby': ariaDescribedby,
|
|
21
|
+
'aria-invalid': ariaInvalid,
|
|
22
|
+
invalid = false,
|
|
19
23
|
class: klass = '',
|
|
20
24
|
el = $bindable(null),
|
|
21
25
|
...rest
|
|
@@ -27,9 +31,21 @@
|
|
|
27
31
|
label?: string;
|
|
28
32
|
showValue?: boolean;
|
|
29
33
|
format?: (v: number) => string;
|
|
34
|
+
/** Error state: aria-invalid. */
|
|
35
|
+
invalid?: boolean;
|
|
36
|
+
id?: string;
|
|
37
|
+
'aria-describedby'?: string | null;
|
|
38
|
+
'aria-invalid'?: HTMLInputAttributes['aria-invalid'];
|
|
30
39
|
el?: HTMLInputElement | null;
|
|
31
40
|
} = $props();
|
|
32
41
|
|
|
42
|
+
const field = getFieldContext();
|
|
43
|
+
const fallbackId = $props.id();
|
|
44
|
+
const id = $derived(idProp ?? field?.id ?? fallbackId);
|
|
45
|
+
const isInvalid = $derived(invalid || !!field?.invalid);
|
|
46
|
+
|
|
47
|
+
$effect(() => warnUnlabelled(el, 'Slider'));
|
|
48
|
+
|
|
33
49
|
// Fill percentage for the track gradient.
|
|
34
50
|
const pct = $derived(
|
|
35
51
|
Math.max(0, Math.min(100, ((Number(value) - +min) / (+max - +min || 1)) * 100))
|
|
@@ -47,6 +63,8 @@
|
|
|
47
63
|
bind:value
|
|
48
64
|
aria-label={label}
|
|
49
65
|
{...rest}
|
|
66
|
+
aria-describedby={ariaDescribedby ?? field?.describedBy}
|
|
67
|
+
aria-invalid={ariaInvalid ?? (isInvalid ? 'true' : undefined)}
|
|
50
68
|
/>
|
|
51
69
|
{#if showValue}
|
|
52
70
|
<output for={id} class="slider-out">{format(Number(value))}</output>
|
|
@@ -7,6 +7,11 @@ type $$ComponentProps = HTMLInputAttributes & {
|
|
|
7
7
|
label?: string;
|
|
8
8
|
showValue?: boolean;
|
|
9
9
|
format?: (v: number) => string;
|
|
10
|
+
/** Error state: aria-invalid. */
|
|
11
|
+
invalid?: boolean;
|
|
12
|
+
id?: string;
|
|
13
|
+
'aria-describedby'?: string | null;
|
|
14
|
+
'aria-invalid'?: HTMLInputAttributes['aria-invalid'];
|
|
10
15
|
el?: HTMLInputElement | null;
|
|
11
16
|
};
|
|
12
17
|
declare const Slider: import("svelte").Component<$$ComponentProps, {}, "value" | "el">;
|
|
@@ -3,14 +3,28 @@
|
|
|
3
3
|
// Owns its sizing/colors from theme tokens; the parent supplies `checked`
|
|
4
4
|
// and an `onclick` handler plus an accessible label/title.
|
|
5
5
|
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
6
|
+
import { getFieldContext } from '../../field-context';
|
|
6
7
|
|
|
7
8
|
let {
|
|
8
9
|
checked = false,
|
|
9
10
|
invalid = false,
|
|
10
11
|
label,
|
|
12
|
+
id,
|
|
13
|
+
'aria-describedby': ariaDescribedby,
|
|
14
|
+
'aria-invalid': ariaInvalid,
|
|
11
15
|
class: klass = '',
|
|
12
16
|
...rest
|
|
13
|
-
}: HTMLButtonAttributes & {
|
|
17
|
+
}: HTMLButtonAttributes & {
|
|
18
|
+
checked?: boolean;
|
|
19
|
+
invalid?: boolean;
|
|
20
|
+
label: string;
|
|
21
|
+
id?: string;
|
|
22
|
+
'aria-describedby'?: string | null;
|
|
23
|
+
'aria-invalid'?: HTMLButtonAttributes['aria-invalid'];
|
|
24
|
+
} = $props();
|
|
25
|
+
|
|
26
|
+
const field = getFieldContext();
|
|
27
|
+
const isInvalid = $derived(invalid || !!field?.invalid);
|
|
14
28
|
</script>
|
|
15
29
|
|
|
16
30
|
<button
|
|
@@ -21,7 +35,9 @@
|
|
|
21
35
|
class:on={checked}
|
|
22
36
|
role="switch"
|
|
23
37
|
aria-checked={checked}
|
|
24
|
-
|
|
38
|
+
id={id ?? field?.id}
|
|
39
|
+
aria-describedby={ariaDescribedby ?? field?.describedBy}
|
|
40
|
+
aria-invalid={ariaInvalid ?? (isInvalid ? 'true' : undefined)}
|
|
25
41
|
aria-label={label}
|
|
26
42
|
>
|
|
27
43
|
<span class="knob"></span>
|
|
@@ -3,6 +3,9 @@ type $$ComponentProps = HTMLButtonAttributes & {
|
|
|
3
3
|
checked?: boolean;
|
|
4
4
|
invalid?: boolean;
|
|
5
5
|
label: string;
|
|
6
|
+
id?: string;
|
|
7
|
+
'aria-describedby'?: string | null;
|
|
8
|
+
'aria-invalid'?: HTMLButtonAttributes['aria-invalid'];
|
|
6
9
|
};
|
|
7
10
|
declare const Switch: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
11
|
type Switch = ReturnType<typeof Switch>;
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// default whenever `onsubmit` is given).
|
|
21
21
|
import type { HTMLTextareaAttributes } from 'svelte/elements';
|
|
22
22
|
import { autoresize as autoresizeAction } from '../../autoresize';
|
|
23
|
+
import { getFieldContext, warnUnlabelled } from '../../field-context';
|
|
23
24
|
|
|
24
25
|
type Props = HTMLTextareaAttributes & {
|
|
25
26
|
mono?: boolean;
|
|
@@ -37,6 +38,9 @@
|
|
|
37
38
|
/** Fires with the current value on the `submitOn` key combo. */
|
|
38
39
|
onsubmit?: (value: string) => void;
|
|
39
40
|
submitOn?: 'enter' | 'mod-enter' | 'none';
|
|
41
|
+
id?: string;
|
|
42
|
+
'aria-describedby'?: string | null;
|
|
43
|
+
'aria-invalid'?: HTMLTextareaAttributes['aria-invalid'];
|
|
40
44
|
class?: string;
|
|
41
45
|
value?: HTMLTextareaAttributes['value'];
|
|
42
46
|
el?: HTMLTextAreaElement | null;
|
|
@@ -52,12 +56,20 @@
|
|
|
52
56
|
onsubmit,
|
|
53
57
|
submitOn = 'none',
|
|
54
58
|
onkeydown,
|
|
59
|
+
id,
|
|
60
|
+
'aria-describedby': ariaDescribedby,
|
|
61
|
+
'aria-invalid': ariaInvalid,
|
|
55
62
|
class: klass = '',
|
|
56
63
|
value = $bindable(),
|
|
57
64
|
el = $bindable(null),
|
|
58
65
|
...rest
|
|
59
66
|
}: Props = $props();
|
|
60
67
|
|
|
68
|
+
const field = getFieldContext();
|
|
69
|
+
const isInvalid = $derived(invalid || !!field?.invalid);
|
|
70
|
+
|
|
71
|
+
$effect(() => warnUnlabelled(el, 'Textarea'));
|
|
72
|
+
|
|
61
73
|
// With autoresize, only the top handle (a min-height floor) is meaningful.
|
|
62
74
|
const handleEdge = $derived(autoresize ? (resize === 'top' ? 'top' : 'none') : resize);
|
|
63
75
|
const showHandle = $derived(handleEdge !== 'none');
|
|
@@ -137,7 +149,9 @@
|
|
|
137
149
|
use:autoresizeAction={typeof value === 'string' ? value : ''}
|
|
138
150
|
{...rest}
|
|
139
151
|
onkeydown={onsubmit || onkeydown ? handleKeydown : undefined}
|
|
140
|
-
|
|
152
|
+
id={id ?? field?.id}
|
|
153
|
+
aria-describedby={ariaDescribedby ?? field?.describedBy}
|
|
154
|
+
aria-invalid={ariaInvalid ?? (isInvalid ? 'true' : undefined)}
|
|
141
155
|
></textarea>
|
|
142
156
|
{:else}
|
|
143
157
|
<textarea
|
|
@@ -150,7 +164,9 @@
|
|
|
150
164
|
bind:value
|
|
151
165
|
{...rest}
|
|
152
166
|
onkeydown={onsubmit || onkeydown ? handleKeydown : undefined}
|
|
153
|
-
|
|
167
|
+
id={id ?? field?.id}
|
|
168
|
+
aria-describedby={ariaDescribedby ?? field?.describedBy}
|
|
169
|
+
aria-invalid={ariaInvalid ?? (isInvalid ? 'true' : undefined)}
|
|
154
170
|
></textarea>
|
|
155
171
|
{/if}
|
|
156
172
|
{#if showHandle}
|
|
@@ -15,6 +15,9 @@ type Props = HTMLTextareaAttributes & {
|
|
|
15
15
|
/** Fires with the current value on the `submitOn` key combo. */
|
|
16
16
|
onsubmit?: (value: string) => void;
|
|
17
17
|
submitOn?: 'enter' | 'mod-enter' | 'none';
|
|
18
|
+
id?: string;
|
|
19
|
+
'aria-describedby'?: string | null;
|
|
20
|
+
'aria-invalid'?: HTMLTextareaAttributes['aria-invalid'];
|
|
18
21
|
class?: string;
|
|
19
22
|
value?: HTMLTextareaAttributes['value'];
|
|
20
23
|
el?: HTMLTextAreaElement | null;
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
// Form field wrapper: a label above a slotted control, with optional hint and
|
|
3
3
|
// error text. Replaces the ad-hoc `<div class="field"><label class="label">`
|
|
4
|
-
// markup.
|
|
5
|
-
//
|
|
4
|
+
// markup. The label is a real <label for>; when `for` is omitted a generated
|
|
5
|
+
// id is published via field context so the slotted control adopts it (plus
|
|
6
|
+
// aria-describedby for hint/error and aria-invalid) automatically.
|
|
6
7
|
// `layout="inline"` puts the label beside the control (fixed `labelWidth`
|
|
7
8
|
// aligns a column of them); hint/error then follow the control on the row.
|
|
8
9
|
import type { Snippet } from 'svelte';
|
|
10
|
+
import { setFieldContext } from '../../field-context';
|
|
9
11
|
|
|
10
12
|
let {
|
|
11
13
|
label,
|
|
12
|
-
for:
|
|
14
|
+
for: forProp,
|
|
13
15
|
hint,
|
|
14
16
|
error,
|
|
15
17
|
layout = 'stack',
|
|
@@ -30,21 +32,37 @@
|
|
|
30
32
|
class?: string;
|
|
31
33
|
children?: Snippet;
|
|
32
34
|
} = $props();
|
|
35
|
+
|
|
36
|
+
const uid = $props.id();
|
|
37
|
+
const forId = $derived(forProp ?? uid);
|
|
38
|
+
const hintId = $derived(`${forId}-hint`);
|
|
39
|
+
const errorId = $derived(`${forId}-err`);
|
|
40
|
+
const describedBy = $derived(
|
|
41
|
+
[hint ? hintId : null, error ? errorId : null].filter(Boolean).join(' ') || undefined
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
setFieldContext({
|
|
45
|
+
get id() {
|
|
46
|
+
return forId;
|
|
47
|
+
},
|
|
48
|
+
get describedBy() {
|
|
49
|
+
return describedBy;
|
|
50
|
+
},
|
|
51
|
+
get invalid() {
|
|
52
|
+
return !!error;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
33
55
|
</script>
|
|
34
56
|
|
|
35
57
|
<div class="field {klass}" class:field-grow={grow} class:field-inline={layout === 'inline'} data-tsu="Field">
|
|
36
58
|
{#if label}
|
|
37
|
-
{
|
|
38
|
-
<label class="label" for={forId} style:width={labelWidth}>{label}</label>
|
|
39
|
-
{:else}
|
|
40
|
-
<span class="label" style:width={labelWidth}>{label}</span>
|
|
41
|
-
{/if}
|
|
59
|
+
<label class="label" for={forId} style:width={labelWidth}>{label}</label>
|
|
42
60
|
{/if}
|
|
43
61
|
{@render children?.()}
|
|
44
62
|
{#if hint}
|
|
45
|
-
<span class="hint">{#if typeof hint === 'function'}{@render hint()}{:else}{hint}{/if}</span>
|
|
63
|
+
<span class="hint" id={hintId}>{#if typeof hint === 'function'}{@render hint()}{:else}{hint}{/if}</span>
|
|
46
64
|
{/if}
|
|
47
|
-
{#if error}<span class="error">{error}</span>{/if}
|
|
65
|
+
{#if error}<span class="error" id={errorId}>{error}</span>{/if}
|
|
48
66
|
</div>
|
|
49
67
|
|
|
50
68
|
<style>
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
import { parse } from '../../query/parser';
|
|
44
44
|
import { type Schema } from '../../query/schema';
|
|
45
45
|
import { suggest, type SuggestState } from '../../query/suggest';
|
|
46
|
+
import { getFieldContext, warnUnlabelled } from '../../field-context';
|
|
46
47
|
|
|
47
48
|
let {
|
|
48
49
|
schema,
|
|
@@ -57,6 +58,9 @@
|
|
|
57
58
|
hotkey,
|
|
58
59
|
showHotkey = false,
|
|
59
60
|
grow = false,
|
|
61
|
+
id,
|
|
62
|
+
'aria-describedby': ariaDescribedby,
|
|
63
|
+
'aria-invalid': ariaInvalid,
|
|
60
64
|
onchange,
|
|
61
65
|
onsubmit,
|
|
62
66
|
inline,
|
|
@@ -88,6 +92,9 @@
|
|
|
88
92
|
showHotkey?: boolean;
|
|
89
93
|
/** Fill the available width of a flex/Cluster row (flex: 1). */
|
|
90
94
|
grow?: boolean;
|
|
95
|
+
id?: string;
|
|
96
|
+
'aria-describedby'?: string | null;
|
|
97
|
+
'aria-invalid'?: 'true' | 'false' | boolean | null;
|
|
91
98
|
/** Fires the parsed AST on every change — feed this to your backend. */
|
|
92
99
|
onchange?: (query: Query, raw: string) => void;
|
|
93
100
|
/** Fires on Enter / clear / chip-remove with the raw query string. */
|
|
@@ -105,6 +112,7 @@
|
|
|
105
112
|
below?: Snippet<[FilterInputContext]>;
|
|
106
113
|
} = $props();
|
|
107
114
|
|
|
115
|
+
const field = getFieldContext();
|
|
108
116
|
let el = $state<HTMLInputElement | null>(null);
|
|
109
117
|
let menuEl = $state<HTMLDivElement | null>(null);
|
|
110
118
|
let menu = $state<SuggestState | null>(null);
|
|
@@ -288,6 +296,8 @@
|
|
|
288
296
|
queueMicrotask(() => el?.focus());
|
|
289
297
|
}
|
|
290
298
|
|
|
299
|
+
$effect(() => warnUnlabelled(el, 'FilterInput'));
|
|
300
|
+
|
|
291
301
|
const kindLabel: Record<string, string> = {
|
|
292
302
|
field: 'Fields',
|
|
293
303
|
operator: 'Operators',
|
|
@@ -311,6 +321,9 @@
|
|
|
311
321
|
bind:this={el}
|
|
312
322
|
bind:value
|
|
313
323
|
class="fi__input"
|
|
324
|
+
id={id ?? field?.id}
|
|
325
|
+
aria-describedby={ariaDescribedby ?? field?.describedBy}
|
|
326
|
+
aria-invalid={ariaInvalid ?? (field?.invalid ? 'true' : undefined)}
|
|
314
327
|
spellcheck="false"
|
|
315
328
|
autocomplete="off"
|
|
316
329
|
{placeholder}
|
|
@@ -48,6 +48,9 @@ type $$ComponentProps = {
|
|
|
48
48
|
showHotkey?: boolean;
|
|
49
49
|
/** Fill the available width of a flex/Cluster row (flex: 1). */
|
|
50
50
|
grow?: boolean;
|
|
51
|
+
id?: string;
|
|
52
|
+
'aria-describedby'?: string | null;
|
|
53
|
+
'aria-invalid'?: 'true' | 'false' | boolean | null;
|
|
51
54
|
/** Fires the parsed AST on every change — feed this to your backend. */
|
|
52
55
|
onchange?: (query: Query, raw: string) => void;
|
|
53
56
|
/** Fires on Enter / clear / chip-remove with the raw query string. */
|
|
@@ -14,21 +14,52 @@
|
|
|
14
14
|
// click). Selecting an item runs its action and closes the menu. Focus moves
|
|
15
15
|
// to the first item when the menu opens. Escape / click-outside close it
|
|
16
16
|
// (inherited from the native popover).
|
|
17
|
-
import type { Snippet } from 'svelte';
|
|
17
|
+
import type { ComponentProps, Snippet } from 'svelte';
|
|
18
18
|
import Popover from './Popover.svelte';
|
|
19
19
|
import Icon from '../atoms/Icon.svelte';
|
|
20
20
|
|
|
21
|
+
type PopoverProps = ComponentProps<typeof Popover>;
|
|
22
|
+
type TriggerChrome = Pick<
|
|
23
|
+
PopoverProps,
|
|
24
|
+
| 'variant'
|
|
25
|
+
| 'tone'
|
|
26
|
+
| 'size'
|
|
27
|
+
| 'box'
|
|
28
|
+
| 'control'
|
|
29
|
+
| 'block'
|
|
30
|
+
| 'triggerClass'
|
|
31
|
+
| 'bare'
|
|
32
|
+
| 'hitArea'
|
|
33
|
+
| 'disabled'
|
|
34
|
+
| 'gap'
|
|
35
|
+
| 'onopen'
|
|
36
|
+
| 'onclose'
|
|
37
|
+
>;
|
|
38
|
+
|
|
21
39
|
let {
|
|
22
40
|
label,
|
|
23
41
|
items,
|
|
24
42
|
trigger,
|
|
25
|
-
placement = 'bottom-start'
|
|
43
|
+
placement = 'bottom-start',
|
|
44
|
+
variant,
|
|
45
|
+
tone,
|
|
46
|
+
size,
|
|
47
|
+
box,
|
|
48
|
+
control,
|
|
49
|
+
block,
|
|
50
|
+
triggerClass,
|
|
51
|
+
bare,
|
|
52
|
+
hitArea,
|
|
53
|
+
disabled,
|
|
54
|
+
gap,
|
|
55
|
+
onopen,
|
|
56
|
+
onclose
|
|
26
57
|
}: {
|
|
27
58
|
label: string;
|
|
28
59
|
items: MenuItem[];
|
|
29
60
|
trigger: Snippet;
|
|
30
61
|
placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
|
|
31
|
-
} = $props();
|
|
62
|
+
} & TriggerChrome = $props();
|
|
32
63
|
|
|
33
64
|
let listEl = $state<HTMLDivElement | null>(null);
|
|
34
65
|
|
|
@@ -66,7 +97,27 @@
|
|
|
66
97
|
}
|
|
67
98
|
</script>
|
|
68
99
|
|
|
69
|
-
<Popover
|
|
100
|
+
<Popover
|
|
101
|
+
{label}
|
|
102
|
+
{placement}
|
|
103
|
+
{trigger}
|
|
104
|
+
{variant}
|
|
105
|
+
{tone}
|
|
106
|
+
{size}
|
|
107
|
+
{box}
|
|
108
|
+
{control}
|
|
109
|
+
{block}
|
|
110
|
+
{triggerClass}
|
|
111
|
+
{bare}
|
|
112
|
+
{hitArea}
|
|
113
|
+
{disabled}
|
|
114
|
+
{gap}
|
|
115
|
+
{onclose}
|
|
116
|
+
onopen={() => {
|
|
117
|
+
queueMicrotask(() => focusAt(0));
|
|
118
|
+
onopen?.();
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
70
121
|
<div bind:this={listEl} role="menu" aria-label={label} class="menu" data-tsu="Menu" tabindex="-1" {onkeydown}>
|
|
71
122
|
{#each items as item (item.label)}
|
|
72
123
|
<button
|
|
@@ -5,13 +5,16 @@ export interface MenuItem {
|
|
|
5
5
|
danger?: boolean;
|
|
6
6
|
disabled?: boolean;
|
|
7
7
|
}
|
|
8
|
-
import type { Snippet } from 'svelte';
|
|
8
|
+
import type { ComponentProps, Snippet } from 'svelte';
|
|
9
|
+
import Popover from './Popover.svelte';
|
|
10
|
+
type PopoverProps = ComponentProps<typeof Popover>;
|
|
11
|
+
type TriggerChrome = Pick<PopoverProps, 'variant' | 'tone' | 'size' | 'box' | 'control' | 'block' | 'triggerClass' | 'bare' | 'hitArea' | 'disabled' | 'gap' | 'onopen' | 'onclose'>;
|
|
9
12
|
type $$ComponentProps = {
|
|
10
13
|
label: string;
|
|
11
14
|
items: MenuItem[];
|
|
12
15
|
trigger: Snippet;
|
|
13
16
|
placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
|
|
14
|
-
};
|
|
17
|
+
} & TriggerChrome;
|
|
15
18
|
declare const Menu: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
16
19
|
type Menu = ReturnType<typeof Menu>;
|
|
17
20
|
export default Menu;
|
|
@@ -35,7 +35,11 @@
|
|
|
35
35
|
label = 'Options',
|
|
36
36
|
// `mobile` hides segment labels below the mobile breakpoint, collapsing
|
|
37
37
|
// labelled segments to centered icon-only squares (aria-label preserved).
|
|
38
|
+
// `container` does the same when the nearest size container is at most
|
|
39
|
+
// 35rem wide (the breakpoint is fixed: `@container` cannot read a var).
|
|
38
40
|
collapseLabels = 'never',
|
|
41
|
+
scroll = false,
|
|
42
|
+
block = false,
|
|
39
43
|
class: klass = '',
|
|
40
44
|
// Custom rendering per option (overrides the built-in label/count/icon).
|
|
41
45
|
option: optionSnippet
|
|
@@ -48,7 +52,11 @@
|
|
|
48
52
|
* `control`) so the whole segmented control height-matches its siblings. */
|
|
49
53
|
control?: boolean;
|
|
50
54
|
label?: string;
|
|
51
|
-
collapseLabels?: 'never' | 'mobile';
|
|
55
|
+
collapseLabels?: 'never' | 'mobile' | 'container';
|
|
56
|
+
/** Single non-wrapping row that scrolls horizontally (scrollbar hidden). */
|
|
57
|
+
scroll?: boolean;
|
|
58
|
+
/** Fill the parent width, sharing it equally between segments. */
|
|
59
|
+
block?: boolean;
|
|
52
60
|
class?: string;
|
|
53
61
|
option?: Snippet<[SegmentOption, boolean]>;
|
|
54
62
|
} = $props();
|
|
@@ -90,6 +98,9 @@
|
|
|
90
98
|
class="seg seg-{variant} seg-{size} {klass}"
|
|
91
99
|
class:seg-control={control}
|
|
92
100
|
class:seg-collapse-mobile={collapseLabels === 'mobile'}
|
|
101
|
+
class:collapse-container={collapseLabels === 'container'}
|
|
102
|
+
class:seg-scroll={scroll}
|
|
103
|
+
class:seg-block={block}
|
|
93
104
|
data-tsu="SegmentedControl"
|
|
94
105
|
{onkeydown}
|
|
95
106
|
>
|
|
@@ -132,6 +143,28 @@
|
|
|
132
143
|
.seg-icon {
|
|
133
144
|
border-radius: var(--r-md);
|
|
134
145
|
}
|
|
146
|
+
.seg-scroll {
|
|
147
|
+
flex: 1 0 100%;
|
|
148
|
+
flex-wrap: nowrap;
|
|
149
|
+
max-width: 100%;
|
|
150
|
+
overflow-x: auto;
|
|
151
|
+
scrollbar-width: none;
|
|
152
|
+
-webkit-overflow-scrolling: touch;
|
|
153
|
+
}
|
|
154
|
+
.seg-scroll::-webkit-scrollbar {
|
|
155
|
+
display: none;
|
|
156
|
+
}
|
|
157
|
+
.seg-scroll .seg-item {
|
|
158
|
+
flex: none;
|
|
159
|
+
}
|
|
160
|
+
.seg-block {
|
|
161
|
+
display: flex;
|
|
162
|
+
width: 100%;
|
|
163
|
+
}
|
|
164
|
+
.seg-block .seg-item {
|
|
165
|
+
flex: 1 1 0;
|
|
166
|
+
justify-content: center;
|
|
167
|
+
}
|
|
135
168
|
|
|
136
169
|
.seg-item {
|
|
137
170
|
display: inline-flex;
|
|
@@ -209,6 +242,18 @@
|
|
|
209
242
|
padding: 0.25rem;
|
|
210
243
|
}
|
|
211
244
|
}
|
|
245
|
+
@container (max-width: 35rem) {
|
|
246
|
+
.collapse-container .seg-item.has-label .seg-label {
|
|
247
|
+
display: none;
|
|
248
|
+
}
|
|
249
|
+
.collapse-container .seg-item.has-label {
|
|
250
|
+
gap: 0;
|
|
251
|
+
padding: 0.35rem;
|
|
252
|
+
}
|
|
253
|
+
.collapse-container.seg-sm .seg-item.has-label {
|
|
254
|
+
padding: 0.25rem;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
212
257
|
|
|
213
258
|
.seg-item:hover:not(:disabled):not(.selected) {
|
|
214
259
|
color: var(--text);
|
|
@@ -19,7 +19,11 @@ type $$ComponentProps = {
|
|
|
19
19
|
* `control`) so the whole segmented control height-matches its siblings. */
|
|
20
20
|
control?: boolean;
|
|
21
21
|
label?: string;
|
|
22
|
-
collapseLabels?: 'never' | 'mobile';
|
|
22
|
+
collapseLabels?: 'never' | 'mobile' | 'container';
|
|
23
|
+
/** Single non-wrapping row that scrolls horizontally (scrollbar hidden). */
|
|
24
|
+
scroll?: boolean;
|
|
25
|
+
/** Fill the parent width, sharing it equally between segments. */
|
|
26
|
+
block?: boolean;
|
|
23
27
|
class?: string;
|
|
24
28
|
option?: Snippet<[SegmentOption, boolean]>;
|
|
25
29
|
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const FIELD_KEY: unique symbol;
|
|
2
|
+
export type FieldContext = {
|
|
3
|
+
id: string;
|
|
4
|
+
describedBy?: string;
|
|
5
|
+
invalid: boolean;
|
|
6
|
+
};
|
|
7
|
+
export declare function setFieldContext(ctx: FieldContext): FieldContext;
|
|
8
|
+
export declare function getFieldContext(): FieldContext | undefined;
|
|
9
|
+
export declare function warnUnlabelled(el: HTMLElement | null | undefined, name: string): void;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
export const FIELD_KEY = Symbol('tsu-field');
|
|
3
|
+
export function setFieldContext(ctx) {
|
|
4
|
+
return setContext(FIELD_KEY, ctx);
|
|
5
|
+
}
|
|
6
|
+
export function getFieldContext() {
|
|
7
|
+
return getContext(FIELD_KEY);
|
|
8
|
+
}
|
|
9
|
+
const warned = new WeakSet();
|
|
10
|
+
export function warnUnlabelled(el, name) {
|
|
11
|
+
if (!import.meta.env.DEV || !el || typeof document === 'undefined')
|
|
12
|
+
return;
|
|
13
|
+
if (warned.has(el))
|
|
14
|
+
return;
|
|
15
|
+
if (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby'))
|
|
16
|
+
return;
|
|
17
|
+
if (el.closest('label'))
|
|
18
|
+
return;
|
|
19
|
+
const id = el.id;
|
|
20
|
+
if (id && document.querySelector(`label[for="${CSS.escape(id)}"]`))
|
|
21
|
+
return;
|
|
22
|
+
warned.add(el);
|
|
23
|
+
console.warn(`[tsumikit] <${name}> rendered without an accessible label`);
|
|
24
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -66,6 +66,7 @@ export { default as Tooltip } from './components/molecules/Tooltip.svelte';
|
|
|
66
66
|
export { default as Truncate } from './components/molecules/Truncate.svelte';
|
|
67
67
|
export { type Column, default as DataTable, type RowTone, } from './components/organisms/DataTable.svelte';
|
|
68
68
|
export { default as FilterSearchBar } from './components/organisms/FilterSearchBar.svelte';
|
|
69
|
+
export { FIELD_KEY, type FieldContext, getFieldContext, setFieldContext, warnUnlabelled, } from './field-context';
|
|
69
70
|
export { type AndNode, activeToken, compilePredicate, defaultOperator, type ExprNode, type FieldDef, type FieldType, type FilterNode, filters, findField, freeText, type LeafNode, type NotNode, OPERATORS, type Operator, type OperatorId, type OrNode, operatorByCode, operatorById, operatorsFor, parse, type Query, type QueryNode, resolveValues, type Schema, type Suggestion, type SuggestKind, type SuggestState, serialize, serializeFilter, suggest, type TextNode, toSql, type ValueContext, type ValueOption, type ValueProvider, walk, } from './query';
|
|
70
71
|
export { fontScale, SCALE_LEVELS, type ScaleLevel } from './stores/fontscale.svelte';
|
|
71
72
|
export { type Mode, THEMES, theme } from './stores/theme.svelte';
|
package/dist/index.js
CHANGED
|
@@ -77,6 +77,7 @@ export { default as Truncate } from './components/molecules/Truncate.svelte';
|
|
|
77
77
|
// ---- organisms ----
|
|
78
78
|
export { default as DataTable, } from './components/organisms/DataTable.svelte';
|
|
79
79
|
export { default as FilterSearchBar } from './components/organisms/FilterSearchBar.svelte';
|
|
80
|
+
export { FIELD_KEY, getFieldContext, setFieldContext, warnUnlabelled, } from './field-context';
|
|
80
81
|
// ---- query core (headless: schema / parser / AST / suggest / compilers) ----
|
|
81
82
|
export { activeToken, compilePredicate, defaultOperator, filters, findField, freeText, OPERATORS, operatorByCode, operatorById, operatorsFor, parse, resolveValues, serialize, serializeFilter, suggest, toSql, walk, } from './query';
|
|
82
83
|
export { fontScale, SCALE_LEVELS } from './stores/fontscale.svelte';
|
package/package.json
CHANGED