@streamscloud/kit 0.89.1 → 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 (67) hide show
  1. package/README.md +2 -1
  2. package/dist/core/validation/form-validation-handler/form-validation-handler.svelte.d.ts +4 -2
  3. package/dist/core/validation/form-validation-handler/form-validation-handler.svelte.js +1 -1
  4. package/dist/ui/_internal/form-field-label-context/form-field-label-context.svelte.d.ts +15 -0
  5. package/dist/ui/_internal/form-field-label-context/form-field-label-context.svelte.js +20 -0
  6. package/dist/ui/_internal/form-field-label-context/index.d.ts +1 -0
  7. package/dist/ui/_internal/form-field-label-context/index.js +1 -0
  8. package/dist/ui/checkbox/cmp.checkbox.svelte +11 -2
  9. package/dist/ui/chip-group/cmp.chip-group.svelte +10 -3
  10. package/dist/ui/chip-group/cmp.chip-group.svelte.d.ts +4 -2
  11. package/dist/ui/color-picker/cmp.color-picker.svelte +12 -2
  12. package/dist/ui/color-picker/cmp.color-picker.svelte.d.ts +1 -3
  13. package/dist/ui/date-picker/cmp.date-picker.svelte +6 -4
  14. package/dist/ui/date-picker/cmp.date-picker.svelte.d.ts +2 -9
  15. package/dist/ui/date-picker/cmp.date-time-picker.svelte +19 -3
  16. package/dist/ui/date-picker/cmp.date-time-picker.svelte.d.ts +3 -5
  17. package/dist/ui/date-picker/cmp.time-picker.svelte +3 -1
  18. package/dist/ui/date-picker/cmp.time-picker.svelte.d.ts +1 -4
  19. package/dist/ui/document-tabs/types.d.ts +0 -1
  20. package/dist/ui/form-field/cmp.form-field-validatable.svelte +4 -4
  21. package/dist/ui/form-field/cmp.form-field-validatable.svelte.d.ts +4 -7
  22. package/dist/ui/form-field/cmp.form-field.svelte +31 -79
  23. package/dist/ui/form-field/cmp.form-field.svelte.d.ts +11 -10
  24. package/dist/ui/form-field/form-field-label.svelte.d.ts +6 -0
  25. package/dist/ui/form-field/form-field-label.svelte.js +19 -0
  26. package/dist/ui/form-field/index.d.ts +1 -1
  27. package/dist/ui/icon-picker/cmp.icon-picker.svelte +3 -1
  28. package/dist/ui/icon-picker/cmp.icon-picker.svelte.d.ts +3 -1
  29. package/dist/ui/input/cmp.input.svelte +5 -1
  30. package/dist/ui/input/types.d.ts +0 -3
  31. package/dist/ui/numeral-input/cmp.numeral-input.svelte +6 -2
  32. package/dist/ui/numeral-input/cmp.numeral-input.svelte.d.ts +0 -4
  33. package/dist/ui/option-pill/cmp.option-pill.svelte.d.ts +0 -2
  34. package/dist/ui/page-layout/cmp.page-layout.svelte.d.ts +1 -2
  35. package/dist/ui/pin-input/cmp.pin-input.svelte +11 -3
  36. package/dist/ui/pin-input/cmp.pin-input.svelte.d.ts +1 -1
  37. package/dist/ui/popover/cmp.popover.svelte +2 -1
  38. package/dist/ui/popover/cmp.popover.svelte.d.ts +2 -6
  39. package/dist/ui/radio/cmp.radio.svelte +12 -3
  40. package/dist/ui/rich-text-input/cmp.rich-text-input.svelte +19 -7
  41. package/dist/ui/rich-text-input/cmp.rich-text-input.svelte.d.ts +1 -2
  42. package/dist/ui/segmented-control/cmp.segmented-control.svelte +5 -3
  43. package/dist/ui/segmented-control/cmp.segmented-control.svelte.d.ts +1 -2
  44. package/dist/ui/select/cmp.input-suggest.svelte +5 -1
  45. package/dist/ui/select/cmp.input-suggest.svelte.d.ts +0 -3
  46. package/dist/ui/select/cmp.multiselect-tree.svelte +2 -1
  47. package/dist/ui/select/cmp.multiselect-tree.svelte.d.ts +1 -3
  48. package/dist/ui/select/cmp.singleselect-async.svelte +5 -2
  49. package/dist/ui/select/cmp.singleselect-async.svelte.d.ts +0 -2
  50. package/dist/ui/select/cmp.singleselect.svelte +3 -1
  51. package/dist/ui/select/cmp.singleselect.svelte.d.ts +0 -2
  52. package/dist/ui/select/multiselect-base.svelte +6 -2
  53. package/dist/ui/select/select-core.svelte.js +0 -2
  54. package/dist/ui/select/select-listbox.svelte +1 -5
  55. package/dist/ui/select/types.d.ts +2 -6
  56. package/dist/ui/slider/cmp.slider.svelte +6 -2
  57. package/dist/ui/slider/cmp.slider.svelte.d.ts +1 -0
  58. package/dist/ui/tabs/types.d.ts +0 -1
  59. package/dist/ui/textarea/cmp.textarea.svelte +5 -1
  60. package/dist/ui/textarea/cmp.textarea.svelte.d.ts +0 -2
  61. package/dist/ui/toggle/cmp.toggle.svelte +4 -2
  62. package/dist/ui/validatable/cmp.validatable.svelte +1 -2
  63. package/dist/ui/validatable/cmp.validatable.svelte.d.ts +1 -2
  64. package/dist/ui/video/cmp.video.svelte.d.ts +0 -1
  65. package/dist/ui/visual-select/cmp.visual-select.svelte +11 -3
  66. package/dist/ui/visual-select/cmp.visual-select.svelte.d.ts +2 -1
  67. package/package.json +3 -2
