@streamscloud/kit 0.80.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.
- package/dist/core/utils/html-helper.d.ts +7 -1
- package/dist/core/utils/html-helper.js +83 -10
- package/dist/styles/_mixins.scss +12 -0
- package/dist/ui/color-picker/cmp.color-picker.svelte +2 -2
- package/dist/ui/color-picker/cmp.color-picker.svelte.d.ts +2 -0
- package/dist/ui/dialog/cmp.dialog-container.svelte +9 -0
- package/dist/ui/dialog/cmp.dialog.svelte +1 -1
- package/dist/ui/dialog/cmp.dialog.svelte.d.ts +1 -1
- package/dist/ui/dialog/dialog-controller.d.ts +2 -0
- package/dist/ui/dialog/dialog-controller.js +3 -0
- package/dist/ui/dialog/layout/cmp.frameless-dialog.svelte +1 -1
- package/dist/ui/dialog/layout/cmp.stepper-dialog.svelte +1 -1
- package/dist/ui/file-uploader/cmp.file-uploader.svelte +4 -1
- package/dist/ui/file-uploader/file-uploader-localization.d.ts +1 -0
- package/dist/ui/file-uploader/file-uploader-localization.js +7 -0
- package/dist/ui/form-field/cmp.form-field-validatable.svelte +22 -37
- package/dist/ui/form-field/cmp.form-field-validatable.svelte.d.ts +3 -25
- package/dist/ui/form-field/cmp.form-field.svelte +23 -12
- package/dist/ui/form-field/cmp.form-field.svelte.d.ts +7 -7
- package/dist/ui/icon/cmp.icon.svelte +6 -5
- package/dist/ui/icon/cmp.icon.svelte.d.ts +1 -1
- package/dist/ui/navigation/cmp.nav-rail-item.svelte +17 -0
- package/dist/ui/navigation/cmp.nav-rail-item.svelte.d.ts +1 -1
- package/dist/ui/navigation/cmp.nav-rail.svelte +4 -4
- package/dist/ui/navigation/cmp.nav-rail.svelte.d.ts +2 -0
- package/dist/ui/navigation/nav-rail-item-localization.d.ts +3 -0
- package/dist/ui/navigation/nav-rail-item-localization.js +12 -0
- package/dist/ui/pin-input/cmp.pin-input.svelte +5 -2
- package/dist/ui/pin-input/cmp.pin-input.svelte.d.ts +1 -0
- package/dist/ui/pin-input/pin-input-localization.d.ts +3 -0
- package/dist/ui/pin-input/pin-input-localization.js +12 -0
- package/dist/ui/segmented-control/cmp.segmented-control.svelte +52 -12
- package/dist/ui/segmented-control/cmp.segmented-control.svelte.d.ts +12 -10
- package/dist/ui/table/cmp.table-draggable.svelte.d.ts +6 -0
- package/dist/ui/table/cmp.table.svelte.d.ts +4 -0
- package/dist/ui/table/table-cells/table-actions-cell.svelte +3 -1
- package/dist/ui/table/table-cells/table-by-cell.svelte +1 -1
- package/dist/ui/table/table-core.svelte +51 -7
- package/dist/ui/table/table-core.svelte.d.ts +4 -0
- package/dist/ui/table/table-localization.d.ts +1 -0
- package/dist/ui/table/table-localization.js +7 -0
- package/dist/ui/toast/cmp.toast-item.svelte +29 -4
- package/dist/ui/toast/types.d.ts +1 -1
- package/dist/ui/tooltip/cmp.tooltip.svelte +15 -4
- package/dist/ui/tooltip/cmp.tooltip.svelte.d.ts +7 -2
- package/dist/ui/validatable/cmp.validatable.svelte +6 -42
- package/dist/ui/validatable/cmp.validatable.svelte.d.ts +9 -43
- package/dist/ui/validatable/cmp.validation-error.svelte +3 -2
- package/dist/ui/validatable/cmp.validation-error.svelte.d.ts +4 -1
- package/dist/ui/validatable/field-frame.svelte +2 -2
- package/dist/ui/validatable/field-frame.svelte.d.ts +2 -0
- package/package.json +1 -1
|
@@ -12,7 +12,13 @@ export declare class HtmlHelper {
|
|
|
12
12
|
*/
|
|
13
13
|
static trimElementValue(element: HTMLInputElement | HTMLTextAreaElement): boolean;
|
|
14
14
|
static pasteIntoInput(value: string, element: HTMLInputElement | HTMLTextAreaElement): void;
|
|
15
|
-
|
|
15
|
+
/**
|
|
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.
|
|
19
|
+
* Returns null when the markup isn't a well-formed SVG document, or when there is no window.
|
|
20
|
+
*/
|
|
21
|
+
static sanitizeSvg(svg: string): string | null;
|
|
16
22
|
static sanitizeHtml(html: string): string;
|
|
17
23
|
static stripHtml(html: string, lineBreaksReplacer?: string | null, trimResult?: boolean): string;
|
|
18
24
|
}
|
|
@@ -1,4 +1,57 @@
|
|
|
1
1
|
import dp from 'dompurify';
|
|
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']);
|
|
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
|
+
};
|
|
2
55
|
export class HtmlHelper {
|
|
3
56
|
static clearSelection() {
|
|
4
57
|
if (window.getSelection) {
|
|
@@ -7,11 +60,9 @@ export class HtmlHelper {
|
|
|
7
60
|
return;
|
|
8
61
|
}
|
|
9
62
|
if (selection.empty) {
|
|
10
|
-
// Chrome
|
|
11
63
|
selection.empty();
|
|
12
64
|
}
|
|
13
65
|
else if (selection.removeAllRanges) {
|
|
14
|
-
// Firefox
|
|
15
66
|
selection.removeAllRanges();
|
|
16
67
|
}
|
|
17
68
|
}
|
|
@@ -66,11 +117,34 @@ export class HtmlHelper {
|
|
|
66
117
|
}));
|
|
67
118
|
element.focus();
|
|
68
119
|
}
|
|
120
|
+
/**
|
|
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.
|
|
124
|
+
* Returns null when the markup isn't a well-formed SVG document, or when there is no window.
|
|
125
|
+
*/
|
|
69
126
|
static sanitizeSvg(svg) {
|
|
70
127
|
if (typeof window === 'undefined') {
|
|
71
|
-
return
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const parsed = new DOMParser().parseFromString(svg, 'image/svg+xml');
|
|
131
|
+
if (!isSvgRoot(parsed.documentElement) || parsed.getElementsByTagName('parsererror').length) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
convertCdataSectionsToText(parsed.documentElement);
|
|
135
|
+
const sanitized = dp.sanitize(parsed.documentElement, {
|
|
136
|
+
USE_PROFILES: { svg: true, svgFilters: true },
|
|
137
|
+
ADD_TAGS: ['use'],
|
|
138
|
+
FORBID_TAGS: ['font'],
|
|
139
|
+
ADD_ATTR: ['dominant-baseline'],
|
|
140
|
+
RETURN_DOM_FRAGMENT: true
|
|
141
|
+
});
|
|
142
|
+
const root = sanitized.firstElementChild;
|
|
143
|
+
if (!isSvgRoot(root)) {
|
|
144
|
+
return null;
|
|
72
145
|
}
|
|
73
|
-
|
|
146
|
+
stripExternalLoadsAndHtmlBreakouts(root);
|
|
147
|
+
return new XMLSerializer().serializeToString(root);
|
|
74
148
|
}
|
|
75
149
|
static sanitizeHtml(html) {
|
|
76
150
|
return HtmlSanitizer.sanitize(html);
|
|
@@ -82,7 +156,6 @@ export class HtmlHelper {
|
|
|
82
156
|
html = HtmlHelper.sanitizeHtml(html);
|
|
83
157
|
const tmp = document.createElement('div');
|
|
84
158
|
tmp.innerHTML = html;
|
|
85
|
-
// move element out of visible area
|
|
86
159
|
tmp.style.position = 'fixed';
|
|
87
160
|
tmp.style.left = '200%';
|
|
88
161
|
// element should be appended into the DOM otherwise element.innerText doesn't contain line breaks (\n) (at least at Chrome)
|
|
@@ -100,21 +173,21 @@ export class HtmlHelper {
|
|
|
100
173
|
}
|
|
101
174
|
}
|
|
102
175
|
class HtmlSanitizer {
|
|
103
|
-
static
|
|
176
|
+
static _initialized = false;
|
|
104
177
|
static sanitize(html) {
|
|
105
|
-
HtmlSanitizer.
|
|
178
|
+
HtmlSanitizer._ensureInitialized();
|
|
106
179
|
return dp.sanitize(html, {
|
|
107
180
|
ADD_ATTR: ['target']
|
|
108
181
|
});
|
|
109
182
|
}
|
|
110
|
-
static
|
|
111
|
-
if (!HtmlSanitizer.
|
|
183
|
+
static _ensureInitialized() {
|
|
184
|
+
if (!HtmlSanitizer._initialized) {
|
|
112
185
|
dp.addHook('afterSanitizeAttributes', (node) => {
|
|
113
186
|
if (node.tagName === 'A' && node.hasAttribute('target')) {
|
|
114
187
|
node.setAttribute('rel', 'noopener noreferrer');
|
|
115
188
|
}
|
|
116
189
|
});
|
|
117
|
-
HtmlSanitizer.
|
|
190
|
+
HtmlSanitizer._initialized = true;
|
|
118
191
|
}
|
|
119
192
|
}
|
|
120
193
|
}
|
package/dist/styles/_mixins.scss
CHANGED
|
@@ -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
|
<!--
|
|
@@ -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
|
-
<
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
</
|
|
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 —
|
|
21
|
-
|
|
22
|
-
|
|
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 —
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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 {
|
|
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 (
|
|
19
|
-
|
|
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 (
|
|
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
|
-
<
|
|
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 —
|
|
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
|
-
/**
|
|
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 —
|
|
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 |
|
|
@@ -5,9 +5,12 @@ let mounted = $state(false);
|
|
|
5
5
|
onMount(() => {
|
|
6
6
|
mounted = true;
|
|
7
7
|
});
|
|
8
|
+
const sanitizedSvg = $derived(mounted ? HtmlHelper.sanitizeSvg(src) : null);
|
|
8
9
|
</script>
|
|
9
10
|
|
|
10
|
-
{#if
|
|
11
|
+
{#if mounted && sanitizedSvg === null}
|
|
12
|
+
<i class="invalid-svg">Invalid SVG</i>
|
|
13
|
+
{:else}
|
|
11
14
|
<div
|
|
12
15
|
class="icon"
|
|
13
16
|
class:icon--accent={color === 'accent'}
|
|
@@ -26,12 +29,10 @@ onMount(() => {
|
|
|
26
29
|
class:icon--control-md={size === 'control-md'}
|
|
27
30
|
class:icon--control-lg={size === 'control-lg'}
|
|
28
31
|
class:icon--control-xl={size === 'control-xl'}>
|
|
29
|
-
{#if
|
|
30
|
-
{@html
|
|
32
|
+
{#if sanitizedSvg}
|
|
33
|
+
{@html sanitizedSvg}
|
|
31
34
|
{/if}
|
|
32
35
|
</div>
|
|
33
|
-
{:else}
|
|
34
|
-
<i class="invalid-svg">Invalid SVG</i>
|
|
35
36
|
{/if}
|
|
36
37
|
|
|
37
38
|
<!--
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IconColor, IconSize } from './types';
|
|
2
2
|
interface Props {
|
|
3
|
-
/**
|
|
3
|
+
/** SVG markup, a well-formed `image/svg+xml` document (an XML declaration and DOCTYPE are allowed) */
|
|
4
4
|
src: string;
|
|
5
5
|
/** Predefined color preset applied to the icon */
|
|
6
6
|
color?: IconColor | null;
|
|
@@ -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>
|