@streamscloud/kit 0.81.0 → 0.81.1

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 (50) hide show
  1. package/dist/core/utils/html-helper.d.ts +2 -0
  2. package/dist/core/utils/html-helper.js +62 -8
  3. package/dist/styles/_mixins.scss +12 -0
  4. package/dist/ui/color-picker/cmp.color-picker.svelte +2 -2
  5. package/dist/ui/color-picker/cmp.color-picker.svelte.d.ts +2 -0
  6. package/dist/ui/dialog/cmp.dialog-container.svelte +9 -0
  7. package/dist/ui/dialog/cmp.dialog.svelte +1 -1
  8. package/dist/ui/dialog/cmp.dialog.svelte.d.ts +1 -1
  9. package/dist/ui/dialog/dialog-controller.d.ts +2 -0
  10. package/dist/ui/dialog/dialog-controller.js +3 -0
  11. package/dist/ui/dialog/layout/cmp.frameless-dialog.svelte +1 -1
  12. package/dist/ui/dialog/layout/cmp.stepper-dialog.svelte +1 -1
  13. package/dist/ui/file-uploader/cmp.file-uploader.svelte +4 -1
  14. package/dist/ui/file-uploader/file-uploader-localization.d.ts +1 -0
  15. package/dist/ui/file-uploader/file-uploader-localization.js +7 -0
  16. package/dist/ui/form-field/cmp.form-field-validatable.svelte +22 -37
  17. package/dist/ui/form-field/cmp.form-field-validatable.svelte.d.ts +3 -25
  18. package/dist/ui/form-field/cmp.form-field.svelte +23 -12
  19. package/dist/ui/form-field/cmp.form-field.svelte.d.ts +7 -7
  20. package/dist/ui/navigation/cmp.nav-rail-item.svelte +17 -0
  21. package/dist/ui/navigation/cmp.nav-rail-item.svelte.d.ts +1 -1
  22. package/dist/ui/navigation/cmp.nav-rail.svelte +4 -4
  23. package/dist/ui/navigation/cmp.nav-rail.svelte.d.ts +2 -0
  24. package/dist/ui/navigation/nav-rail-item-localization.d.ts +3 -0
  25. package/dist/ui/navigation/nav-rail-item-localization.js +12 -0
  26. package/dist/ui/pin-input/cmp.pin-input.svelte +5 -2
  27. package/dist/ui/pin-input/cmp.pin-input.svelte.d.ts +1 -0
  28. package/dist/ui/pin-input/pin-input-localization.d.ts +3 -0
  29. package/dist/ui/pin-input/pin-input-localization.js +12 -0
  30. package/dist/ui/segmented-control/cmp.segmented-control.svelte +52 -12
  31. package/dist/ui/segmented-control/cmp.segmented-control.svelte.d.ts +12 -10
  32. package/dist/ui/table/cmp.table-draggable.svelte.d.ts +6 -0
  33. package/dist/ui/table/cmp.table.svelte.d.ts +4 -0
  34. package/dist/ui/table/table-cells/table-actions-cell.svelte +3 -1
  35. package/dist/ui/table/table-cells/table-by-cell.svelte +1 -1
  36. package/dist/ui/table/table-core.svelte +51 -7
  37. package/dist/ui/table/table-core.svelte.d.ts +4 -0
  38. package/dist/ui/table/table-localization.d.ts +1 -0
  39. package/dist/ui/table/table-localization.js +7 -0
  40. package/dist/ui/toast/cmp.toast-item.svelte +29 -4
  41. package/dist/ui/toast/types.d.ts +1 -1
  42. package/dist/ui/tooltip/cmp.tooltip.svelte +15 -4
  43. package/dist/ui/tooltip/cmp.tooltip.svelte.d.ts +7 -2
  44. package/dist/ui/validatable/cmp.validatable.svelte +6 -42
  45. package/dist/ui/validatable/cmp.validatable.svelte.d.ts +9 -43
  46. package/dist/ui/validatable/cmp.validation-error.svelte +3 -2
  47. package/dist/ui/validatable/cmp.validation-error.svelte.d.ts +4 -1
  48. package/dist/ui/validatable/field-frame.svelte +2 -2
  49. package/dist/ui/validatable/field-frame.svelte.d.ts +2 -0
  50. package/package.json +1 -1