package/README.md CHANGED
@@ -15,7 +15,8 @@ without it agent sessions run with no hooks and no shared skills. Re-run after c
15
15
  ## Checks
16
16
 
17
17
  ```sh
18
- npm run check # the gate: strict svelte-check + tsc for node files + eslint + prettier --check
18
+ npm run check:fix # after edits: lint:fix, then check
19
+ npm run check # the gate, read-only: strict svelte-check + tsc for node files + eslint + prettier --check
19
20
  npm run lint:fix # eslint --fix + prettier --write
20
21
  ```
21
22
 
@@ -25,8 +25,10 @@ export declare class FormValidationHandler<T extends TypeToValidate<T> = TypeToV
25
25
  onReset?: () => void;
26
26
  });
27
27
  get form(): T;
28
- get errors(): { [P in keyof Partial<T>]: string; };
29
- get touched(): Record<keyof T, boolean>;
28
+ get errors(): Readonly<{
29
+ [P in keyof Partial<T>]: string;
30
+ }>;
31
+ get touched(): Readonly<Record<keyof T, boolean>>;
30
32
  get isValidating(): boolean;
31
33
  get isSubmitting(): boolean;
32
34
  updateField: <K extends keyof T>(field: K, value: T[K]) => void;
@@ -167,7 +167,7 @@ export class FormValidationHandler {
167
167
  this.updateTouched(field, true);
168
168
  this._isValidating = true;
169
169
  const error = await this._validator.validateAt(this._form, field);
170
- ValidationUtils.update(this.errors, field, error || '');
170
+ ValidationUtils.update(this._errors, field, error || '');
171
171
  this._isValidating = false;
172
172
  };
