@dorsk/tsumikit 0.33.0 → 0.35.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.
@@ -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 bind:this={el} type="checkbox" bind:checked {...rest} aria-invalid={invalid || undefined} />
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
- aria-invalid={invalid || undefined}
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 class="select ghost {klass}" data-tsu="Select" bind:value {...rest} aria-invalid={invalid || undefined}>
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
- aria-invalid={invalid || undefined}
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 = `slider-${Math.random().toString(36).slice(2, 8)}`,
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 & { checked?: boolean; invalid?: boolean; label: string } = $props();
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
- aria-invalid={invalid || undefined}
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
- aria-invalid={invalid || undefined}
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
- aria-invalid={invalid || undefined}
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. Renders a real <label for> when `for` is given (associates with the
5
- // control), else a plain <span> for control groups (radio/segment rows).
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: forId,
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
- {#if forId}
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. */
@@ -9,7 +9,7 @@
9
9
  </script>
10
10
 
11
11
  <script lang="ts">
12
- // Dropdown menu: a Popover whose panel is a WAI-ARIA `menu`. Items are
12
+ // Dropdown menu: a Popover whose panel carries the WAI-ARIA `menu` role. Items are
13
13
  // `menuitem`s navigable with ↑/↓/Home/End, activated with Enter/Space (and
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
@@ -112,13 +112,15 @@
112
112
  {hitArea}
113
113
  {disabled}
114
114
  {gap}
115
+ role="menu"
116
+ haspopup="menu"
115
117
  {onclose}
116
118
  onopen={() => {
117
119
  queueMicrotask(() => focusAt(0));
118
120
  onopen?.();
119
121
  }}
120
122
  >
121
- <div bind:this={listEl} role="menu" aria-label={label} class="menu" data-tsu="Menu" tabindex="-1" {onkeydown}>
123
+ <div bind:this={listEl} role="none" class="menu" data-tsu="Menu" tabindex="-1" {onkeydown}>
122
124
  {#each items as item (item.label)}
123
125
  <button
124
126
  type="button"
@@ -1,11 +1,12 @@
1
1
  <script lang="ts">
2
2
  // Floating panel built on the native Popover API. The platform gives us the
3
3
  // top layer (no z-index races, renders above dialogs/sticky headers), light
4
- // dismiss (click-outside), Escape-to-close and correct focus/`aria-expanded`
5
- // wiring between the invoker and the panel — all declaratively via the
6
- // `popovertarget` / `popover` attributes. We add only smart placement:
7
- // position the panel against its trigger and flip into the viewport when it
8
- // would overflow. (CSS anchor positioning would replace the JS here once it
4
+ // dismiss (click-outside), Escape-to-close and focus wiring between the
5
+ // invoker and the panel — declaratively via the `popovertarget` / `popover`
6
+ // attributes. We add explicit `aria-expanded` / `aria-haspopup` on the
7
+ // trigger (the implicit popover semantics are not exposed uniformly by
8
+ // assistive tech), the panel role, and smart placement: position the panel
9
+ // against its trigger and flip into the viewport when it would overflow. (CSS anchor positioning would replace the JS here once it
9
10
  // ships beyond Chromium; the popover semantics above are the hard part and
10
11
  // are broadly supported today.)
11
12
  import { tick, type Snippet } from 'svelte';
@@ -15,6 +16,8 @@
15
16
  type TriggerVariant = 'default' | 'primary' | 'ghost' | 'danger';
16
17
  type TriggerTone = 'none' | 'accent' | 'success' | 'info' | 'warn' | 'danger';
17
18
  type TriggerSize = 'sm' | 'md' | 'lg';
19
+ type PanelRole = 'dialog' | 'menu' | 'listbox' | 'group';
20
+ type HasPopup = 'menu' | 'dialog' | 'listbox' | true;
18
21
 
19
22
  let {
20
23
  placement = 'bottom-start',
@@ -32,6 +35,8 @@
32
35
  block = false,
33
36
  hitArea = 'auto',
34
37
  disabled = false,
38
+ role = 'dialog',
39
+ haspopup = 'dialog',
35
40
  onopen,
36
41
  onclose
37
42
  }: {
@@ -63,6 +68,10 @@
63
68
  /** Icon-only triggers grow a 44px hit slab on coarse pointers; `compact` opts out. */
64
69
  hitArea?: 'auto' | 'compact';
65
70
  disabled?: boolean;
71
+ /** ARIA role of the panel. */
72
+ role?: PanelRole;
73
+ /** `aria-haspopup` announced on the trigger. */
74
+ haspopup?: HasPopup;
66
75
  onopen?: () => void;
67
76
  onclose?: () => void;
68
77
  } = $props();
@@ -78,6 +87,7 @@
78
87
  // reopening is instant. The panel element itself always renders — the native
79
88
  // `popovertarget` wiring needs its id present in the DOM at all times.
80
89
  let opened = $state(false);
90
+ let open = $state(false);
81
91
 
82
92
  function reposition() {
83
93
  if (triggerEl && panelEl) place(triggerEl, panelEl, placement, gap);
@@ -87,6 +97,7 @@
87
97
  if (e.newState === 'open') {
88
98
  const firstOpen = !opened;
89
99
  opened = true;
100
+ open = true;
90
101
  // On the first open the content snippet mounts this tick; measure the
91
102
  // panel only once it has, so placement accounts for its real size.
92
103
  if (firstOpen) await tick();
@@ -95,6 +106,7 @@
95
106
  addEventListener('resize', reposition);
96
107
  onopen?.();
97
108
  } else {
109
+ open = false;
98
110
  removeEventListener('scroll', reposition, true);
99
111
  removeEventListener('resize', reposition);
100
112
  onclose?.();
@@ -126,6 +138,8 @@
126
138
  class:trigger-tone-danger={tone === 'danger'}
127
139
  popovertarget={id}
128
140
  aria-label={label}
141
+ aria-haspopup={haspopup}
142
+ aria-expanded={open}
129
143
  {disabled}
130
144
  >
131
145
  {@render trigger()}
@@ -136,7 +150,7 @@
136
150
  {id}
137
151
  popover="auto"
138
152
  class="pop-panel"
139
- role="group"
153
+ {role}
140
154
  aria-label={label}
141
155
  ontoggle={onToggle}
142
156
  >
@@ -3,6 +3,8 @@ type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
3
3
  type TriggerVariant = 'default' | 'primary' | 'ghost' | 'danger';
4
4
  type TriggerTone = 'none' | 'accent' | 'success' | 'info' | 'warn' | 'danger';
5
5
  type TriggerSize = 'sm' | 'md' | 'lg';
6
+ type PanelRole = 'dialog' | 'menu' | 'listbox' | 'group';
7
+ type HasPopup = 'menu' | 'dialog' | 'listbox' | true;
6
8
  type $$ComponentProps = {
7
9
  placement?: Placement;
8
10
  gap?: number;
@@ -32,6 +34,10 @@ type $$ComponentProps = {
32
34
  /** Icon-only triggers grow a 44px hit slab on coarse pointers; `compact` opts out. */
33
35
  hitArea?: 'auto' | 'compact';
34
36
  disabled?: boolean;
37
+ /** ARIA role of the panel. */
38
+ role?: PanelRole;
39
+ /** `aria-haspopup` announced on the trigger. */
40
+ haspopup?: HasPopup;
35
41
  onopen?: () => void;
36
42
  onclose?: () => void;
37
43
  };
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
4
4
  "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
5
5
  "type": "module",
6
6
  "license": "MIT",