@@ -14,6 +14,8 @@ export declare class HtmlHelper {
14
14
  static pasteIntoInput(value: string, element: HTMLInputElement | HTMLTextAreaElement): void;
15
15
  /**
16
16
  * Sanitizes SVG markup and serializes it as XML, valid both inline in HTML and as a standalone `image/svg+xml` file.
17
+ * The result never loads anything from outside the document: links and CSS may point only at `#fragment`s or embedded
18
+ * raster `data:image/…` URLs.
17
19
  * Returns null when the markup isn't a well-formed SVG document, or when there is no window.
18
20
  */
19
21
  static sanitizeSvg(svg: string): string | null;
@@ -1,6 +1,57 @@
1
1
  import dp from 'dompurify';
2
2
  const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
3
+ const EMBEDDED_RASTER = /^data:image\/(png|jpeg|gif|webp|avif)[;,]/i;
4
+ const CSS_EXTERNAL_LOADER = /@import|@font-face|image-set\(|image\(|src\(/;
5
+ const CSS_URL_ARGUMENT = /url\([ \t\n\r\f]*['"]?([^'")]*)/g;
6
+ const HTML_INTEGRATION_POINTS = new Set(['title', 'desc']);
3
7
  const isSvgRoot = (element) => element?.localName === 'svg' && element.namespaceURI === SVG_NAMESPACE;
8
+ const isInDocumentReference = (url) => url.startsWith('#') || EMBEDDED_RASTER.test(url);
9
+ const decodeCssEscapes = (css) => css.replace(/\\(?:([0-9a-f]{1,6})[ \t\n\r\f]?|\r\n|([\s\S]))/gi, (_escape, hex, char) => {
10
+ if (hex) {
11
+ const codePoint = parseInt(hex, 16);
12
+ return codePoint > 0 && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : '�';
13
+ }
14
+ return char === '\n' || char === '\r' || char === '\f' || char === undefined ? '' : char;
15
+ });
16
+ const cssLoadsExternalResource = (css) => {
17
+ const decoded = decodeCssEscapes(css).toLowerCase();
18
+ return CSS_EXTERNAL_LOADER.test(decoded) || [...decoded.matchAll(CSS_URL_ARGUMENT)].some(([, url]) => !isInDocumentReference(url));
19
+ };
20
+ const convertCdataSectionsToText = (root) => {
21
+ for (const element of [root, ...root.querySelectorAll('*')]) {
22
+ for (const child of [...element.childNodes]) {
23
+ if (child.nodeType === Node.CDATA_SECTION_NODE) {
24
+ child.replaceWith(child.textContent ?? '');
25
+ }
26
+ }
27
+ }
28
+ };
29
+ const isRemovableElement = (element) => {
30
+ if (HTML_INTEGRATION_POINTS.has(element.parentElement?.localName.toLowerCase() ?? '')) {
31
+ return true;
32
+ }
33
+ const css = element.textContent ?? '';
34
+ return element.localName.toLowerCase() === 'style' && (element.firstElementChild !== null || !css.trim() || cssLoadsExternalResource(css));
35
+ };
36
+ const isRemovableAttribute = (element, attribute) => {
37
+ if (attribute.localName.toLowerCase() !== 'href') {
38
+ return cssLoadsExternalResource(attribute.value);
39
+ }
40
+ return element.localName.toLowerCase() === 'image' ? !EMBEDDED_RASTER.test(attribute.value) : !isInDocumentReference(attribute.value);
41
+ };
42
+ const stripExternalLoadsAndHtmlBreakouts = (root) => {
43
+ for (const element of [root, ...root.querySelectorAll('*')]) {
44
+ if (isRemovableElement(element)) {
45
+ element.remove();
46
+ continue;
47
+ }
48
+ for (const attribute of [...element.attributes]) {
49
+ if (isRemovableAttribute(element, attribute)) {
50
+ element.removeAttributeNode(attribute);
51
+ }
52
+ }
53
+ }
54
+ };
4
55
  export class HtmlHelper {
5
56
  static clearSelection() {
6
57
  if (window.getSelection) {
@@ -9,11 +60,9 @@ export class HtmlHelper {
9
60
  return;
10
61
  }
11
62
  if (selection.empty) {
12
- // Chrome
13
63
  selection.empty();
14
64
  }
15
65
  else if (selection.removeAllRanges) {
16
- // Firefox
17
66
  selection.removeAllRanges();
18
67
  }
19
68
  }
@@ -70,6 +119,8 @@ export class HtmlHelper {
70
119
  }
71
120
  /**
72
121
  * Sanitizes SVG markup and serializes it as XML, valid both inline in HTML and as a standalone `image/svg+xml` file.
122
+ * The result never loads anything from outside the document: links and CSS may point only at `#fragment`s or embedded
123
+ * raster `data:image/…` URLs.
73
124
  * Returns null when the markup isn't a well-formed SVG document, or when there is no window.
74
125
  */
75
126
  static sanitizeSvg(svg) {
@@ -80,8 +131,11 @@ export class HtmlHelper {
80
131
  if (!isSvgRoot(parsed.documentElement) || parsed.getElementsByTagName('parsererror').length) {
81
132
  return null;
82
133
  }
134
+ convertCdataSectionsToText(parsed.documentElement);
83
135
  const sanitized = dp.sanitize(parsed.documentElement, {
84
136
  USE_PROFILES: { svg: true, svgFilters: true },
137
+ ADD_TAGS: ['use'],
138
+ FORBID_TAGS: ['font'],
85
139
  ADD_ATTR: ['dominant-baseline'],
86
140
  RETURN_DOM_FRAGMENT: true
87
141
  });
@@ -89,6 +143,7 @@ export class HtmlHelper {
89
143
  if (!isSvgRoot(root)) {
90
144
  return null;
91
145
  }
146
+ stripExternalLoadsAndHtmlBreakouts(root);
92
147
  return new XMLSerializer().serializeToString(root);
93
148
  }
94
149
  static sanitizeHtml(html) {
@@ -101,7 +156,6 @@ export class HtmlHelper {
101
156
  html = HtmlHelper.sanitizeHtml(html);
102
157
  const tmp = document.createElement('div');
103
158
  tmp.innerHTML = html;
104
- // move element out of visible area
105
159
  tmp.style.position = 'fixed';
106
160
  tmp.style.left = '200%';
107
161
  // element should be appended into the DOM otherwise element.innerText doesn't contain line breaks (\n) (at least at Chrome)
@@ -119,21 +173,21 @@ export class HtmlHelper {
119
173
  }
120
174
  }
121
175
  class HtmlSanitizer {
122
- static initialized = false;
176
+ static _initialized = false;
123
177
  static sanitize(html) {
124
- HtmlSanitizer.ensureInitialized();
178
+ HtmlSanitizer._ensureInitialized();
125
179
  return dp.sanitize(html, {
126
180
  ADD_ATTR: ['target']
127
181
  });
128
182
  }
129
- static ensureInitialized() {
130
- if (!HtmlSanitizer.initialized) {
183
+ static _ensureInitialized() {
184
+ if (!HtmlSanitizer._initialized) {
131
185
  dp.addHook('afterSanitizeAttributes', (node) => {
132
186
  if (node.tagName === 'A' && node.hasAttribute('target')) {
133
187
  node.setAttribute('rel', 'noopener noreferrer');
134
188
  }
135
189
  });
136
- HtmlSanitizer.initialized = true;
190
+ HtmlSanitizer._initialized = true;
137
191
  }
138
192
  }
139
193
  }
@@ -50,6 +50,18 @@
50
50
  }
51
51
  }
52
52
 
53
+ @mixin visually-hidden {
54
+ position: absolute;
55
+ width: 1px;
56
+ height: 1px;
57
+ margin: -1px;
58
+ padding: 0;
59
+ overflow: hidden;
60
+ clip-path: inset(50%);
61
+ white-space: nowrap;
62
+ border: 0;
63
+ }
64
+
53
65
  @mixin absolute-fill {
54
66
  position: absolute;
55
67
  inset: 0;
@@ -8,7 +8,7 @@ import IconColorF from '@fluentui/svg-icons/icons/color_20_regular.svg?raw';
8
8
  import IconDismiss from '@fluentui/svg-icons/icons/dismiss_20_regular.svg?raw';
9
9
  import { colord } from 'colord';
10
10
  import AwesomeColorPicker from 'svelte-awesome-color-picker';
11
- let { value, variant = 'fill', label, size = 'md', defaultColor, clearable = false, disabled = false, placeholder, on, children, previewIcon } = $props();
11
+ let { value, variant = 'fill', label, size = 'md', defaultColor, clearable = false, disabled = false, placeholder, on, children, previewIcon, 'aria-label': ariaLabel } = $props();
12
12
  const loc = new ColorPickerLocalization();
13
13
  const displayColor = $derived(value || defaultColor || '');
14
14
  const textColor = $derived.by(() => {
@@ -45,7 +45,7 @@ const onColorPickerInput = ({ hex }) => {
45
45
  </script>
46
46
 
47
47
  <div class="color-picker">
48
- <Popover position="bottom-start" keepOpen panel={false} fixedPosition disabled={disabled}>
48
+ <Popover position="bottom-start" keepOpen panel={false} fixedPosition disabled={disabled} aria-label={ariaLabel}>
49
49
  {#snippet trigger()}
50
50
  <div class="color-picker__trigger">
51
51
  {#if children}
@@ -25,6 +25,8 @@ type Props = {
25
25
  * droplet glyph. Same shape as Input's `icon` prop (Rule 9).
26
26
  */
27
27
  previewIcon?: string | Snippet;
28
+ /** Accessible name of the trigger — otherwise it is announced by its color value only. */
29
+ 'aria-label'?: string;
28
30
  };
29
31
  /**
30
32
  * A color picker with an Input-shaped swatch trigger that opens a popover hosting a full hex/rgb picker
@@ -37,10 +37,18 @@ const handleBackdropClick = (event) => {
37
37
  dialog.controller.cancel();
38
38
  }
39
39
  };
40
+ const opener = document.activeElement;
41
+ const restoreFocus = () => {
42
+ if (opener instanceof HTMLElement && opener.isConnected && (document.activeElement ?? document.body) === document.body) {
43
+ opener.focus();
44
+ }
45
+ };
40
46
  $effect(() => {
41
47
  if (dialogElement && !dialogElement.open) {
42
48
  dialogElement.showModal();
43
49
  }
50
+ // Deferred: closeAll tears the outer dialog down first, while focus is still inside the inner one.
51
+ return () => queueMicrotask(restoreFocus);
44
52
  });
45
53
  $effect(() => {
46
54
  const handleNavigateBack = () => {
@@ -82,6 +90,7 @@ const isStretchContent = $derived(position === 'full-screen' || position === 'fu
82
90
  class:dialog-container--position-full-height={position === 'full-height'}
83
91
  class:dialog-container--position-side-left={position === 'side-left'}
84
92
  class:dialog-container--position-side-right={position === 'side-right'}
93
+ aria-labelledby={dialog.controller.titleId}
85
94
  oncancel={handleCancel}
86
95
  onclick={handleBackdropClick}
87
96
  style:--dialog-container--custom-width={customWidth}>
@@ -9,7 +9,7 @@ const showCloseButton = $derived(!hideCloseButton && !controller.settings.nonCan
9
9
  {:else if title || showCloseButton}
10
10
  <div class="dialog__header">
11
11
  {#if title}
12
- <div class="dialog__title">
12
+ <div id={controller.titleId} class="dialog__title">
13
13
  {@render title()}
14
14
  </div>
15
15
  {/if}
@@ -12,7 +12,7 @@ declare function $$render<TResult = void, TCancelResult = void>(): {
12
12
  body?: Snippet;
13
13
  /** Footer content rendered at the bottom (typically action buttons) */
14
14
  footer?: Snippet;
15
- /** Replaces the entire default header (title + close button) */
15
+ /** Replaces the entire default header (title + close button). Put `id={controller.titleId}` on your title — the dialog is named by it. */
16
16
  headerSection?: Snippet;
17
17
  /** Replaces the entire default body wrapper */
18
18
  bodySection?: Snippet;
@@ -1,6 +1,8 @@
1
1
  import { DialogContainerSettings, DialogSettings, type DialogResult } from './types.svelte';
2
2
  export declare class DialogController<TResult = void, TCancelResult = void> {
3
3
  readonly dialogId: number | string;
4
+ /** Id for the element holding the dialog's title — the dialog is labeled by it. */
5
+ readonly titleId: string;
4
6
  settings: DialogSettings;
5
7
  containerSettings: DialogContainerSettings;
6
8
  private _onClose;
@@ -2,6 +2,8 @@ import { Deferred } from '../../core/deferred';
2
2
  import { DialogContainerSettings, DialogSettings } from './types.svelte';
3
3
  export class DialogController {
4
4
  dialogId;
5
+ /** Id for the element holding the dialog's title — the dialog is labeled by it. */
6
+ titleId;
5
7
  settings;
6
8
  containerSettings;
7
9
  _onClose;
@@ -11,6 +13,7 @@ export class DialogController {
11
13
  _isClosed = false;
12
14
  constructor(init) {
13
15
  this.dialogId = init.id;
16
+ this.titleId = `sc-kit-dialog-title-${String(init.id).replace(/[^\w-]/g, '')}`;
14
17
  this._onClose = init.onClose;
15
18
  this._explicitSettings = init.settings;
16
19
  this._explicitContainerSettings = init.containerSettings;
@@ -9,7 +9,7 @@ let { controller, title, hideCloseButton = false, children, footer } = $props();
9
9
  {#if !hideCloseButton}
10
10
  <span class="frameless-dialog__close"><DialogCloseButton controller={controller} /></span>
11
11
  {/if}
12
- <div class="frameless-dialog__title" class:frameless-dialog__title--no-close={hideCloseButton}>{title}</div>
12
+ <div id={controller.titleId} class="frameless-dialog__title" class:frameless-dialog__title--no-close={hideCloseButton}>{title}</div>
13
13
  {@render children()}
14
14
  </div>
15
15
  {/snippet}
@@ -59,7 +59,7 @@ const handleStepClick = (clicked) => {
59
59
  <span class="stepper-dialog__icon" aria-hidden="true"><IconSlot icon={icon} /></span>
60
60
  {/if}
61
61
  <div class="stepper-dialog__title-block">
62
- <div class="stepper-dialog__title">{title}</div>
62
+ <div id={controller.titleId} class="stepper-dialog__title">{title}</div>
63
63
  {#if subtitle}
64
64
  <div class="stepper-dialog__subtitle">{subtitle}</div>
65
65
  {/if}
@@ -1,7 +1,9 @@
1
1
  <script lang="ts">import { deriveAccept, FileValidator } from '../../core/files';
2
2
  import { Icon } from '../icon';
3
+ import { FileUploaderLocalization } from './file-uploader-localization';
3
4
  import IconArrowUpload from '@fluentui/svg-icons/icons/arrow_upload_24_regular.svg?raw';
4
5
  const { multiple = false, validationRules, disabled = false, title = '', description = '', on } = $props();
6
+ const localization = new FileUploaderLocalization();
5
7
  const accept = $derived(deriveAccept(validationRules ?? []));
6
8
  let inputRef = $state.raw(undefined);
7
9
  let dragOver = $state(false);
@@ -90,6 +92,7 @@ const handleDrop = (e) => {
90
92
  class:file-uploader__zone--drag={dragOver && !dragInvalid}
91
93
  class:file-uploader__zone--drag-invalid={dragOver && dragInvalid}
92
94
  disabled={disabled}
95
+ aria-label={title || description ? undefined : localization.selectFiles}
93
96
  onclick={() => inputRef?.click()}
94
97
  ondragenter={handleDragEnter}
95
98
  ondragleave={handleDragLeave}
@@ -100,8 +103,8 @@ const handleDrop = (e) => {
100
103
  </span>
101
104
  {#if title}<span class="file-uploader__title">{title}</span>{/if}
102
105
  {#if description}<span class="file-uploader__desc">{description}</span>{/if}
103
- <input bind:this={inputRef} class="file-uploader__input" type="file" multiple={multiple} accept={accept} disabled={disabled} onchange={handleInput} />
104
106
  </button>
107
+ <input bind:this={inputRef} class="file-uploader__input" type="file" multiple={multiple} accept={accept} disabled={disabled} onchange={handleInput} />
105
108
  </div>
106
109
 
107
110
  <!--
@@ -1,4 +1,5 @@
1
1
  export declare class FileUploaderLocalization {
2
2
  get uploadingHint(): string;
3
+ get selectFiles(): string;
3
4
  removeAria(name: string): string;
4
5
  }
@@ -7,12 +7,19 @@ const loc = {
7
7
  removeAria: {
8
8
  en: 'Remove {name}',
9
9
  no: 'Fjern {name}'
10
+ },
11
+ selectFiles: {
12
+ en: 'Select files',
13
+ no: 'Velg filer'
10
14
  }
11
15
  };
12
16
  export class FileUploaderLocalization {
13
17
  get uploadingHint() {
14
18
  return loc.uploadingHint[AppLocale.current];
15
19
  }
20
+ get selectFiles() {
21
+ return loc.selectFiles[AppLocale.current];
22
+ }
16
23
  removeAria(name) {
17
24
  return loc.removeAria[AppLocale.current].replace('{name}', name);
18
25
  }
@@ -1,45 +1,30 @@
1
1
  <script lang="ts" generics="T extends Record<string, unknown>, K extends keyof T & string">import { Validatable } from '../validatable';
2
2
  import { default as FormField } from './cmp.form-field.svelte';
3
- let { handler, name, label, hint, required = false, validateOn, debounceMs, disableTouchedTracking, errorPlacement, on, children } = $props();
3
+ let { handler, name, label, hint, required = false, validateOn, debounceMs, disableTouchedTracking, errorPlacement, on, children: field } = $props();
4
4
  </script>
5
5
 
6
- <FormField label={label} hint={hint} required={required}>
7
- <Validatable
8
- handler={handler}
9
- name={name}
10
- validateOn={validateOn}
11
- debounceMs={debounceMs}
12
- disableTouchedTracking={disableTouchedTracking}
13
- errorPlacement={errorPlacement}
14
- on={on}
15
- children={children} />
16
- </FormField>
6
+ <Validatable
7
+ handler={handler}
8
+ name={name}
9
+ validateOn={validateOn}
10
+ debounceMs={debounceMs}
11
+ disableTouchedTracking={disableTouchedTracking}
12
+ errorPlacement={errorPlacement}
13
+ on={on}>
14
+ {#snippet children(binding, validating)}
15
+ <FormField label={label} hint={hint} required={required}>
16
+ <span class="form-field-validatable__control">{@render field(binding, validating)}</span>
17
+ </FormField>
18
+ {/snippet}
19
+ </Validatable>
17
20
 
18
21
  <!--
19
22
  @component
20
- FormFieldValidatable — convenience wrapper that composes `<FormField>` and `<Validatable>` in a single component. Reduces the 3-level nesting (FormField → Validatable → Input) to a flat callsite when you need both a label and validation binding.
21
-
22
- ### Usage
23
- ```svelte
24
- <FormFieldValidatable {handler} name="email" label="Email" required>
25
- {#snippet children(field)}
26
- <Input {...field} type="email" placeholder="you@example.com" />
27
- {/snippet}
28
- </FormFieldValidatable>
29
- ```
30
-
31
- The snippet takes a second `validating: boolean` argument — true from the value change until the validator has caught up, debounce window included. See `Validatable` for what it drives.
32
-
33
- Identical visual output to:
34
- ```svelte
35
- <FormField label="Email" required>
36
- <Validatable {handler} name="email">
37
- {#snippet children(field)}
38
- <Input {...field} type="email" placeholder="you@example.com" />
39
- {/snippet}
40
- </Validatable>
41
- </FormField>
42
- ```
43
-
44
- Both `FormField`'s label/required and `Validatable`'s error slot (`errorPlacement`) apply unchanged — this cmp adds no styling, just collapses the wiring.
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.
45
26
  -->
27
+
28
+ <style>.form-field-validatable__control {
29
+ display: block;
30
+ }</style>
@@ -50,31 +50,9 @@ interface $$IsomorphicComponent {
50
50
  z_$$bindings?: ReturnType<__sveltets_Render<any, any>['bindings']>;
51
51
  }
52
52
  /**
53
- * FormFieldValidatable — convenience wrapper that composes `<FormField>` and `<Validatable>` in a single component. Reduces the 3-level nesting (FormField → Validatable → Input) to a flat callsite when you need both a label and validation binding.
54
- *
55
- * ### Usage
56
- * ```svelte
57
- * <FormFieldValidatable {handler} name="email" label="Email" required>
58
- * {#snippet children(field)}
59
- * <Input {...field} type="email" placeholder="you@example.com" />
60
- * {/snippet}
61
- * </FormFieldValidatable>
62
- * ```
63
- *
64
- * The snippet takes a second `validating: boolean` argument — true from the value change until the validator has caught up, debounce window included. See `Validatable` for what it drives.
65
- *
66
- * Identical visual output to:
67
- * ```svelte
68
- * <FormField label="Email" required>
69
- * <Validatable {handler} name="email">
70
- * {#snippet children(field)}
71
- * <Input {...field} type="email" placeholder="you@example.com" />
72
- * {/snippet}
73
- * </Validatable>
74
- * </FormField>
75
- * ```
76
- *
77
- * Both `FormField`'s label/required and `Validatable`'s error slot (`errorPlacement`) apply unchanged — this cmp adds no styling, just collapses the wiring.
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.
78
56
  */
79
57
  declare const Cmp: $$IsomorphicComponent;
80
58
  type Cmp<T extends Record<string, unknown>, K extends keyof T & string> = InstanceType<typeof Cmp<T, K>>;
@@ -3,8 +3,7 @@
3
3
  const INTERACTIVE_CONTENT = 'a[href], audio[controls], button, details, embed, iframe, img[usemap], input:not([type="hidden"]), label, select, textarea, video[controls]';
4
4
  </script>
5
5
 
6
- <script lang="ts">import { DomHelper } from '../../core/utils';
7
- import { Icon } from '../icon';
6
+ <script lang="ts">import { Icon } from '../icon';
8
7
  import { Tooltip } from '../tooltip';
9
8
  import IconInfo from '@fluentui/svg-icons/icons/info_20_regular.svg?raw';
10
9
  let { label, hint, required = false, children } = $props();
@@ -15,10 +14,9 @@ const swallowClick = (event) => {
15
14
  event.stopPropagation();
16
15
  // This click never reaches guardLabelActivation, so the flag would outlive the gesture and swallow a later keyboard-activated click.
17
16
  swallowLabelActivation = false;
18
- if (DomHelper.isInsideInteractive(event.target)) {
19
- return;
17
+ if (forwardsLabelActivation(event.target)) {
18
+ event.preventDefault();
20
19
  }
21
- event.preventDefault();
22
20
  };
23
21
  const forwardsLabelActivation = (target) => {
24
22
  if (!(target instanceof Element)) {
@@ -53,9 +51,15 @@ $effect(() => {
53
51
  const guardLabelActivation = (event) => {
54
52
  const swallow = swallowLabelActivation;
55
53
  swallowLabelActivation = false;
56
- if (swallow && forwardsLabelActivation(event.target)) {
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) {
57
60
  event.preventDefault();
58
61
  }
62
+ radio?.closest('[role="radiogroup"]')?.querySelector('[role="radio"][tabindex="0"]')?.focus();
59
63
  };
60
64
  </script>
61
65
 
@@ -76,7 +80,9 @@ const guardLabelActivation = (event) => {
76
80
  <span class="form-field__hint" onclick={swallowClick}>
77
81
  {#if typeof hint === 'string'}
78
82
  <Tooltip text={hint}>
79
- <Icon src={IconInfo} size="sm" color="muted" />
83
+ <span class="form-field__hint-trigger" role="button" tabindex="0" aria-label={hint}>
84
+ <Icon src={IconInfo} size="sm" color="muted" />
85
+ </span>
80
86
  </Tooltip>
81
87
  {:else}
82
88
  {@render hint()}
@@ -91,11 +97,8 @@ const guardLabelActivation = (event) => {
91
97
 
92
98
  <!--
93
99
  @component
94
- FormField — pure layout shell: optional label (string or snippet) above an input slot. The root is a `<label>` element, so the first labelable form control inside is automatically associated via HTML's implicit-association rule — no id wiring required. Does NOT render errors or helper text; pair with `Validatable` for that.
95
-
96
- Clicking the label opens a popup field inside it (select, date picker, color picker) but never re-opens one that is already expanded — that click dismisses it, exactly like a click on empty space. The same guard keeps a drag that starts inside an expanded panel from closing it, so a color picker's gradient and its hue / alpha bars stay usable when the pointer leaves the panel. A nested `<label>` — a `Checkbox` sitting next to the popup field — keeps its own activation either way. HTML forbids a `<label>` inside a `<label>`, so that pairing is tolerated rather than recommended: give the `Checkbox` its own row outside the `FormField` when you can.
97
-
98
- `hint` pins an affordance to the trailing edge of the label row — a string gives the standard info icon with a tooltip, a snippet takes over completely (button, link, badge). It rides on the label row, so it renders only when `label` is set. Clicks inside the hint are swallowed, so an interactive hint never activates the labelled field.
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.
99
102
 
100
103
  ### CSS Custom Properties
101
104
  | Property | Description | Default |
@@ -136,4 +139,12 @@ Clicking the label opens a popup field inside it (select, date picker, color pic
136
139
  margin-inline-start: auto;
137
140
  color: var(--_ff--hint-color);
138
141
  font-weight: var(--sc-kit--font-weight--regular);
142
+ }
143
+ .form-field__hint-trigger {
144
+ display: inline-flex;
145
+ border-radius: var(--sc-kit--radius--sm);
146
+ }
147
+ .form-field__hint-trigger:focus-visible {
148
+ outline: 2px solid var(--sc-kit--color--border--focus);
149
+ outline-offset: 2px;
139
150
  }</style>
@@ -2,19 +2,19 @@ import type { Snippet } from 'svelte';
2
2
  type Props = {
3
3
  /** Label rendered above the input. String renders as text; Snippet allows extra content (e.g. tooltip icon) next to the text. */
4
4
  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 with that text in a tooltip; Snippet renders anything (button, link, badge). Clicks inside never reach the field. */
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. */
6
6
  hint?: string | Snippet;
7
7
  /** Appends a danger-colored `*` to the label. Purely visual — does not enforce anything. */
8
8
  required?: boolean;
9
- /** Input slot. The wrapping `<label>` implicitly associates with the first labelable control inside (HTML standard), so no id wiring is needed. */
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
+ */
10
13
  children: Snippet;
11
14
  };
12
15
  /**
13
- * FormField — pure layout shell: optional label (string or snippet) above an input slot. The root is a `<label>` element, so the first labelable form control inside is automatically associated via HTML's implicit-association rule — no id wiring required. Does NOT render errors or helper text; pair with `Validatable` for that.
14
- *
15
- * Clicking the label opens a popup field inside it (select, date picker, color picker) but never re-opens one that is already expanded — that click dismisses it, exactly like a click on empty space. The same guard keeps a drag that starts inside an expanded panel from closing it, so a color picker's gradient and its hue / alpha bars stay usable when the pointer leaves the panel. A nested `<label>` — a `Checkbox` sitting next to the popup field — keeps its own activation either way. HTML forbids a `<label>` inside a `<label>`, so that pairing is tolerated rather than recommended: give the `Checkbox` its own row outside the `FormField` when you can.
16
- *
17
- * `hint` pins an affordance to the trailing edge of the label row — a string gives the standard info icon with a tooltip, a snippet takes over completely (button, link, badge). It rides on the label row, so it renders only when `label` is set. Clicks inside the hint are swallowed, so an interactive hint never activates the labelled field.
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.
18
18
  *
19
19
  * ### CSS Custom Properties
20
20
  * | Property | Description | Default |
@@ -1,5 +1,7 @@
1
1
  <script lang="ts">import { IconSlot } from '../icon';
2
+ import { NavRailItemLocalization } from './nav-rail-item-localization';
2
3
  const { label, icon, href, active = false, error = false, disabled = false, target, rel, on } = $props();
4
+ const localization = new NavRailItemLocalization();
3
5
  const handleClick = (event) => {
4
6
  if (disabled) {
5
7
  return;
@@ -11,6 +13,9 @@ const handleClick = (event) => {
11
13
  {#snippet tile()}
12
14
  <span class="nav-rail-item__icon" aria-hidden="true"><IconSlot icon={icon} /></span>
13
15
  <span class="nav-rail-item__label">{label}</span>
16
+ {#if error}
17
+ <span class="nav-rail-item__error-text">{localization.hasError}</span>
18
+ {/if}
14
19
  {/snippet}
15
20
 
16
21
  {#if href && !disabled}
@@ -122,4 +127,16 @@ CSS variables (settable on the parent rail or any ancestor).
122
127
  font-size: var(--_nav-rail--item-label-size);
123
128
  line-height: var(--sc-kit--leading--tight);
124
129
  white-space: nowrap;
130
+ }
131
+
132
+ .nav-rail-item__error-text {
133
+ position: absolute;
134
+ width: 1px;
135
+ height: 1px;
136
+ margin: -1px;
137
+ padding: 0;
138
+ overflow: hidden;
139
+ clip-path: inset(50%);
140
+ white-space: nowrap;
141
+ border: 0;
125
142
  }</style>
@@ -7,7 +7,7 @@ type Props = {
7
7
  href?: string;
8
8
  /** Highlight as the current section. Consumer-controlled. @default false */
9
9
  active?: boolean;
10
- /** Paint the tile in the danger color to signal an error in its section. @default false */
10
+ /** Paint the tile in the danger color to signal an error in its section; screen readers hear it as part of the label. @default false */
11
11
  error?: boolean;
12
12
  disabled?: boolean;
13
13
  /** Anchor target — only relevant when `href` is set. */
@@ -5,7 +5,7 @@
5
5
  import { NavRailLocalization } from './nav-rail-localization';
6
6
  import IconChevronDown from '@fluentui/svg-icons/icons/chevron_down_20_regular.svg?raw';
7
7
  import IconChevronUp from '@fluentui/svg-icons/icons/chevron_up_20_regular.svg?raw';
8
- const { brand, children, footerToggles } = $props();
8
+ const { brand, children, footerToggles, 'aria-label': ariaLabel } = $props();
9
9
  const localization = new NavRailLocalization();
10
10
  const SCROLL_STEP = 120;
11
11
  let listNode = $state(null);
@@ -33,7 +33,7 @@ const scrollDown = () => {
33
33
  };
34
34
  </script>
35
35
 
36
- <div class="nav-rail" role="navigation">
36
+ <div class="nav-rail" role="navigation" aria-label={ariaLabel}>
37
37
  {#if brand}
38
38
  <div class="nav-rail__brand">
39
39
  {@render brand()}
@@ -41,7 +41,7 @@ const scrollDown = () => {
41
41
  {/if}
42
42
  <div class="nav-rail__scroll-area">
43
43
  {#if canScrollUp}
44
- <button type="button" class="nav-rail__scroll-btn nav-rail__scroll-btn--up" onclick={scrollUp} aria-label={localization.scrollUp}>
44
+ <button type="button" class="nav-rail__scroll-btn nav-rail__scroll-btn--up" tabindex="-1" onclick={scrollUp} aria-label={localization.scrollUp}>
45
45
  <Icon src={IconChevronUp} size="sm" />
46
46
  </button>
47
47
  {/if}
@@ -55,7 +55,7 @@ const scrollDown = () => {
55
55
  {@render children()}
56
56
  </div>
57
57
  {#if canScrollDown}
58
- <button type="button" class="nav-rail__scroll-btn nav-rail__scroll-btn--down" onclick={scrollDown} aria-label={localization.scrollDown}>
58
+ <button type="button" class="nav-rail__scroll-btn nav-rail__scroll-btn--down" tabindex="-1" onclick={scrollDown} aria-label={localization.scrollDown}>
59
59
  <Icon src={IconChevronDown} size="sm" />
60
60
  </button>
61
61
  {/if}
@@ -13,6 +13,8 @@ type Props = {
13
13
  children: Snippet;
14
14
  /** Icon-only toggle buttons stacked at the bottom of the rail (e.g. expand panel, account). */
15
15
  footerToggles?: NavRailFooterToggle[];
16
+ /** Accessible name of the navigation landmark — needed when the page has more than one navigation. */
17
+ 'aria-label'?: string;
16
18
  };
17
19
  /**
18
20
  * NavRail — first-level icon-and-label navigation column for app shells. 70px wide by default;