173
173
  stringify = (value) => {
@@ -0,0 +1,15 @@
1
+ export type FormFieldLabelContext = {
2
+ readonly labelId: string | undefined;
3
+ /** The first mounted claimant gets the label; the returned release hands it to the next one still mounted. */
4
+ claimControl: (id: () => string) => () => void;
5
+ };
6
+ /** Offers a visible label to every kit control rendered inside the calling component. */
7
+ export declare const provideFormFieldLabel: (formFieldLabel: FormFieldLabelContext) => void;
8
+ /** The label of the surrounding FormField, or `undefined` outside one. */
9
+ export declare const getFormFieldLabel: () => FormFieldLabelContext | undefined;
10
+ /** For a text input: the surrounding FormField label points its `for` at this id while the input is mounted. */
11
+ export declare const claimFormFieldLabel: (controlId: () => string) => void;
12
+ /** The `aria-labelledby` of a control that is not a text input: the explicit one, else the FormField label's id — unless an explicit `aria-label` names it. */
13
+ export declare const resolveAriaLabelledby: (formFieldLabel: FormFieldLabelContext | undefined, ariaLabel: string | undefined, ariaLabelledby: string | undefined) => string | undefined;
14
+ /** Below the calling component no control is named by the surrounding FormField label — for a field that renders other fields in its own panel. */
15
+ export declare const stopFormFieldLabelHere: () => void;
@@ -0,0 +1,20 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ // Svelte context despite sc-frontend-svelte's "no context API": the least invasive way for FormField to reach a kit control at any depth (see the ADR).
3
+ const KEY = Symbol('form-field-label');
4
+ /** Offers a visible label to every kit control rendered inside the calling component. */
5
+ export const provideFormFieldLabel = (formFieldLabel) => {
6
+ setContext(KEY, formFieldLabel);
7
+ };
8
+ /** The label of the surrounding FormField, or `undefined` outside one. */
9
+ export const getFormFieldLabel = () => getContext(KEY);
10
+ /** For a text input: the surrounding FormField label points its `for` at this id while the input is mounted. */
11
+ export const claimFormFieldLabel = (controlId) => {
12
+ const formFieldLabel = getFormFieldLabel();
13
+ $effect(() => formFieldLabel?.claimControl(controlId));
14
+ };
15
+ /** The `aria-labelledby` of a control that is not a text input: the explicit one, else the FormField label's id — unless an explicit `aria-label` names it. */
16
+ export const resolveAriaLabelledby = (formFieldLabel, ariaLabel, ariaLabelledby) => ariaLabelledby ?? (ariaLabel ? undefined : formFieldLabel?.labelId);
17
+ /** Below the calling component no control is named by the surrounding FormField label — for a field that renders other fields in its own panel. */
18
+ export const stopFormFieldLabelHere = () => {
19
+ setContext(KEY, undefined);
20
+ };
@@ -0,0 +1 @@
1
+ export { claimFormFieldLabel, getFormFieldLabel, provideFormFieldLabel, resolveAriaLabelledby, stopFormFieldLabelHere, type FormFieldLabelContext } from './form-field-label-context.svelte';
@@ -0,0 +1 @@
1
+ export { claimFormFieldLabel, getFormFieldLabel, provideFormFieldLabel, resolveAriaLabelledby, stopFormFieldLabelHere } from './form-field-label-context.svelte';
@@ -1,10 +1,12 @@
1
- <script lang="ts">import { Icon } from '../icon';
1
+ <script lang="ts">import { getFormFieldLabel } from '../_internal/form-field-label-context';
2
+ import { Icon } from '../icon';
2
3
  import IconCheckmark from '@fluentui/svg-icons/icons/checkmark_12_regular.svg?raw';
3
4
  import IconSubtract from '@fluentui/svg-icons/icons/subtract_16_regular.svg?raw';
4
5
  const { checked, variant = 'default', size = 'md', disabled = false, label, title, on } = $props();
5
6
  const isOn = $derived(checked === true);
6
7
  const isIndeterminate = $derived(checked === 'indeterminate');
7
8
  const ariaChecked = $derived(isIndeterminate ? 'mixed' : isOn ? 'true' : 'false');
9
+ const formFieldLabel = getFormFieldLabel();
8
10
  const handleClick = (event) => {
9
11
  event.stopPropagation();
10
12
  if (disabled) {
@@ -21,7 +23,14 @@ const handleClick = (event) => {
21
23
  class:check--indeterminate={isIndeterminate}
22
24
  class:check--disabled={disabled}
23
25
  title={title}>
24
- <button type="button" class="check__box" role="checkbox" aria-checked={ariaChecked} disabled={disabled} onclick={handleClick}>
26
+ <button
27
+ type="button"
28
+ class="check__box"
29
+ role="checkbox"
30
+ aria-checked={ariaChecked}
31
+ aria-labelledby={label ? undefined : formFieldLabel?.labelId}
32
+ disabled={disabled}
33
+ onclick={handleClick}>
25
34
  <span class="check__mark" class:check__mark--visible={isOn || isIndeterminate} aria-hidden="true">
26
35
  <Icon src={isIndeterminate ? IconSubtract : IconCheckmark} />
27
36
  </span>
@@ -1,6 +1,8 @@
1
- <script lang="ts" generics="T">import { Icon } from '../icon';
1
+ <script lang="ts" generics="T">import { getFormFieldLabel, resolveAriaLabelledby } from '../_internal/form-field-label-context';
2
+ import { Icon } from '../icon';
2
3
  import IconCheckmark from '@fluentui/svg-icons/icons/checkmark_12_regular.svg?raw';
3
- const { chips, variant = 'soft', size = 'md', disabled = false, compare = (a, b) => a === b, ...mode } = $props();
4
+ const { chips, variant = 'soft', size = 'md', disabled = false, compare = (a, b) => a === b, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, ...mode } = $props();
5
+ const formFieldLabel = getFormFieldLabel();
4
6
  const isSelected = (chip) => {
5
7
  if (mode.type === 'multi') {
6
8
  return mode.value.some((v) => compare(v, chip.value));
@@ -22,7 +24,12 @@ const handleClick = (chip) => {
22
24
  };
23
25
  </script>
24
26
 
25
- <div class="chip-group chip-group--{size} chip-group--{variant}" class:chip-group--disabled={disabled} role={mode.type === 'multi' ? 'group' : 'radiogroup'}>
27
+ <div
28
+ class="chip-group chip-group--{size} chip-group--{variant}"
29
+ class:chip-group--disabled={disabled}
30
+ role={mode.type === 'multi' ? 'group' : 'radiogroup'}
31
+ aria-label={ariaLabel}
32
+ aria-labelledby={resolveAriaLabelledby(formFieldLabel, ariaLabel, ariaLabelledby)}>
26
33
  {#each chips as chip (chip.value)}
27
34
  {@const selected = isSelected(chip)}
28
35
  <button
@@ -6,10 +6,11 @@ declare function $$render<T>(): {
6
6
  variant?: "soft" | "solid";
7
7
  /** @default 'md' */
8
8
  size?: "sm" | "md";
9
- /** Disable the whole group. */
10
9
  disabled?: boolean;
11
10
  /** Custom equality comparator for non-primitive values (objects with `id`, etc.). @default (a, b) => a === b */
12
11
  compare?: (a: T, b: T) => boolean;
12
+ 'aria-label'?: string;
13
+ 'aria-labelledby'?: string;
13
14
  } & {
14
15
  /** Single-select mode. */
15
16
  type: "single";
@@ -23,10 +24,11 @@ declare function $$render<T>(): {
23
24
  variant?: "soft" | "solid";
24
25
  /** @default 'md' */
25
26
  size?: "sm" | "md";
26
- /** Disable the whole group. */
27
27
  disabled?: boolean;
28
28
  /** Custom equality comparator for non-primitive values (objects with `id`, etc.). @default (a, b) => a === b */
29
29
  compare?: (a: T, b: T) => boolean;
30
+ 'aria-label'?: string;
31
+ 'aria-labelledby'?: string;
30
32
  } & {
31
33
  /** Multi-select mode. */
32
34
  type: "multi";
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">import { ColorHelper } from '../../core/utils';
2
+ import { getFormFieldLabel, resolveAriaLabelledby, stopFormFieldLabelHere } from '../_internal/form-field-label-context';
2
3
  import { Icon } from '../icon';
3
4
  import { Input } from '../input';
4
5
  import { Popover } from '../popover';
@@ -8,7 +9,9 @@ import IconColorF from '@fluentui/svg-icons/icons/color_20_regular.svg?raw';
8
9
  import IconDismiss from '@fluentui/svg-icons/icons/dismiss_20_regular.svg?raw';
9
10
  import { colord } from 'colord';
10
11
  import AwesomeColorPicker from 'svelte-awesome-color-picker';
11
- let { value, variant = 'fill', label, size = 'md', defaultColor, clearable = false, disabled = false, placeholder, on, children, previewIcon, 'aria-label': ariaLabel } = $props();
12
+ let { value, variant = 'fill', label, size = 'md', defaultColor, clearable = false, disabled = false, placeholder, on, children, previewIcon, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby } = $props();
13
+ const formFieldLabel = getFormFieldLabel();
14
+ stopFormFieldLabelHere();
12
15
  const loc = new ColorPickerLocalization();
13
16
  const displayColor = $derived(value || defaultColor || '');
14
17
  const textColor = $derived.by(() => {
@@ -45,7 +48,14 @@ const onColorPickerInput = ({ hex }) => {
45
48
  </script>
46
49
 
47
50
  <div class="color-picker">
48
- <Popover position="bottom-start" keepOpen panel={false} fixedPosition disabled={disabled} aria-label={ariaLabel}>
51
+ <Popover
52
+ position="bottom-start"
53
+ keepOpen
54
+ panel={false}
55
+ fixedPosition
56
+ disabled={disabled}
57
+ aria-label={ariaLabel}
58
+ aria-labelledby={resolveAriaLabelledby(formFieldLabel, ariaLabel, ariaLabelledby)}>
49
59
  {#snippet trigger()}
50
60
  <div class="color-picker__trigger">
51
61
  {#if children}
@@ -9,9 +9,7 @@ type Props = {
9
9
  size?: 'sm' | 'md' | 'lg';
10
10
  /** Default color shown in the picker UI when value is empty (does not set the value itself). */
11
11
  defaultColor?: string;
12
- /** Show a × clear button on the trigger to wipe the value. */
13
12
  clearable?: boolean;
14
- /** Visual disabled state — popover won't open. */
15
13
  disabled?: boolean;
16
14
  /** Placeholder shown on the swatch trigger when no value is selected. @default localized "Not set" */
17
15
  placeholder?: string;
@@ -25,8 +23,8 @@ type Props = {
25
23
  * droplet glyph. Same shape as Input's `icon` prop (Rule 9).
26
24
  */
27
25
  previewIcon?: string | Snippet;
28
- /** Accessible name of the trigger — otherwise it is announced by its color value only. */
29
26
  'aria-label'?: string;
27
+ 'aria-labelledby'?: string;
30
28
  };
31
29
  /**
32
30
  * A color picker with an Input-shaped swatch trigger that opens a popover hosting a full hex/rgb picker
@@ -1,10 +1,12 @@
1
- <script lang="ts">import { InputClearButton } from '../_internal/input-clear-button';
1
+ <script lang="ts">import { getFormFieldLabel, resolveAriaLabelledby } from '../_internal/form-field-label-context';
2
+ import { InputClearButton } from '../_internal/input-clear-button';
2
3
  import { Icon, IconSlot } from '../icon';
3
4
  import DatePickerCalendar from './cmp.date-picker-calendar.svelte';
4
5
  import { DatePickerLocalization } from './date-picker-localization';
5
6
  import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
6
7
  import IconCalendar from '@fluentui/svg-icons/icons/calendar_16_regular.svg?raw';
7
- const { size = 'md', placeholder = '', disabled = false, readonly = false, inert = false, error = false, borderless = false, min, max, locale, name, id = null, autofocus = false, 'aria-label': ariaLabel, 'aria-describedby': ariaDescribedby, 'aria-required': ariaRequired, icon, ...mode } = $props();
8
+ const { size = 'md', placeholder = '', disabled = false, readonly = false, inert = false, error = false, borderless = false, min, max, locale, name, id = null, autofocus = false, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-describedby': ariaDescribedby, 'aria-required': ariaRequired, icon, ...mode } = $props();
9
+ const formFieldLabel = getFormFieldLabel();
8
10
  const localization = new DatePickerLocalization();
9
11
  /** Resolved locale: explicit prop or `'en-US'` default. */
10
12
  const resolvedLocale = $derived(locale ?? 'en-US');
@@ -134,6 +136,7 @@ $effect(() => {
134
136
  aria-haspopup="dialog"
135
137
  aria-expanded={open}
136
138
  aria-label={ariaLabel ?? name}
139
+ aria-labelledby={resolveAriaLabelledby(formFieldLabel, ariaLabel, ariaLabelledby)}
137
140
  aria-describedby={ariaDescribedby}
138
141
  onclick={readonly ? undefined : toggleOpen}>
139
142
  {#if icon}
@@ -156,8 +159,7 @@ $effect(() => {
156
159
  {/if}
157
160
 
158
161
  {#if open}
159
- <!-- preventDefault cancels an ancestor <label>'s activation — a dead-space click inside the panel would forward to the trigger and close the calendar. FormField guards its own; a consumer's plain <label> does not. -->
160
- <div bind:this={panelEl} class="date-picker__panel" role="presentation" onclick={(e) => e.preventDefault()} onkeydown={() => undefined}>
162
+ <div bind:this={panelEl} class="date-picker__panel">
161
163
  <DatePickerCalendar
162
164
  selectedDate={selectedDate}
163
165
  minDate={minDate}
@@ -5,9 +5,7 @@ type BaseProps = {
5
5
  size?: 'sm' | 'md' | 'lg';
6
6
  placeholder?: string;
7
7
  disabled?: boolean;
8
- /** Non-clickable but still visible. Trigger button is disabled (like `disabled`) but cursor stays default. */
9
8
  readonly?: boolean;
10
- /** Visual display mode — non-focusable, no hover/focus animations, hidden clear button. */
11
9
  inert?: boolean;
12
10
  error?: boolean;
13
11
  /** Strip border + background + focus underline. */
@@ -16,17 +14,14 @@ type BaseProps = {
16
14
  min?: LikeDate;
17
15
  /** Latest selectable date (inclusive). */
18
16
  max?: LikeDate;
19
- /**
20
- * BCP-47 locale tag. Drives month/weekday labels, trigger date formatting AND first day of week.
21
- * When omitted, uses an opinionated default: `en-US` formatting + Monday-first calendar.
22
- * When passed (even `'en-US'`), first day of week is derived from the locale via `Intl.Locale.getWeekInfo()`.
23
- */
17
+ /** Omitted: `en-US` formatting with Monday first. Passed (even `'en-US'`): first day of week comes from the locale. */
24
18
  locale?: string;
25
19
  /** Native form name. When set, a hidden input with the ISO value is rendered for form submission. */
26
20
  name?: string;
27
21
  id?: string | null;
28
22
  autofocus?: boolean;
29
23
  'aria-label'?: string;
24
+ 'aria-labelledby'?: string;
30
25
  'aria-describedby'?: string;
31
26
  'aria-required'?: boolean;
32
27
  /**
@@ -36,7 +31,6 @@ type BaseProps = {
36
31
  icon?: IconProp;
37
32
  };
38
33
  type ClearableProps = BaseProps & {
39
- /** Value can be cleared to `null`. Trigger shows a clear (X) button when set. */
40
34
  clearable: true;
41
35
  value: LikeDate | null | undefined;
42
36
  on?: {
@@ -44,7 +38,6 @@ type ClearableProps = BaseProps & {
44
38
  };
45
39
  };
46
40
  type RequiredProps = BaseProps & {
47
- /** Value is required. No clear button. */
48
41
  clearable: false;
49
42
  value: LikeDate;
50
43
  on?: {
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">import DatePicker from './cmp.date-picker.svelte';
2
2
  import TimePicker from './cmp.time-picker.svelte';
3
- const { value, size = 'md', datePlaceholder = '', min, step = 30, on } = $props();
3
+ const { value, size = 'md', datePlaceholder = '', min, step = 30, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, on } = $props();
4
4
  const DEFAULT_TIME = '12:00';
5
5
  const MINUTES_PER_DAY = 24 * 60;
6
6
  const AFTER_LAST_SLOT = '24:00';
@@ -72,9 +72,25 @@ const onTimeChange = (next) => {
72
72
  </script>
73
73
 
74
74
  <div class="date-time date-time--{size}" class:date-time--date-only={!currentDate}>
75
- <DatePicker clearable size={size} value={value} placeholder={datePlaceholder} min={firstAllowedDate ?? undefined} on={{ change: onDateChange }} />
75
+ <DatePicker
76
+ clearable
77
+ size={size}
78
+ value={value}
79
+ placeholder={datePlaceholder}
80
+ min={firstAllowedDate ?? undefined}
81
+ aria-label={ariaLabel}
82
+ aria-labelledby={ariaLabelledby}
83
+ on={{ change: onDateChange }} />
76
84
  {#if currentDate}
77
- <TimePicker clearable={false} size={size} step={step} min={timeMin} value={timeValue} on={{ change: onTimeChange }} />
85
+ <TimePicker
86
+ clearable={false}
87
+ size={size}
88
+ step={step}
89
+ min={timeMin}
90
+ value={timeValue}
91
+ aria-label={ariaLabel}
92
+ aria-labelledby={ariaLabelledby}
93
+ on={{ change: onTimeChange }} />
78
94
  {/if}
79
95
  </div>
80
96
 
@@ -4,14 +4,12 @@ type Props = {
4
4
  /** @default 'md' */
5
5
  size?: 'sm' | 'md' | 'lg';
6
6
  datePlaceholder?: string;
7
- /**
8
- * Earliest selectable date-time (inclusive). On its day only the `step` slots at or after it are offered and an
9
- * earlier time is raised to the first of them; a day with no slot left is not selectable. A `value` before the first
10
- * allowed day still renders, but its time list is empty — only picking an allowed date changes it.
11
- */
7
+ /** Earliest selectable date-time, inclusive: on its day only the slots from it on are offered, and a day with no slot left is disabled. */
12
8
  min?: LikeDate;
13
9
  /** Minute interval for the time list. @default 30 */
14
10
  step?: number;
11
+ 'aria-label'?: string;
12
+ 'aria-labelledby'?: string;
15
13
  on?: {
16
14
  change?: (value: string | null) => void;
17
15
  };
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">import { Singleselect } from '../select';
2
- const { size = 'md', step = 30, min, placeholder = '', disabled = false, readonly = false, inert = false, error = false, borderless = false, id, name, 'aria-label': ariaLabel, 'aria-describedby': ariaDescribedby, 'aria-required': ariaRequired, ...mode } = $props();
2
+ const { size = 'md', step = 30, min, placeholder = '', disabled = false, readonly = false, inert = false, error = false, borderless = false, id, name, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-describedby': ariaDescribedby, 'aria-required': ariaRequired, ...mode } = $props();
3
3
  const MINUTES_PER_DAY = 24 * 60;
4
4
  const pad = (n) => n.toString().padStart(2, '0');
5
5
  const stepOptions = $derived.by(() => {
@@ -44,6 +44,7 @@ const emit = (next) => {
44
44
  id={id}
45
45
  name={name}
46
46
  aria-label={ariaLabel}
47
+ aria-labelledby={ariaLabelledby}
47
48
  aria-describedby={ariaDescribedby}
48
49
  aria-required={ariaRequired}
49
50
  on={{ change: emit }} />
@@ -62,6 +63,7 @@ const emit = (next) => {
62
63
  id={id}
63
64
  name={name}
64
65
  aria-label={ariaLabel}
66
+ aria-labelledby={ariaLabelledby}
65
67
  aria-describedby={ariaDescribedby}
66
68
  aria-required={ariaRequired}
67
69
  on={{ change: emit }} />
@@ -7,20 +7,18 @@ type BaseProps = {
7
7
  min?: string;
8
8
  placeholder?: string;
9
9
  disabled?: boolean;
10
- /** Focusable, value visible, but selection cannot change. */
11
10
  readonly?: boolean;
12
- /** Non-interactive, unstyled view-only mode. */
13
11
  inert?: boolean;
14
12
  error?: boolean;
15
13
  borderless?: boolean;
16
14
  id?: string;
17
15
  name?: string;
18
16
  'aria-label'?: string;
17
+ 'aria-labelledby'?: string;
19
18
  'aria-describedby'?: string;
20
19
  'aria-required'?: boolean;
21
20
  };
22
21
  type ClearableProps = BaseProps & {
23
- /** Value can be cleared to `null`; shows a clear (X) button when set. */
24
22
  clearable: true;
25
23
  value: string | null | undefined;
26
24
  on?: {
@@ -28,7 +26,6 @@ type ClearableProps = BaseProps & {
28
26
  };
29
27
  };
30
28
  type RequiredProps = BaseProps & {
31
- /** Value is required. No clear button. */
32
29
  clearable: false;
33
30
  value: string;
34
31
  on?: {
@@ -15,6 +15,5 @@ export type DocumentTab<TValue = unknown> = {
15
15
  tone?: DocumentTabTone;
16
16
  /** Full text for a label that truncates — the complete file path, typically. */
17
17
  tooltip?: string;
18
- /** Non-interactive — click does nothing. */
19
18
  disabled?: boolean;
20
19
  };
@@ -13,16 +13,16 @@ let { handler, name, label, hint, required = false, validateOn, debounceMs, disa
13
13
  on={on}>
14
14
  {#snippet children(binding, validating)}
15
15
  <FormField label={label} hint={hint} required={required}>
16
- <span class="form-field-validatable__control">{@render field(binding, validating)}</span>
16
+ {#snippet children(ids)}
17
+ <span class="form-field-validatable__control">{@render field(binding, validating, ids)}</span>
18
+ {/snippet}
17
19
  </FormField>
18
20
  {/snippet}
19
21
  </Validatable>
20
22
 
21
23
  <!--
22
24
  @component
23
- FormFieldValidatable — `FormField` and `Validatable` in one component: a label plus a validated field, with the error message outside the `<label>`, so
24
- screen readers announce it as the field's description rather than as part of the field's name. Composing them by hand keeps that only with `Validatable` on
25
- the outside and `FormField` inside its snippet.
25
+ FormFieldValidatable — `FormField` and `Validatable` in one component: a label plus a validated field, with the error message below the field.
26
26
  -->
27
27
 
28
28
  <style>.form-field-validatable__control {
@@ -1,5 +1,6 @@
1
1
  import type { FormValidationHandler } from '../../core/validation';
2
2
  import { type ErrorPlacement, type FieldBinding, type ValidateOnEvent } from '../validatable';
3
+ import { type FormFieldIds } from './cmp.form-field.svelte';
3
4
  import type { Snippet } from 'svelte';
4
5
  declare function $$render<T extends Record<string, unknown>, K extends keyof T & string>(): {
5
6
  props: {
@@ -27,8 +28,8 @@ declare function $$render<T extends Record<string, unknown>, K extends keyof T &
27
28
  change?: (value: T[K]) => void;
28
29
  blur?: () => void;
29
30
  };
30
- /** Receives the wired `FieldBinding<T[K]>` — spread or wire onto the kit input — plus the `validating` flag. */
31
- children: Snippet<[FieldBinding<T[K]>, boolean]>;
31
+ /** Receives the wired `FieldBinding<T[K]>` — spread or wire onto the kit input — the `validating` flag, and `FormField`'s ids. */
32
+ children: Snippet<[FieldBinding<T[K]>, boolean, FormFieldIds]>;
32
33
  };
33
34
  exports: {};
34
35
  bindings: "";
@@ -49,11 +50,7 @@ interface $$IsomorphicComponent {
49
50
  <T extends Record<string, unknown>, K extends keyof T & string>(internal: unknown, props: ReturnType<__sveltets_Render<T, K>['props']> & {}): ReturnType<__sveltets_Render<T, K>['exports']>;
50
51
  z_$$bindings?: ReturnType<__sveltets_Render<any, any>['bindings']>;
51
52
  }
52
- /**
53
- * FormFieldValidatable — `FormField` and `Validatable` in one component: a label plus a validated field, with the error message outside the `<label>`, so
54
- * screen readers announce it as the field's description rather than as part of the field's name. Composing them by hand keeps that only with `Validatable` on
55
- * the outside and `FormField` inside its snippet.
56
- */
53
+ /** FormFieldValidatable — `FormField` and `Validatable` in one component: a label plus a validated field, with the error message below the field. */
57
54
  declare const Cmp: $$IsomorphicComponent;
58
55
  type Cmp<T extends Record<string, unknown>, K extends keyof T & string> = InstanceType<typeof Cmp<T, K>>;
59
56
  export default Cmp;
@@ -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
  }