@streamscloud/kit 0.89.0 → 0.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +2 -1
  2. package/dist/core/utils/html-helper.d.ts +10 -0
  3. package/dist/core/utils/html-helper.js +37 -0
  4. package/dist/core/utils/string-helper.d.ts +10 -0
  5. package/dist/core/utils/string-helper.js +14 -0
  6. package/dist/core/validation/form-validation-handler/form-validation-handler.svelte.d.ts +4 -2
  7. package/dist/core/validation/form-validation-handler/form-validation-handler.svelte.js +1 -1
  8. package/dist/ui/_internal/form-field-label-context/form-field-label-context.svelte.d.ts +15 -0
  9. package/dist/ui/_internal/form-field-label-context/form-field-label-context.svelte.js +20 -0
  10. package/dist/ui/_internal/form-field-label-context/index.d.ts +1 -0
  11. package/dist/ui/_internal/form-field-label-context/index.js +1 -0
  12. package/dist/ui/checkbox/cmp.checkbox.svelte +11 -2
  13. package/dist/ui/chip-group/cmp.chip-group.svelte +10 -3
  14. package/dist/ui/chip-group/cmp.chip-group.svelte.d.ts +4 -2
  15. package/dist/ui/color-picker/cmp.color-picker.svelte +12 -2
  16. package/dist/ui/color-picker/cmp.color-picker.svelte.d.ts +1 -3
  17. package/dist/ui/date-picker/cmp.date-picker.svelte +6 -4
  18. package/dist/ui/date-picker/cmp.date-picker.svelte.d.ts +2 -9
  19. package/dist/ui/date-picker/cmp.date-time-picker.svelte +19 -3
  20. package/dist/ui/date-picker/cmp.date-time-picker.svelte.d.ts +3 -5
  21. package/dist/ui/date-picker/cmp.time-picker.svelte +3 -1
  22. package/dist/ui/date-picker/cmp.time-picker.svelte.d.ts +1 -4
  23. package/dist/ui/document-tabs/types.d.ts +0 -1
  24. package/dist/ui/form-field/cmp.form-field-validatable.svelte +4 -4
  25. package/dist/ui/form-field/cmp.form-field-validatable.svelte.d.ts +4 -7
  26. package/dist/ui/form-field/cmp.form-field.svelte +31 -79
  27. package/dist/ui/form-field/cmp.form-field.svelte.d.ts +11 -10
  28. package/dist/ui/form-field/form-field-label.svelte.d.ts +6 -0
  29. package/dist/ui/form-field/form-field-label.svelte.js +19 -0
  30. package/dist/ui/form-field/index.d.ts +1 -1
  31. package/dist/ui/icon-picker/cmp.icon-picker.svelte +3 -1
  32. package/dist/ui/icon-picker/cmp.icon-picker.svelte.d.ts +3 -1
  33. package/dist/ui/input/cmp.input.svelte +18 -2
  34. package/dist/ui/input/cmp.input.svelte.d.ts +3 -0
  35. package/dist/ui/input/types.d.ts +0 -3
  36. package/dist/ui/numeral-input/cmp.numeral-input.svelte +6 -2
  37. package/dist/ui/numeral-input/cmp.numeral-input.svelte.d.ts +0 -4
  38. package/dist/ui/option-pill/cmp.option-pill.svelte.d.ts +0 -2
  39. package/dist/ui/page-layout/cmp.page-layout.svelte.d.ts +1 -2
  40. package/dist/ui/pin-input/cmp.pin-input.svelte +11 -3
  41. package/dist/ui/pin-input/cmp.pin-input.svelte.d.ts +1 -1
  42. package/dist/ui/popover/cmp.popover.svelte +2 -1
  43. package/dist/ui/popover/cmp.popover.svelte.d.ts +2 -6
  44. package/dist/ui/radio/cmp.radio.svelte +12 -3
  45. package/dist/ui/rich-text-input/cmp.rich-text-input.svelte +19 -7
  46. package/dist/ui/rich-text-input/cmp.rich-text-input.svelte.d.ts +1 -2
  47. package/dist/ui/segmented-control/cmp.segmented-control.svelte +5 -3
  48. package/dist/ui/segmented-control/cmp.segmented-control.svelte.d.ts +1 -2
  49. package/dist/ui/select/cmp.input-suggest.svelte +5 -1
  50. package/dist/ui/select/cmp.input-suggest.svelte.d.ts +0 -3
  51. package/dist/ui/select/cmp.multiselect-tree.svelte +2 -1
  52. package/dist/ui/select/cmp.multiselect-tree.svelte.d.ts +1 -3
  53. package/dist/ui/select/cmp.singleselect-async.svelte +5 -2
  54. package/dist/ui/select/cmp.singleselect-async.svelte.d.ts +0 -2
  55. package/dist/ui/select/cmp.singleselect.svelte +3 -1
  56. package/dist/ui/select/cmp.singleselect.svelte.d.ts +0 -2
  57. package/dist/ui/select/multiselect-base.svelte +6 -2
  58. package/dist/ui/select/select-core.svelte.js +0 -2
  59. package/dist/ui/select/select-listbox.svelte +1 -5
  60. package/dist/ui/select/types.d.ts +2 -6
  61. package/dist/ui/slider/cmp.slider.svelte +6 -2
  62. package/dist/ui/slider/cmp.slider.svelte.d.ts +1 -0
  63. package/dist/ui/tabs/types.d.ts +0 -1
  64. package/dist/ui/textarea/cmp.textarea.svelte +16 -16
  65. package/dist/ui/textarea/cmp.textarea.svelte.d.ts +4 -2
  66. package/dist/ui/toggle/cmp.toggle.svelte +4 -2
  67. package/dist/ui/validatable/cmp.validatable.svelte +1 -2
  68. package/dist/ui/validatable/cmp.validatable.svelte.d.ts +1 -2
  69. package/dist/ui/video/cmp.video.svelte.d.ts +0 -1
  70. package/dist/ui/visual-select/cmp.visual-select.svelte +11 -3
  71. package/dist/ui/visual-select/cmp.visual-select.svelte.d.ts +2 -1
  72. package/package.json +3 -2
@@ -1,83 +1,30 @@
1
- <script lang="ts" module>"use strict";
2
- // HTML's interactive-content list, verbatim: exactly what makes the browser skip label activation. A wider selector (role, tabindex) skips the guard on clicks the browser still forwards.
3
- const INTERACTIVE_CONTENT = 'a[href], audio[controls], button, details, embed, iframe, img[usemap], input:not([type="hidden"]), label, select, textarea, video[controls]';
1
+ <script lang="ts" module>export {};
4
2
  </script>
5
3
 
6
- <script lang="ts">import { Icon } from '../icon';
4
+ <script lang="ts">import { provideFormFieldLabel } from '../_internal/form-field-label-context';
5
+ import { Icon } from '../icon';
7
6
  import { Tooltip } from '../tooltip';
7
+ import { createFormFieldLabel } from './form-field-label.svelte';
8
8
  import IconInfo from '@fluentui/svg-icons/icons/info_20_regular.svg?raw';
9
9
  let { label, hint, required = false, children } = $props();
10
- let rootEl = $state.raw(undefined);
11
- let swallowLabelActivation = false;
12
- // Unhandled, the click activates the labelled field (a checkbox would toggle); cancelling it on interactive content would kill a link's navigation.
13
- const swallowClick = (event) => {
14
- event.stopPropagation();
15
- // This click never reaches guardLabelActivation, so the flag would outlive the gesture and swallow a later keyboard-activated click.
16
- swallowLabelActivation = false;
17
- if (forwardsLabelActivation(event.target)) {
18
- event.preventDefault();
19
- }
20
- };
21
- const forwardsLabelActivation = (target) => {
22
- if (!(target instanceof Element)) {
23
- return false;
24
- }
25
- const interactive = target.closest(INTERACTIVE_CONTENT);
26
- return !interactive || interactive === rootEl;
27
- };
28
- // Registered at mount, so it precedes the capture-phase dismissal a Popover registers on open: by click time the popup has closed and the forwarded activation would reopen it.
29
- $effect(() => {
30
- const armLabelActivation = (event) => {
31
- swallowLabelActivation = false;
32
- if (event.button !== 0 || !rootEl || !(event.target instanceof Node) || !rootEl.contains(event.target)) {
33
- return;
34
- }
35
- swallowLabelActivation = !!rootEl.querySelector('[aria-haspopup][aria-expanded="true"]');
36
- };
37
- // A keyboard-activated click brings no pointerdown of its own and would otherwise inherit the flag from a gesture that produced no click. Only the two keys that synthesize one: any key would also disarm a pointer gesture that is still in flight.
38
- const disarmLabelActivation = (event) => {
39
- if (event.key !== 'Enter' && event.key !== ' ') {
40
- return;
41
- }
42
- swallowLabelActivation = false;
43
- };
44
- window.addEventListener('pointerdown', armLabelActivation, true);
45
- window.addEventListener('keydown', disarmLabelActivation, true);
46
- return () => {
47
- window.removeEventListener('pointerdown', armLabelActivation, true);
48
- window.removeEventListener('keydown', disarmLabelActivation, true);
49
- };
50
- });
51
- const guardLabelActivation = (event) => {
52
- const swallow = swallowLabelActivation;
53
- swallowLabelActivation = false;
54
- if (!forwardsLabelActivation(event.target)) {
55
- return;
56
- }
57
- // The label's control is the group's first radio: forwarding the click would select it instead of keeping the current choice.
58
- const radio = rootEl?.control?.getAttribute('role') === 'radio' ? rootEl.control : null;
59
- if (swallow || radio) {
60
- event.preventDefault();
61
- }
62
- radio?.closest('[role="radiogroup"]')?.querySelector('[role="radio"][tabindex="0"]')?.focus();
63
- };
10
+ const id = $props.id();
11
+ const formFieldLabel = createFormFieldLabel(id, () => !!label);
12
+ provideFormFieldLabel(formFieldLabel);
64
13
  </script>
65
14
 
66
- <!-- svelte-ignore a11y_click_events_have_key_events -->
67
- <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
68
- <label bind:this={rootEl} class="form-field" onclick={guardLabelActivation}>
15
+ <div class="form-field">
69
16
  {#if label}
70
- <span class="form-field__label">
71
- {#if typeof label === 'string'}
72
- {label}
73
- {:else}
74
- {@render label()}
75
- {/if}
76
- {#if required}<span class="form-field__required" aria-hidden="true">*</span>{/if}
17
+ <div class="form-field__header">
18
+ <label class="form-field__label" for={formFieldLabel.controlId} id={formFieldLabel.labelId}>
19
+ {#if typeof label === 'string'}
20
+ {label}
21
+ {:else}
22
+ {@render label()}
23
+ {/if}
24
+ {#if required}<span class="form-field__required" aria-hidden="true">*</span>{/if}
25
+ </label>
77
26
  {#if hint}
78
- <!-- svelte-ignore a11y_click_events_have_key_events -->
79
- <!-- svelte-ignore a11y_no_static_element_interactions -->
80
- <span class="form-field__hint" onclick={swallowClick}>
27
+ <span class="form-field__hint">
81
28
  {#if typeof hint === 'string'}
82
29
  <Tooltip text={hint}>
83
30
  <span class="form-field__hint-trigger" role="button" tabindex="0" aria-label={hint}>
@@ -89,21 +36,21 @@ const guardLabelActivation = (event) => {
89
36
  {/if}
90
37
  </span>
91
38
  {/if}
92
- </span>
39
+ </div>
93
40
  {/if}
94
41
 
95
- {@render children()}
96
- </label>
42
+ {@render children({ labelId: formFieldLabel.labelId })}
43
+ </div>
97
44
 
98
45
  <!--
99
46
  @component
100
- FormField — a label above an input slot. The root is a `<label>`, so the first labelable control inside is associated without id wiring; a label click never
101
- re-opens an already expanded popup field. It renders no errors — put it inside `Validatable` (or use `FormFieldValidatable`) for that.
47
+ FormField — a label above a field slot; a kit control inside is named by the label on its own, a control outside the kit takes the slot's `labelId` as its
48
+ `aria-labelledby`. Errors are `FormFieldValidatable`'s job.
102
49
 
103
50
  ### CSS Custom Properties
104
51
  | Property | Description | Default |
105
52
  |---|---|---|
106
- | `--sc-kit--form-field--gap` | Vertical gap between label and the input slot | `0.375rem` (6px) |
53
+ | `--sc-kit--form-field--gap` | Vertical gap between label and the field slot | `0.375rem` (6px) |
107
54
  | `--sc-kit--form-field--hint--color` | Hint color (inherited by the default info icon) | `--sc-kit--color--text--muted` |
108
55
  | `--sc-kit--form-field--label--color` | Label text color | `--sc-kit--color--text--primary` |
109
56
  | `--sc-kit--form-field--label--font-size` | Label font size | `--sc-kit--font-size--sm` |
@@ -122,14 +69,19 @@ re-opens an already expanded popup field. It renders no errors — put it inside
122
69
  flex-direction: column;
123
70
  gap: var(--_ff--gap);
124
71
  }
125
- .form-field__label {
126
- display: inline-flex;
72
+ .form-field__header {
73
+ display: flex;
127
74
  align-items: center;
128
75
  gap: 0.25rem;
129
76
  color: var(--_ff--label-color);
130
77
  font-size: var(--_ff--label-font-size);
131
78
  font-weight: var(--_ff--label-font-weight);
132
79
  }
80
+ .form-field__label {
81
+ display: inline-flex;
82
+ align-items: center;
83
+ gap: 0.25rem;
84
+ }
133
85
  .form-field__required {
134
86
  color: var(--_ff--required-color);
135
87
  }
@@ -1,25 +1,26 @@
1
+ export type FormFieldIds = {
2
+ /** For a control outside the kit — kit controls pick the label up by themselves. */
3
+ labelId: string | undefined;
4
+ };
1
5
  import type { Snippet } from 'svelte';
2
6
  type Props = {
3
- /** Label rendered above the input. String renders as text; Snippet allows extra content (e.g. tooltip icon) next to the text. */
7
+ /** Label rendered above the field. String renders as text; Snippet allows extra content (e.g. tooltip icon) next to the text. */
4
8
  label?: string | Snippet;
5
- /** Affordance pinned to the trailing edge of the label row — rendered only alongside a `label`. String renders the standard info icon — keyboard-focusable, named by the text — with that text in a tooltip; Snippet renders anything (button, link, badge). Clicks inside never reach the field. */
9
+ /** Affordance pinned to the trailing edge of the label row — rendered only alongside a `label`. String renders the standard info icon — keyboard-focusable, named by the text — with that text in a tooltip; Snippet renders anything (button, link, badge). */
6
10
  hint?: string | Snippet;
7
11
  /** Appends a danger-colored `*` to the label. Purely visual — does not enforce anything. */
8
12
  required?: boolean;
9
- /**
10
- * Input slot. The wrapping `<label>` implicitly associates with the first labelable control inside (HTML standard), so no id wiring is needed.
11
- * A control made of several fields (`SegmentedControl`, `PinInput`) is not named by it — give it its own `aria-label`.
12
- */
13
- children: Snippet;
13
+ /** Field slot. A kit control inside is named by the label on its own: a text input through `<label for>`, anything else through `aria-labelledby`. */
14
+ children: Snippet<[FormFieldIds]>;
14
15
  };
15
16
  /**
16
- * FormField — a label above an input slot. The root is a `<label>`, so the first labelable control inside is associated without id wiring; a label click never
17
- * re-opens an already expanded popup field. It renders no errors — put it inside `Validatable` (or use `FormFieldValidatable`) for that.
17
+ * FormField — a label above a field slot; a kit control inside is named by the label on its own, a control outside the kit takes the slot's `labelId` as its
18
+ * `aria-labelledby`. Errors are `FormFieldValidatable`'s job.
18
19
  *
19
20
  * ### CSS Custom Properties
20
21
  * | Property | Description | Default |
21
22
  * |---|---|---|
22
- * | `--sc-kit--form-field--gap` | Vertical gap between label and the input slot | `0.375rem` (6px) |
23
+ * | `--sc-kit--form-field--gap` | Vertical gap between label and the field slot | `0.375rem` (6px) |
23
24
  * | `--sc-kit--form-field--hint--color` | Hint color (inherited by the default info icon) | `--sc-kit--color--text--muted` |
24
25
  * | `--sc-kit--form-field--label--color` | Label text color | `--sc-kit--color--text--primary` |
25
26
  * | `--sc-kit--form-field--label--font-size` | Label font size | `--sc-kit--font-size--sm` |
@@ -0,0 +1,6 @@
1
+ import type { FormFieldLabelContext } from '../_internal/form-field-label-context';
2
+ export type FormFieldLabel = FormFieldLabelContext & {
3
+ /** The id the label's `for` points at — the first mounted text input that claimed the label, if any. */
4
+ readonly controlId: string | undefined;
5
+ };
6
+ export declare const createFormFieldLabel: (id: string, hasLabel: () => boolean) => FormFieldLabel;
@@ -0,0 +1,19 @@
1
+ import { untrack } from 'svelte';
2
+ export const createFormFieldLabel = (id, hasLabel) => {
3
+ const labelId = $derived(hasLabel() ? id : undefined);
4
+ let claimants = $state.raw([]);
5
+ return {
6
+ get labelId() {
7
+ return labelId;
8
+ },
9
+ get controlId() {
10
+ return claimants[0]?.();
11
+ },
12
+ claimControl: (controlId) => {
13
+ claimants = [...untrack(() => claimants), controlId];
14
+ return () => {
15
+ claimants = untrack(() => claimants).filter((claimant) => claimant !== controlId);
16
+ };
17
+ }
18
+ };
19
+ };
@@ -1,2 +1,2 @@
1
- export { default as FormField } from './cmp.form-field.svelte';
1
+ export { default as FormField, type FormFieldIds } from './cmp.form-field.svelte';
2
2
  export { default as FormFieldValidatable } from './cmp.form-field-validatable.svelte';
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">import { Icon } from '../icon';
2
2
  import { Singleselect } from '../select';
3
3
  import { isIconPickerGroup } from './types';
4
- let { value, icons, placeholder = '', disabled = false, error = false, borderless = false, cols = 7, on } = $props();
4
+ let { value, icons, placeholder = '', disabled = false, error = false, borderless = false, cols = 7, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, on } = $props();
5
5
  const toOption = (i) => ({ value: i.value, label: i.label, disabled: i.disabled });
6
6
  const selectOptions = $derived(icons.map((item) => (isIconPickerGroup(item) ? { label: item.label, options: item.options.map(toOption) } : toOption(item))));
7
7
  const iconBySrc = $derived(new Map(icons.flatMap((item) => isIconPickerGroup(item) ? item.options.map((o) => [o.value, o.src]) : [[item.value, item.src]])));
@@ -15,6 +15,8 @@ const iconBySrc = $derived(new Map(icons.flatMap((item) => isIconPickerGroup(ite
15
15
  disabled={disabled}
16
16
  error={error}
17
17
  borderless={borderless}
18
+ aria-label={ariaLabel}
19
+ aria-labelledby={ariaLabelledby}
18
20
  on={{ change: (v) => on?.change?.(v) }}>
19
21
  {#snippet selectionSnippet({ option })}
20
22
  {@const src = iconBySrc.get(option.value)}
@@ -1,7 +1,7 @@
1
1
  import { type IconPickerItem } from './types';
2
2
  type Props = {
3
3
  value: string | null;
4
- /** Flat list of icons or a list of labelled groups. */
4
+ /** Flat list of icons or a list of labeled groups. */
5
5
  icons: IconPickerItem[];
6
6
  placeholder?: string;
7
7
  disabled?: boolean;
@@ -10,6 +10,8 @@ type Props = {
10
10
  borderless?: boolean;
11
11
  /** Tiles per row in the popover grid. @default 7 */
12
12
  cols?: number;
13
+ 'aria-label'?: string;
14
+ 'aria-labelledby'?: string;
13
15
  on?: {
14
16
  change?: (value: string | null) => void;
15
17
  };
@@ -1,9 +1,13 @@
1
- <script lang="ts">import { HtmlHelper } from '../../core/utils';
1
+ <script lang="ts">import { HtmlHelper, StringHelper } from '../../core/utils';
2
+ import { claimFormFieldLabel } from '../_internal/form-field-label-context';
2
3
  import { InputClearButton } from '../_internal/input-clear-button';
3
4
  import { InputEmojiPicker } from '../_internal/input-emoji-picker';
4
5
  import { InputPasswordToggle } from '../_internal/input-password-toggle';
5
6
  import { IconSlot } from '../icon';
6
7
  const { value, type = 'text', size = 'md', disabled = false, readonly = false, inert = false, error = false, borderless = false, clearable = false, emoji = false, trim = true, placeholder = '', title = '', name = '', id = null, autofocus = false, maxLength = null, autocomplete, autocapitalize, inputmode, enterkeyhint, spellcheck, 'aria-describedby': ariaDescribedby, 'aria-label': ariaLabel, 'aria-required': ariaRequired, on, icon, iconPosition = 'leading', secondaryIcon, prefix, suffix } = $props();
8
+ const fallbackId = $props.id();
9
+ const controlId = $derived(id ?? fallbackId);
10
+ claimFormFieldLabel(() => controlId);
7
11
  let inputEl = $state.raw(undefined);
8
12
  let passwordRevealed = $state(false);
9
13
  const valueParsed = $derived(value ?? '');
@@ -25,6 +29,9 @@ const handleInput = () => {
25
29
  if (!inputEl) {
26
30
  return;
27
31
  }
32
+ if (type !== 'password') {
33
+ HtmlHelper.cleanElementValue(inputEl, StringHelper.toSingleLineText);
34
+ }
28
35
  on?.input?.(inputEl.value);
29
36
  };
30
37
  const applyTrim = () => {
@@ -71,6 +78,11 @@ const handleBlur = () => {
71
78
  }
72
79
  on?.blur?.();
73
80
  };
81
+ const handlePaste = (event) => {
82
+ if (type !== 'password') {
83
+ HtmlHelper.pasteCleanedText(event, StringHelper.toSingleLineText);
84
+ }
85
+ };
74
86
  const handleKeydown = (event) => {
75
87
  // Input only — the native `change` Enter fires next announces the commit; emitting it here doubles it.
76
88
  if (event.key === 'Enter' && !event.isComposing) {
@@ -113,7 +125,7 @@ const focusInput = () => inputEl?.focus();
113
125
  class="input__el"
114
126
  type={effectiveType}
115
127
  name={name}
116
- id={id}
128
+ id={controlId}
117
129
  placeholder={placeholder}
118
130
  disabled={disabled}
119
131
  readonly={readonly}
@@ -133,6 +145,7 @@ const focusInput = () => inputEl?.focus();
133
145
  aria-required={ariaRequired ? 'true' : undefined}
134
146
  oninput={handleInput}
135
147
  onchange={handleChange}
148
+ onpaste={handlePaste}
136
149
  onblur={handleBlur}
137
150
  onfocus={() => on?.focus?.()}
138
151
  onkeydown={handleKeydown} />
@@ -173,6 +186,9 @@ Leading / trailing whitespace is stripped at every commit point — `change`, bl
173
186
  value the consumer receives always matches what the field shows. Typing is untouched. Pass
174
187
  `trim={false}` to opt out; `type="password"` never trims.
175
188
 
189
+ Entered text is cleaned as it arrives — typed, pasted, dropped or autofilled: line breaks and tabs
190
+ become spaces, other control characters are dropped. `type="password"` is never cleaned.
191
+
176
192
  Two snippet slots widen the chrome: `prefix` for a full-height segmented leading addon (see
177
193
  `WebsiteInput`), and `suffix` for a free-form trailing addon rendered before any trailing icons
178
194
  or the clear button (see `HandleInput`'s availability pill).
@@ -10,6 +10,9 @@ import type { InputProps } from './types';
10
10
  * value the consumer receives always matches what the field shows. Typing is untouched. Pass
11
11
  * `trim={false}` to opt out; `type="password"` never trims.
12
12
  *
13
+ * Entered text is cleaned as it arrives — typed, pasted, dropped or autofilled: line breaks and tabs
14
+ * become spaces, other control characters are dropped. `type="password"` is never cleaned.
15
+ *
13
16
  * Two snippet slots widen the chrome: `prefix` for a full-height segmented leading addon (see
14
17
  * `WebsiteInput`), and `suffix` for a free-form trailing addon rendered before any trailing icons
15
18
  * or the clear button (see `HandleInput`'s availability pill).
@@ -10,15 +10,12 @@ export type InputProps = {
10
10
  /** @default 'md' */
11
11
  size?: InputSize;
12
12
  disabled?: boolean;
13
- /** Non-editable but selectable. The value is still submitted with a form. */
14
13
  readonly?: boolean;
15
- /** Visual display mode — non-focusable, no hover/focus animations, hidden clear button. */
16
14
  inert?: boolean;
17
15
  /** Visual error state — also sets `aria-invalid="true"`. */
18
16
  error?: boolean;
19
17
  /** Strip border + background + focus underline. */
20
18
  borderless?: boolean;
21
- /** Show the default × clear button when value is non-empty. */
22
19
  clearable?: boolean;
23
20
  /** Show the emoji-picker trigger inside the input. */
24
21
  emoji?: boolean;
@@ -1,8 +1,12 @@
1
- <script lang="ts">import { InputClearButton } from '../_internal/input-clear-button';
1
+ <script lang="ts">import { claimFormFieldLabel } from '../_internal/form-field-label-context';
2
+ import { InputClearButton } from '../_internal/input-clear-button';
2
3
  import { IconSlot } from '../icon';
3
4
  import { formatNumeral, NumeralThousandGroupStyles, registerCursorTracker } from 'cleave-zen';
4
5
  import { untrack } from 'svelte';
5
6
  const { size = 'md', disabled = false, readonly = false, inert = false, error = false, borderless = false, placeholder = '', title = '', name = '', id = null, autofocus = false, min = null, max = null, minExclusive = false, maxExclusive = false, decimals = 2, suffix, hideResolvedValue = false, 'aria-describedby': ariaDescribedby, 'aria-label': ariaLabel, 'aria-required': ariaRequired, icon, iconPosition = 'leading', secondaryIcon, ...mode } = $props();
7
+ const fallbackId = $props.id();
8
+ const controlId = $derived(id ?? fallbackId);
9
+ claimFormFieldLabel(() => controlId);
6
10
  let inputEl = $state.raw(undefined);
7
11
  let focused = $state(false);
8
12
  let typedValue = $state(undefined);
@@ -136,7 +140,7 @@ const initInput = (node) => {
136
140
  type="text"
137
141
  inputmode="decimal"
138
142
  name={name}
139
- id={id}
143
+ id={controlId}
140
144
  placeholder={placeholder}
141
145
  disabled={disabled}
142
146
  readonly={readonly}
@@ -3,9 +3,7 @@ type BaseProps = {
3
3
  /** @default 'md' */
4
4
  size?: 'sm' | 'md' | 'lg';
5
5
  disabled?: boolean;
6
- /** Non-editable but selectable; value still submitted with a form. */
7
6
  readonly?: boolean;
8
- /** Visual display mode — non-focusable, no hover/focus animations, no clear button. */
9
7
  inert?: boolean;
10
8
  /** Visual error state — also sets `aria-invalid="true"`. */
11
9
  error?: boolean;
@@ -41,7 +39,6 @@ type BaseProps = {
41
39
  secondaryIcon?: IconProp;
42
40
  };
43
41
  type ClearableProps = BaseProps & {
44
- /** Value can be cleared to `null`. Trigger shows an × button when set. */
45
42
  clearable: true;
46
43
  value: number | null | undefined;
47
44
  on?: {
@@ -55,7 +52,6 @@ type ClearableProps = BaseProps & {
55
52
  };
56
53
  };
57
54
  type RequiredProps = BaseProps & {
58
- /** Value is required; no clear button. */
59
55
  clearable: false;
60
56
  value: number;
61
57
  on?: {
@@ -2,7 +2,6 @@ import type { OptionPillOption } from './types';
2
2
  declare function $$render<T>(): {
3
3
  props: ({
4
4
  options: OptionPillOption<T>[];
5
- /** Disable the whole group. */
6
5
  disabled?: boolean;
7
6
  /** Custom equality comparator for non-primitive values (objects with `id`, etc.). @default (a, b) => a === b */
8
7
  compare?: (a: T, b: T) => boolean;
@@ -15,7 +14,6 @@ declare function $$render<T>(): {
15
14
  };
16
15
  }) | ({
17
16
  options: OptionPillOption<T>[];
18
- /** Disable the whole group. */
19
17
  disabled?: boolean;
20
18
  /** Custom equality comparator for non-primitive values (objects with `id`, etc.). @default (a, b) => a === b */
21
19
  compare?: (a: T, b: T) => boolean;
@@ -9,9 +9,8 @@ type Props = {
9
9
  /** Editor chrome — the fixed panels-toggle + close pair. Set it to render both buttons; omit it entirely for a non-editor page. */
10
10
  editorControls?: EditorControls;
11
11
  scrollable?: boolean;
12
- /** Sets `inert` on the whole layout — header and body stop taking focus, clicks and assistive-tech interaction. */
13
12
  inert?: boolean;
14
- /** Shows a `Spinner` centered over the layout; `blocking` also makes the whole layout `inert` and dims it. @default false */
13
+ /** Shows a `Spinner` centered over the layout; `blocking` also makes the whole layout `inert` and dims it. */
15
14
  loading?: PageLoading;
16
15
  };
17
16
  /**
@@ -1,5 +1,7 @@
1
- <script lang="ts">import { PinInputLocalization } from './pin-input-localization';
2
- const { value, length = 6, mask = false, disabled = false, error = false, 'aria-label': ariaLabel, 'aria-describedby': ariaDescribedby, on } = $props();
1
+ <script lang="ts">import { getFormFieldLabel, resolveAriaLabelledby } from '../_internal/form-field-label-context';
2
+ import { PinInputLocalization } from './pin-input-localization';
3
+ const { value, length = 6, mask = false, disabled = false, error = false, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-describedby': ariaDescribedby, on } = $props();
4
+ const formFieldLabel = getFormFieldLabel();
3
5
  const localization = new PinInputLocalization();
4
6
  let cells = $state([]);
5
7
  const cellValue = (i) => value[i] ?? '';
@@ -59,7 +61,13 @@ const handleFocus = (e) => {
59
61
  };
60
62
  </script>
61
63
 
62
- <div class="pin-input" class:pin-input--disabled={disabled} class:pin-input--error={error} role="group" aria-label={ariaLabel}>
64
+ <div
65
+ class="pin-input"
66
+ class:pin-input--disabled={disabled}
67
+ class:pin-input--error={error}
68
+ role="group"
69
+ aria-label={ariaLabel}
70
+ aria-labelledby={resolveAriaLabelledby(formFieldLabel, ariaLabel, ariaLabelledby)}>
63
71
  {#each Array(length) as _, i (i)}
64
72
  <input
65
73
  aria-label={localization.cellLabel(i + 1, length)}
@@ -7,8 +7,8 @@ type Props = {
7
7
  disabled?: boolean;
8
8
  /** Visual error state — also sets `aria-invalid="true"` on each cell. */
9
9
  error?: boolean;
10
- /** Accessible label for the cell group. Consumer-provided (no kit default). */
11
10
  'aria-label'?: string;
11
+ 'aria-labelledby'?: string;
12
12
  'aria-describedby'?: string;
13
13
  on?: {
14
14
  input?: (value: string) => void;
@@ -4,7 +4,7 @@ import { validateTrigger } from './validate-trigger';
4
4
  import { autoUpdate, computePosition, flip, offset as offsetMiddleware, shift, size } from '@floating-ui/dom';
5
5
  import IconChevronDown from '@fluentui/svg-icons/icons/chevron_down_20_regular.svg?raw';
6
6
  import { tick } from 'svelte';
7
- let { position = 'bottom-start', disabled = false, fixedPosition = false, offset = 4, boundaryMargin = 8, backdrop = false, matchWidth = false, panel = true, keepOpen = false, fillContainer = false, tabbable = true, on, children, trigger, 'aria-label': ariaLabel } = $props();
7
+ let { position = 'bottom-start', disabled = false, fixedPosition = false, offset = 4, boundaryMargin = 8, backdrop = false, matchWidth = false, panel = true, keepOpen = false, fillContainer = false, tabbable = true, on, children, trigger, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby } = $props();
8
8
  let opened = $state(false);
9
9
  let triggerEl = $state.raw(undefined);
10
10
  let contentEl = $state.raw(undefined);
@@ -182,6 +182,7 @@ const handleBackdropClick = (e) => {
182
182
  disabled={disabled}
183
183
  tabindex={tabbable ? undefined : -1}
184
184
  aria-label={ariaLabel}
185
+ aria-labelledby={ariaLabelledby}
185
186
  aria-haspopup="true"
186
187
  aria-expanded={opened}
187
188
  onclick={handleTriggerClick}
@@ -27,14 +27,10 @@ type Props = {
27
27
  closed?: () => void;
28
28
  };
29
29
  children: Snippet;
30
- /**
31
- * Custom trigger content; defaults to a chevron-down icon. Renders inside the trigger `<button>`,
32
- * so content MUST be non-interactive — for a button-looking trigger use `Button` with
33
- * `type="presentational"`. Nesting an interactive element triggers a dev-only warning.
34
- */
30
+ /** Content of the trigger `<button>`, so non-interactive only — `Button type="presentational"` for a button look. @default chevron-down icon */
35
31
  trigger?: Snippet;
36
- /** Accessible name for the trigger button — required when the trigger content carries no text (an icon-only trigger). */
37
32
  'aria-label'?: string;
33
+ 'aria-labelledby'?: string;
38
34
  };
39
35
  /**
40
36
  * Popover — floating content anchored to a trigger element. Click trigger to open;
@@ -1,5 +1,7 @@
1
- <script lang="ts">const { selected, label, disabled = false, title, name, on } = $props();
1
+ <script lang="ts">import { getFormFieldLabel } from '../_internal/form-field-label-context';
2
+ const { selected, label, disabled = false, title, name, on } = $props();
2
3
  const ariaLabel = $derived(typeof label === 'string' ? label : name);
4
+ const formFieldLabel = getFormFieldLabel();
3
5
  const handleClick = (event) => {
4
6
  event.stopPropagation();
5
7
  if (disabled || selected) {
@@ -7,11 +9,18 @@ const handleClick = (event) => {
7
9
  }
8
10
  on?.change?.();
9
11
  };
10
- export {};
11
12
  </script>
12
13
 
13
14
  <label class="radio" class:radio--on={selected} class:radio--disabled={disabled} title={title}>
14
- <button type="button" class="radio__dot" role="radio" aria-checked={selected} aria-label={ariaLabel} disabled={disabled} onclick={handleClick}>
15
+ <button
16
+ type="button"
17
+ class="radio__dot"
18
+ role="radio"
19
+ aria-checked={selected}
20
+ aria-label={ariaLabel}
21
+ aria-labelledby={label || ariaLabel ? undefined : formFieldLabel?.labelId}
22
+ disabled={disabled}
23
+ onclick={handleClick}>
15
24
  <span class="radio__inner" class:radio__inner--visible={selected} aria-hidden="true"></span>
16
25
  </button>
17
26
  {#if typeof label === 'string'}
@@ -1,12 +1,14 @@
1
1
  <script lang="ts">import { AppTheme } from '../../core/theme';
2
2
  import { HtmlHelper } from '../../core/utils';
3
+ import { getFormFieldLabel, resolveAriaLabelledby } from '../_internal/form-field-label-context';
3
4
  import { InputEmojiPicker } from '../_internal/input-emoji-picker';
4
5
  import { HtmlBlock } from '../html-block';
5
6
  import { RichTextInputLocalization } from './rich-text-input-localization';
6
7
  import { addValidatedLinkButton } from './validated-link-button';
7
8
  import { onDestroy, onMount } from 'svelte';
8
- const { value, size = 'md', disabled = false, readonly = false, inert = false, error = false, borderless = false, emoji = false, autofocus = false, rows = 0, placeholder = '', options, conf, 'aria-label': ariaLabel, 'aria-describedby': ariaDescribedby, 'aria-required': ariaRequired, on } = $props();
9
- const id = `ti-${crypto.randomUUID()}`;
9
+ const { value, size = 'md', disabled = false, readonly = false, inert = false, error = false, borderless = false, emoji = false, autofocus = false, rows = 0, placeholder = '', options, conf, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-describedby': ariaDescribedby, 'aria-required': ariaRequired, on } = $props();
10
+ const id = $props.id();
11
+ const formFieldLabel = getFormFieldLabel();
10
12
  let elementRef = $state.raw(undefined);
11
13
  let toolbarRef = $state.raw(undefined);
12
14
  let editorRef = $state.raw(null);
@@ -142,13 +144,23 @@ const onEmojiSelected = (emojiChar) => insertEmoji(emojiChar);
142
144
  class:rich-text-input--readonly={readonly}
143
145
  class:rich-text-input--inert={inert}
144
146
  class:rich-text-input--error={error}
145
- class:rich-text-input--borderless={borderless}
146
- aria-label={ariaLabel}
147
- aria-describedby={ariaDescribedby}
148
- aria-required={ariaRequired ? 'true' : undefined}>
147
+ class:rich-text-input--borderless={borderless}>
149
148
  <div class="rich-text-input__toolbar" bind:this={toolbarRef}></div>
150
149
  <HtmlBlock>
151
- <div class="rich-text-input__input" id={id} bind:this={elementRef}></div>
150
+ <div
151
+ bind:this={elementRef}
152
+ class="rich-text-input__input"
153
+ id={id}
154
+ role="textbox"
155
+ tabindex={disabled || inert ? undefined : 0}
156
+ aria-multiline="true"
157
+ aria-label={ariaLabel}
158
+ aria-labelledby={resolveAriaLabelledby(formFieldLabel, ariaLabel, ariaLabelledby)}
159
+ aria-describedby={ariaDescribedby}
160
+ aria-required={ariaRequired ? 'true' : undefined}
161
+ aria-invalid={error ? 'true' : undefined}
162
+ aria-readonly={readonly ? 'true' : undefined}>
163
+ </div>
152
164
  </HtmlBlock>
153
165
  {#if emoji}
154
166
  <span class="rich-text-input__emoji">
@@ -6,9 +6,7 @@ type Props = {
6
6
  /** @default 'md' — controls the editor root font size (and downstream rem-based sizing). */
7
7
  size?: 'sm' | 'md' | 'lg';
8
8
  disabled?: boolean;
9
- /** Non-editable but still visible (mirrors `disabled` in editor mode). */
10
9
  readonly?: boolean;
11
- /** Visual display mode — no focus, no hover, hidden affordances. */
12
10
  inert?: boolean;
13
11
  /** Visual error state. */
14
12
  error?: boolean;
@@ -24,6 +22,7 @@ type Props = {
24
22
  /** Escape-hatch for extra HugeRTE init options, merged AFTER kit defaults. */
25
23
  conf?: Partial<RawEditorOptions>;
26
24
  'aria-label'?: string;
25
+ 'aria-labelledby'?: string;
27
26
  'aria-describedby'?: string;
28
27
  'aria-required'?: boolean;
29
28
  on?: {
@@ -1,8 +1,9 @@
1
1
  <script lang="ts" module>export {};
2
2
  </script>
3
3
 
4
- <script lang="ts" generics="T">"use strict";
5
- const { value, segments, variant = 'tray', size = 'md', fullWidth = false, compare, 'aria-label': ariaLabel, on } = $props();
4
+ <script lang="ts" generics="T">import { getFormFieldLabel, resolveAriaLabelledby } from '../_internal/form-field-label-context';
5
+ const { value, segments, variant = 'tray', size = 'md', fullWidth = false, compare, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, on } = $props();
6
+ const formFieldLabel = getFormFieldLabel();
6
7
  const segmentIdPrefix = $props.id();
7
8
  const segmentEls = [];
8
9
  const isActive = (segment) => (compare ? compare(value, segment.value) : value === segment.value);
@@ -51,7 +52,8 @@ const handleKeydown = (event, from) => {
51
52
  class="segmented-control segmented-control--{variant} segmented-control--{size}"
52
53
  class:segmented-control--full={fullWidth}
53
54
  role="radiogroup"
54
- aria-label={ariaLabel}>
55
+ aria-label={ariaLabel}
56
+ aria-labelledby={resolveAriaLabelledby(formFieldLabel, ariaLabel, ariaLabelledby)}>
55
57
  {#each segments as segment, i (i)}
56
58
  {@const segmentLabelId = `${segmentIdPrefix}-${i}`}
57
59
  <button
@@ -1,7 +1,6 @@
1
1
  export type Segment<TValue = unknown> = {
2
2
  value: TValue;
3
3
  label: string;
4
- /** Non-interactive — click does nothing. */
5
4
  disabled?: boolean;
6
5
  /** Visual flag — colors the segment in the danger tone (validation hint). */
7
6
  error?: boolean;
@@ -23,8 +22,8 @@ declare function $$render<T>(): {
23
22
  fullWidth?: boolean;
24
23
  /** Equality for object T values. @default reference equality (`===`) */
25
24
  compare?: (a: T, b: T) => boolean;
26
- /** Accessible name of the group. */
27
25
  'aria-label'?: string;
26
+ 'aria-labelledby'?: string;
28
27
  on?: {
29
28
  change?: (value: T) => void;
30
29
  };