@juspay/svelte-ui-components 4.7.0 → 4.9.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.
- package/dist/Card/Card.svelte +21 -3
- package/dist/Card/properties.d.ts +32 -0
- package/dist/ChatComposer/ChatComposer.svelte +48 -7
- package/dist/ChatComposer/controlDisabled.d.ts +10 -0
- package/dist/ChatComposer/controlDisabled.js +12 -0
- package/dist/ChatComposer/properties.d.ts +23 -1
- package/dist/ChatComposer/submitResult.d.ts +34 -0
- package/dist/ChatComposer/submitResult.js +20 -0
- package/dist/Combobox/Combobox.svelte.d.ts +1 -1
- package/dist/MarkdownText/markdown.js +24 -3
- package/dist/Modal/Modal.svelte +121 -6
- package/dist/Modal/focus-trap.d.ts +42 -0
- package/dist/Modal/focus-trap.js +57 -0
- package/dist/Modal/properties.d.ts +44 -0
- package/dist/Select/Select.svelte.d.ts +1 -1
- package/dist/Sheet/Sheet.svelte +38 -1
- package/dist/Sheet/properties.d.ts +9 -1
- package/dist/Snippet/Snippet.svelte +16 -3
- package/dist/Snippet/copyResetTimer.d.ts +21 -0
- package/dist/Snippet/copyResetTimer.js +20 -0
- package/dist/Snippet/properties.d.ts +4 -0
- package/dist/Toolbar/Toolbar.svelte +24 -4
- package/dist/Toolbar/properties.d.ts +11 -0
- package/dist-wc/index.js +1985 -1666
- package/package.json +1 -1
package/dist/Card/Card.svelte
CHANGED
|
@@ -17,10 +17,27 @@
|
|
|
17
17
|
footer,
|
|
18
18
|
stretch = false,
|
|
19
19
|
scrollable = false,
|
|
20
|
-
cssVars
|
|
20
|
+
cssVars,
|
|
21
|
+
attrs,
|
|
22
|
+
as
|
|
21
23
|
}: CardProperties = $props();
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
// `as`, when given, picks the rendered tag outright. Omitted, the root keeps
|
|
26
|
+
// its existing rule: an `<a>` exactly when `href` is set, a `<div>`
|
|
27
|
+
// otherwise -- so a consumer who never passes `as` gets the identical tag
|
|
28
|
+
// resolution as before this prop existed.
|
|
29
|
+
const rootTag = $derived(as ?? (typeof href === 'string' && href.length > 0 ? 'a' : 'div'));
|
|
30
|
+
// Anchor-only attributes/behaviour (href/target/rel, and skipping the
|
|
31
|
+
// synthetic role/tabindex/keydown shim) key off the *resolved* tag rather
|
|
32
|
+
// than `href` alone, so `as="figure"` on an href-carrying Card renders a
|
|
33
|
+
// real `<figure>` with no dangling `href` attribute instead of a
|
|
34
|
+
// contradictory figure-that-navigates. It also requires `href` to actually
|
|
35
|
+
// be set: `as="a"` with an `onclick` but no `href` renders a bare `<a>`
|
|
36
|
+
// with nothing to navigate to, which is not natively focusable or
|
|
37
|
+
// keyboard-activatable, so treating it as an anchor here would silently
|
|
38
|
+
// drop the interactive-div keyboard shim (role/tabindex/onkeydown) with no
|
|
39
|
+
// native behaviour to replace it.
|
|
40
|
+
const isAnchor = $derived(rootTag === 'a' && typeof href === 'string' && href.length > 0);
|
|
24
41
|
const isInteractive = $derived(isAnchor || typeof onclick === 'function');
|
|
25
42
|
// HTML target values are ASCII case-insensitive (e.g. "_BLANK" opens a new
|
|
26
43
|
// context identically to "_blank"), so the comparison must normalize case
|
|
@@ -50,7 +67,8 @@
|
|
|
50
67
|
</script>
|
|
51
68
|
|
|
52
69
|
<svelte:element
|
|
53
|
-
this={
|
|
70
|
+
this={rootTag}
|
|
71
|
+
{...attrs ?? {}}
|
|
54
72
|
class="card {classes ?? ''}"
|
|
55
73
|
class:card-interactive={isInteractive}
|
|
56
74
|
class:card-stretch={stretch}
|
|
@@ -82,4 +82,36 @@ export type OptionalCardProperties = {
|
|
|
82
82
|
* rendered, so existing consumers are unchanged.
|
|
83
83
|
*/
|
|
84
84
|
cssVars?: Record<string, string | number>;
|
|
85
|
+
/**
|
|
86
|
+
* Arbitrary attributes spread onto the card root — `data-*`/`aria-*` (or any
|
|
87
|
+
* other) attribute names mapped to string values. For consumers that key
|
|
88
|
+
* off attribute-selector CSS (`[data-state="waiting"]`, `[data-density]`,
|
|
89
|
+
* `[data-offline]`) the way the rest of their app does, instead of adding a
|
|
90
|
+
* second `classes` modifier for the same state. Applied before Card's own
|
|
91
|
+
* `class`/`style`/`data-pw`/`testID`/`role`/`tabindex`/`href`/`target`/`rel`/
|
|
92
|
+
* `onclick`/`onkeydown` attributes, so an entry here can never override one
|
|
93
|
+
* of those — only add attributes Card does not already manage. Omitted by
|
|
94
|
+
* default, so existing consumers render exactly the same attributes as
|
|
95
|
+
* before.
|
|
96
|
+
*/
|
|
97
|
+
attrs?: Record<string, string>;
|
|
98
|
+
/**
|
|
99
|
+
* Overrides the tag `svelte:element` renders for the card root — e.g.
|
|
100
|
+
* `'figure'` for a card that needs real `<figure>` semantics (so a
|
|
101
|
+
* `<figcaption>` inside `children` or `footer` is valid markup, and the
|
|
102
|
+
* element carries sectioning-content semantics no wrapper `<figure>` around
|
|
103
|
+
* a `<div>`-rooted Card could give it).
|
|
104
|
+
*
|
|
105
|
+
* When omitted, the root keeps its existing behaviour exactly: `'a'` when
|
|
106
|
+
* `href` is set, `'div'` otherwise. Setting `as` to anything other than
|
|
107
|
+
* `'a'` renders that tag instead and suppresses the anchor
|
|
108
|
+
* attributes/behaviour — `href`/`target`/`rel` are not rendered on it, and
|
|
109
|
+
* the synthetic `role="button"`/`tabindex`/keydown shim applies instead
|
|
110
|
+
* when `onclick` is provided, the same as a plain interactive `<div>`.
|
|
111
|
+
* Setting `as="a"` renders an `<a>` with `href`/`target`/`rel` when `href`
|
|
112
|
+
* is also set; with `as="a"` but no `href` there is nothing to navigate
|
|
113
|
+
* to, so the element gets the same interactive-div keyboard shim an
|
|
114
|
+
* `onclick`-only `<div>` gets instead of anchor semantics it can't have.
|
|
115
|
+
*/
|
|
116
|
+
as?: 'div' | 'a' | 'figure';
|
|
85
117
|
};
|
|
@@ -8,11 +8,16 @@
|
|
|
8
8
|
import attachSvg from '../assets/attach.svg?raw';
|
|
9
9
|
import type { Action } from 'svelte/action';
|
|
10
10
|
import type { ChatComposerProperties } from './properties';
|
|
11
|
+
import { resolveControlDisabled } from './controlDisabled';
|
|
12
|
+
import { shouldApplyClear } from './submitResult';
|
|
11
13
|
|
|
12
14
|
let {
|
|
13
15
|
value = $bindable(''),
|
|
14
16
|
placeholder = '',
|
|
15
17
|
disabled = false,
|
|
18
|
+
textDisabled = null,
|
|
19
|
+
voiceDisabled = null,
|
|
20
|
+
sendDisabled = null,
|
|
16
21
|
submitOnEnter = true,
|
|
17
22
|
maxLength = 0,
|
|
18
23
|
streaming = false,
|
|
@@ -75,8 +80,15 @@
|
|
|
75
80
|
richImages.length > 0 || richVideos.length > 0 || richFiles.length > 0
|
|
76
81
|
);
|
|
77
82
|
|
|
83
|
+
// #526: each falls back to the shared `disabled` when its own flag is
|
|
84
|
+
// unset, so a caller who never passes the new props keeps the original
|
|
85
|
+
// single-`disabled`-gates-everything behaviour exactly.
|
|
86
|
+
let resolvedTextDisabled = $derived(resolveControlDisabled(textDisabled, disabled));
|
|
87
|
+
let resolvedVoiceDisabled = $derived(resolveControlDisabled(voiceDisabled, disabled));
|
|
88
|
+
let resolvedSendDisabled = $derived(resolveControlDisabled(sendDisabled, disabled));
|
|
89
|
+
|
|
78
90
|
let canSend = $derived(
|
|
79
|
-
!
|
|
91
|
+
!resolvedSendDisabled &&
|
|
80
92
|
(sendable ?? (value.trim().length > 0 || attachments.length > 0 || hasRichAttachments))
|
|
81
93
|
);
|
|
82
94
|
|
|
@@ -84,9 +96,28 @@
|
|
|
84
96
|
if (!canSend) {
|
|
85
97
|
return;
|
|
86
98
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
99
|
+
const submitted = { value, attachments };
|
|
100
|
+
const result = onsubmit?.(submitted.value, submitted.attachments);
|
|
101
|
+
if (result instanceof Promise) {
|
|
102
|
+
// Nothing here disables the composer while the promise settles — the
|
|
103
|
+
// user is free to keep typing, so the clear only lands if `value` and
|
|
104
|
+
// `attachments` still match what was actually sent (see shouldApplyClear).
|
|
105
|
+
// A rejection is treated the same as a resolved `false`: keep the draft.
|
|
106
|
+
void result.then(
|
|
107
|
+
(resolved) => {
|
|
108
|
+
if (shouldApplyClear(resolved, submitted, { value, attachments })) {
|
|
109
|
+
value = '';
|
|
110
|
+
attachments = [];
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
() => {}
|
|
114
|
+
);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (shouldApplyClear(result, submitted, { value, attachments })) {
|
|
118
|
+
value = '';
|
|
119
|
+
attachments = [];
|
|
120
|
+
}
|
|
90
121
|
}
|
|
91
122
|
|
|
92
123
|
function handleKeydown(event: KeyboardEvent): void {
|
|
@@ -208,7 +239,7 @@
|
|
|
208
239
|
data-pw={inputTestId ?? null}
|
|
209
240
|
bind:value
|
|
210
241
|
{placeholder}
|
|
211
|
-
{
|
|
242
|
+
disabled={resolvedTextDisabled}
|
|
212
243
|
rows="1"
|
|
213
244
|
aria-label={inputAriaLabel ?? (placeholder.length > 0 ? placeholder : 'Message')}
|
|
214
245
|
maxlength={maxLength > 0 ? maxLength : null}
|
|
@@ -220,7 +251,12 @@
|
|
|
220
251
|
|
|
221
252
|
{#if showVoice}
|
|
222
253
|
<div class="control voice" class:recording>
|
|
223
|
-
<Button
|
|
254
|
+
<Button
|
|
255
|
+
onclick={() => onvoice?.()}
|
|
256
|
+
disabled={resolvedVoiceDisabled}
|
|
257
|
+
ariaLabel={voiceLabel}
|
|
258
|
+
testId={voiceTestId}
|
|
259
|
+
>
|
|
224
260
|
{#if typeof voiceIcon === 'function'}
|
|
225
261
|
{@render voiceIcon()}
|
|
226
262
|
{:else}
|
|
@@ -244,7 +280,12 @@
|
|
|
244
280
|
</div>
|
|
245
281
|
{:else if typeof onaction === 'function' && !canSend && !recording}
|
|
246
282
|
<div class="control send action" data-pw={sendSlotTestId ?? null}>
|
|
247
|
-
<Button
|
|
283
|
+
<Button
|
|
284
|
+
onclick={() => onaction()}
|
|
285
|
+
disabled={resolvedSendDisabled}
|
|
286
|
+
ariaLabel={actionLabel}
|
|
287
|
+
testId={actionTestId}
|
|
288
|
+
>
|
|
248
289
|
{#if typeof actionIcon === 'function'}
|
|
249
290
|
{@render actionIcon()}
|
|
250
291
|
{:else}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #526: `disabled` used to gate the textarea, the voice button and the send
|
|
3
|
+
* button together — a caller mid-send with dictation running had no way to
|
|
4
|
+
* freeze the send button without also freezing the mic. `textDisabled`,
|
|
5
|
+
* `voiceDisabled` and `sendDisabled` let a caller override one control at a
|
|
6
|
+
* time; leaving a specific flag at its `null` default falls back to the
|
|
7
|
+
* shared `disabled`, so the single-prop behaviour is exactly what a caller
|
|
8
|
+
* who never passes the new props keeps getting.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveControlDisabled(specific: boolean | null, disabled: boolean): boolean;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #526: `disabled` used to gate the textarea, the voice button and the send
|
|
3
|
+
* button together — a caller mid-send with dictation running had no way to
|
|
4
|
+
* freeze the send button without also freezing the mic. `textDisabled`,
|
|
5
|
+
* `voiceDisabled` and `sendDisabled` let a caller override one control at a
|
|
6
|
+
* time; leaving a specific flag at its `null` default falls back to the
|
|
7
|
+
* shared `disabled`, so the single-prop behaviour is exactly what a caller
|
|
8
|
+
* who never passes the new props keeps getting.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveControlDisabled(specific, disabled) {
|
|
11
|
+
return specific ?? disabled;
|
|
12
|
+
}
|
|
@@ -5,6 +5,22 @@ export type OptionalChatComposerProperties = {
|
|
|
5
5
|
value?: string;
|
|
6
6
|
placeholder?: string;
|
|
7
7
|
disabled?: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Disables only the textarea. `null` (the default) falls back to `disabled`,
|
|
10
|
+
* so a caller that never sets this keeps today's single-`disabled` behaviour
|
|
11
|
+
* exactly.
|
|
12
|
+
*/
|
|
13
|
+
textDisabled?: boolean | null;
|
|
14
|
+
/**
|
|
15
|
+
* Disables only the voice button. `null` (the default) falls back to
|
|
16
|
+
* `disabled` — see `textDisabled`.
|
|
17
|
+
*/
|
|
18
|
+
voiceDisabled?: boolean | null;
|
|
19
|
+
/**
|
|
20
|
+
* Disables only the send (and idle-action) button, independent of `sendable`.
|
|
21
|
+
* `null` (the default) falls back to `disabled` — see `textDisabled`.
|
|
22
|
+
*/
|
|
23
|
+
sendDisabled?: boolean | null;
|
|
8
24
|
submitOnEnter?: boolean;
|
|
9
25
|
maxLength?: number;
|
|
10
26
|
streaming?: boolean;
|
|
@@ -72,7 +88,13 @@ export type OptionalChatComposerProperties = {
|
|
|
72
88
|
classes?: string;
|
|
73
89
|
};
|
|
74
90
|
export type ChatComposerEventProperties = {
|
|
75
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Fires on submit with the value and pending attachments. May return
|
|
93
|
+
* `boolean | Promise<boolean>`: an explicit `false` (sync or resolved) skips
|
|
94
|
+
* the automatic clear so a rejected send can restore its draft — anything
|
|
95
|
+
* else, including today's `void` return, clears exactly as before.
|
|
96
|
+
*/
|
|
97
|
+
onsubmit?: (value: string, attachments: File[]) => boolean | void | Promise<boolean | void>;
|
|
76
98
|
oninput?: (value: string, event: Event) => void;
|
|
77
99
|
onkeydown?: (event: KeyboardEvent) => void;
|
|
78
100
|
onpaste?: (event: ClipboardEvent) => void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #526: `onsubmit` may now return `boolean | Promise<boolean>` instead of only
|
|
3
|
+
* `void`, so a caller can keep the composer's text (and attachments) around
|
|
4
|
+
* when a send fails rather than losing the draft to the unconditional clear.
|
|
5
|
+
*
|
|
6
|
+
* The rule is deliberately narrow: only an explicit `false` — synchronous or
|
|
7
|
+
* resolved — skips the clear. Every other synchronous or resolved outcome,
|
|
8
|
+
* `void` (today's `void`-returning handlers) included, clears exactly as
|
|
9
|
+
* before, keeping the default byte-for-byte: a handler written against the
|
|
10
|
+
* pre-#526 `(value, attachments) => void` shape returns nothing and never
|
|
11
|
+
* opts in to anything, so it keeps clearing on every submit, same as always.
|
|
12
|
+
* A rejected promise is the one outcome this module never sees — the caller
|
|
13
|
+
* (`ChatComposer.svelte`) catches it before either function here runs, and
|
|
14
|
+
* treats it the same as a resolved `false`: the draft is kept, not cleared.
|
|
15
|
+
*/
|
|
16
|
+
export type ChatComposerSubmitResult = boolean | void;
|
|
17
|
+
export declare function shouldClearAfterSubmit(result: ChatComposerSubmitResult): boolean;
|
|
18
|
+
export type ChatComposerDraft = {
|
|
19
|
+
value: string;
|
|
20
|
+
attachments: readonly File[];
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Whether the composer should actually apply the clear once an (async)
|
|
24
|
+
* `onsubmit` settles. Two things have to hold, not just `shouldClearAfterSubmit`:
|
|
25
|
+
* the value and the attachments the user is looking at right now must still be
|
|
26
|
+
* the exact ones that were sent. Between `submit()` firing and a `Promise`
|
|
27
|
+
* resolving, the user is free to keep typing — the composer never disables
|
|
28
|
+
* itself just because a submit is in flight — so a clear that only checked the
|
|
29
|
+
* resolved boolean would wipe out whatever they typed in that window. Compared
|
|
30
|
+
* by reference for attachments: every mutation (`handleFiles`, `removeAttachment`)
|
|
31
|
+
* reassigns the array, so an unchanged reference means nothing happened while the
|
|
32
|
+
* promise was pending.
|
|
33
|
+
*/
|
|
34
|
+
export declare function shouldApplyClear(result: ChatComposerSubmitResult, submitted: ChatComposerDraft, current: ChatComposerDraft): boolean;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function shouldClearAfterSubmit(result) {
|
|
2
|
+
return result !== false;
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Whether the composer should actually apply the clear once an (async)
|
|
6
|
+
* `onsubmit` settles. Two things have to hold, not just `shouldClearAfterSubmit`:
|
|
7
|
+
* the value and the attachments the user is looking at right now must still be
|
|
8
|
+
* the exact ones that were sent. Between `submit()` firing and a `Promise`
|
|
9
|
+
* resolving, the user is free to keep typing — the composer never disables
|
|
10
|
+
* itself just because a submit is in flight — so a clear that only checked the
|
|
11
|
+
* resolved boolean would wipe out whatever they typed in that window. Compared
|
|
12
|
+
* by reference for attachments: every mutation (`handleFiles`, `removeAttachment`)
|
|
13
|
+
* reassigns the array, so an unchanged reference means nothing happened while the
|
|
14
|
+
* promise was pending.
|
|
15
|
+
*/
|
|
16
|
+
export function shouldApplyClear(result, submitted, current) {
|
|
17
|
+
return (shouldClearAfterSubmit(result) &&
|
|
18
|
+
current.value === submitted.value &&
|
|
19
|
+
current.attachments === submitted.attachments);
|
|
20
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ComboboxProperties } from './properties';
|
|
2
2
|
declare const Combobox: import("svelte").Component<ComboboxProperties, {
|
|
3
3
|
getInputRef: () => HTMLInputElement | HTMLTextAreaElement | null;
|
|
4
|
-
}, "
|
|
4
|
+
}, "open" | "value" | "selected" | "inputValue" | "highlightedIndex">;
|
|
5
5
|
type Combobox = ReturnType<typeof Combobox>;
|
|
6
6
|
export default Combobox;
|
|
@@ -62,12 +62,20 @@ function escapeHtml(value) {
|
|
|
62
62
|
* instead of a hole. Casing is normalised so `'HTTPS:'` narrows the same as
|
|
63
63
|
* `'https:'`, matching how `hasSafeProtocol` reads `URL.protocol` (always
|
|
64
64
|
* lower-case).
|
|
65
|
+
*
|
|
66
|
+
* A plain-JS or web-component consumer can hand this anything at runtime --
|
|
67
|
+
* the type is only enforced at compile time for a Svelte caller. Anything
|
|
68
|
+
* other than an array (and any non-string element inside it) is ignored
|
|
69
|
+
* rather than crashing `.map`/`.toLowerCase`, degrading to the unrestricted
|
|
70
|
+
* default instead of throwing an uncaught TypeError.
|
|
65
71
|
*/
|
|
66
72
|
function narrow(defaults, allowedProtocols) {
|
|
67
|
-
if (!allowedProtocols) {
|
|
73
|
+
if (!Array.isArray(allowedProtocols)) {
|
|
68
74
|
return defaults;
|
|
69
75
|
}
|
|
70
|
-
const requested = new Set(allowedProtocols
|
|
76
|
+
const requested = new Set(allowedProtocols
|
|
77
|
+
.filter((protocol) => typeof protocol === 'string')
|
|
78
|
+
.map((protocol) => protocol.toLowerCase()));
|
|
71
79
|
return new Set([...defaults].filter((protocol) => requested.has(protocol)));
|
|
72
80
|
}
|
|
73
81
|
function resolveProtocolSets(sanitize) {
|
|
@@ -81,9 +89,22 @@ function resolveProtocolSets(sanitize) {
|
|
|
81
89
|
* output can produce renders normally) -- unlike the protocol allow-lists,
|
|
82
90
|
* there is no built-in default set to fall back to once this exists, so its
|
|
83
91
|
* absence is what keeps old behaviour byte-for-byte.
|
|
92
|
+
*
|
|
93
|
+
* Names are normalised to lower-case, matching the normalisation
|
|
94
|
+
* `allowedProtocols` already gets -- every name `tagAllowed` is called with
|
|
95
|
+
* (`'a'`, `'img'`, `'h1'`, ...) is lower-case, so an unnormalised `['STRONG']`
|
|
96
|
+
* would otherwise never match and silently drop the tag it meant to keep.
|
|
97
|
+
*
|
|
98
|
+
* As with `narrow`, a plain-JS or web-component consumer can hand this
|
|
99
|
+
* anything at runtime, so anything other than an array (and any non-string
|
|
100
|
+
* element inside it) is ignored rather than crashing `.map`/`.toLowerCase`,
|
|
101
|
+
* degrading to "no restriction" instead of throwing an uncaught TypeError.
|
|
84
102
|
*/
|
|
85
103
|
function resolveTagAllowList(allowedTags) {
|
|
86
|
-
|
|
104
|
+
if (!Array.isArray(allowedTags)) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return new Set(allowedTags.filter((tag) => typeof tag === 'string').map((tag) => tag.toLowerCase()));
|
|
87
108
|
}
|
|
88
109
|
function tagAllowed(allowed, tag) {
|
|
89
110
|
return allowed === null || allowed.has(tag);
|
package/dist/Modal/Modal.svelte
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { ModalProperties } from './properties';
|
|
3
|
-
import { onMount, onDestroy } from 'svelte';
|
|
3
|
+
import { onMount, onDestroy, tick } from 'svelte';
|
|
4
4
|
import ModalAnimation from '../Animations/ModalAnimation.svelte';
|
|
5
5
|
import OverlayAnimation from '../Animations/OverlayAnimation.svelte';
|
|
6
6
|
import { createDebouncer, lockBodyScroll, unlockBodyScroll } from '../utils';
|
|
7
|
+
import { focusEntryPoint, focusTrapTabTarget } from './focus-trap';
|
|
7
8
|
import Button from '../Button/Button.svelte';
|
|
8
9
|
import Img from '../Img/Img.svelte';
|
|
9
10
|
|
|
10
11
|
let overlayDiv: HTMLDivElement | null = $state(null);
|
|
12
|
+
let modalContent: HTMLDivElement | null = $state(null);
|
|
11
13
|
let backPressed = $state(false);
|
|
14
|
+
// Captured in onMount right before focus moves in, restored in onDestroy.
|
|
15
|
+
// Not $state: it's read-once bookkeeping for the mount/destroy pair, not
|
|
16
|
+
// something the template reacts to.
|
|
17
|
+
let previouslyFocusedElement: HTMLElement | null = null;
|
|
12
18
|
|
|
13
19
|
let {
|
|
14
20
|
size = 'fit-content',
|
|
@@ -33,14 +39,28 @@
|
|
|
33
39
|
onsecondarybuttonclick,
|
|
34
40
|
onoverlayclick,
|
|
35
41
|
onkeydown,
|
|
42
|
+
ondismiss,
|
|
36
43
|
classes,
|
|
37
44
|
overlayBackdropFilter,
|
|
38
45
|
overlayFadeIn = false,
|
|
39
46
|
usePortal = false,
|
|
40
47
|
lockScroll = true,
|
|
41
|
-
autoDismissAfter = null
|
|
48
|
+
autoDismissAfter = null,
|
|
49
|
+
ariaLabel,
|
|
50
|
+
role,
|
|
51
|
+
overlayAriaLabel
|
|
42
52
|
}: ModalProperties = $props();
|
|
43
53
|
|
|
54
|
+
// The overlay only takes role="button" and becomes keyboard-reachable when a
|
|
55
|
+
// click on it would actually do something. Without either handler, clicking
|
|
56
|
+
// or activating it is already a no-op (see handleOverlayClick's guard) --
|
|
57
|
+
// announcing it as a focusable "button" in that case is worse for assistive
|
|
58
|
+
// tech than leaving it unannounced, same reasoning already applied to the
|
|
59
|
+
// header images below.
|
|
60
|
+
const overlayDismissible = $derived(
|
|
61
|
+
typeof onoverlayclick === 'function' || typeof ondismiss === 'function'
|
|
62
|
+
);
|
|
63
|
+
|
|
44
64
|
let dismissTimer: ReturnType<typeof setTimeout> | null = null;
|
|
45
65
|
|
|
46
66
|
// Fix [major]: plain const so the debouncer closure retains its internal lastCallTime state
|
|
@@ -67,9 +87,27 @@
|
|
|
67
87
|
};
|
|
68
88
|
};
|
|
69
89
|
|
|
90
|
+
// Resolves the real focused element regardless of whether modalContent sits
|
|
91
|
+
// in the light DOM or inside Modal.wc.svelte's open shadow root.
|
|
92
|
+
// document.activeElement stops at a shadow boundary and returns the shadow
|
|
93
|
+
// host itself, never the element actually focused inside it -- getRootNode()
|
|
94
|
+
// returns that shadow root directly when modalContent is shadow-hosted, and
|
|
95
|
+
// the owner Document otherwise.
|
|
96
|
+
const getActiveElement = (): Element | null => {
|
|
97
|
+
if (modalContent === null) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const root = modalContent.getRootNode();
|
|
101
|
+
if (root instanceof Document || root instanceof ShadowRoot) {
|
|
102
|
+
return root.activeElement;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
};
|
|
106
|
+
|
|
70
107
|
const handlePopstate = (): void => {
|
|
71
108
|
backPressed = true;
|
|
72
109
|
onclose?.();
|
|
110
|
+
ondismiss?.();
|
|
73
111
|
};
|
|
74
112
|
|
|
75
113
|
const handleRightImageClick = (event: MouseEvent): void => {
|
|
@@ -92,6 +130,7 @@
|
|
|
92
130
|
if (event.target === overlayDiv) {
|
|
93
131
|
debounce(() => {
|
|
94
132
|
onoverlayclick?.();
|
|
133
|
+
ondismiss?.();
|
|
95
134
|
});
|
|
96
135
|
}
|
|
97
136
|
};
|
|
@@ -101,6 +140,49 @@
|
|
|
101
140
|
const key = event?.key;
|
|
102
141
|
if (key === 'Escape') {
|
|
103
142
|
onoverlayclick?.();
|
|
143
|
+
ondismiss?.();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (key === 'Tab') {
|
|
147
|
+
const target = focusTrapTabTarget({
|
|
148
|
+
container: modalContent,
|
|
149
|
+
activeElement: getActiveElement(),
|
|
150
|
+
shiftKey: event.shiftKey
|
|
151
|
+
});
|
|
152
|
+
if (target !== null) {
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
target.focus();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// The overlay's own Enter/Space activation, additional to the shared
|
|
160
|
+
// handleKeyDown (Escape/Tab/onkeydown-prop) that svelte:window already
|
|
161
|
+
// delivers here via bubbling -- this handler must not also call
|
|
162
|
+
// handleKeyDown itself, or every keydown on/under the overlay would run it
|
|
163
|
+
// twice (once from this direct call, once from the window listener seeing
|
|
164
|
+
// the same event bubble past it), double-firing onkeydown and, on Escape,
|
|
165
|
+
// the dismiss callbacks. Enter/Space activation is scoped to this handler,
|
|
166
|
+
// not folded into handleKeyDown itself, because handleKeyDown also runs
|
|
167
|
+
// from the window listener for a keypress anywhere in the modal (e.g. an
|
|
168
|
+
// input field) -- it must not treat every Enter/Space in the modal as an
|
|
169
|
+
// overlay dismissal. This handler is bound to the overlay div, so a keydown
|
|
170
|
+
// on any focusable descendant (the back button, a footer button, an input
|
|
171
|
+
// field) bubbles up and reaches it too -- the target check below is what
|
|
172
|
+
// actually stops that bubbled Enter/Space from being read as an activation
|
|
173
|
+
// of the overlay itself, mirroring the guard handleOverlayClick already has
|
|
174
|
+
// for the click case.
|
|
175
|
+
const handleOverlayKeyDown = (event: KeyboardEvent): void => {
|
|
176
|
+
if (
|
|
177
|
+
(event.key === 'Enter' || event.key === ' ') &&
|
|
178
|
+
overlayDismissible &&
|
|
179
|
+
event.target === overlayDiv
|
|
180
|
+
) {
|
|
181
|
+
event.preventDefault();
|
|
182
|
+
debounce(() => {
|
|
183
|
+
onoverlayclick?.();
|
|
184
|
+
ondismiss?.();
|
|
185
|
+
});
|
|
104
186
|
}
|
|
105
187
|
};
|
|
106
188
|
|
|
@@ -131,6 +213,18 @@
|
|
|
131
213
|
history.pushState(null, '', window.location.href);
|
|
132
214
|
window.addEventListener('popstate', handlePopstate);
|
|
133
215
|
}
|
|
216
|
+
// Focus starts inside the dialog once it exists, same as Sheet's trap --
|
|
217
|
+
// otherwise Tab from wherever the trigger was leaves focus behind it,
|
|
218
|
+
// outside the trap that was just installed.
|
|
219
|
+
void tick().then(() => {
|
|
220
|
+
// Capture whatever had focus (the trigger, typically) right before
|
|
221
|
+
// moving focus in, so onDestroy can put it back. Shadow-aware for the
|
|
222
|
+
// same reason the Tab trap is: inside Modal.wc.svelte's open shadow
|
|
223
|
+
// root, document.activeElement would return the shadow host instead.
|
|
224
|
+
const active = getActiveElement();
|
|
225
|
+
previouslyFocusedElement = active instanceof HTMLElement ? active : null;
|
|
226
|
+
focusEntryPoint(modalContent);
|
|
227
|
+
});
|
|
134
228
|
});
|
|
135
229
|
|
|
136
230
|
onDestroy(() => {
|
|
@@ -148,6 +242,13 @@
|
|
|
148
242
|
window.removeEventListener('popstate', handlePopstate);
|
|
149
243
|
}
|
|
150
244
|
}
|
|
245
|
+
// Restore focus to wherever it was before the modal took it. Guarded on
|
|
246
|
+
// isConnected: the trigger can have been unmounted while the modal was
|
|
247
|
+
// open (e.g. the page navigated), and .focus() on a detached element is
|
|
248
|
+
// a silent no-op at best.
|
|
249
|
+
if (previouslyFocusedElement !== null && previouslyFocusedElement.isConnected) {
|
|
250
|
+
previouslyFocusedElement.focus();
|
|
251
|
+
}
|
|
151
252
|
});
|
|
152
253
|
</script>
|
|
153
254
|
|
|
@@ -155,6 +256,7 @@
|
|
|
155
256
|
|
|
156
257
|
{#if typeof content === 'function'}
|
|
157
258
|
<OverlayAnimation fadeIn={overlayFadeIn}>
|
|
259
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
158
260
|
<div
|
|
159
261
|
bind:this={overlayDiv}
|
|
160
262
|
use:portalAction={{ usePortal }}
|
|
@@ -163,14 +265,22 @@
|
|
|
163
265
|
? `--modal-overlay-backdrop-filter: ${overlayBackdropFilter};`
|
|
164
266
|
: null}
|
|
165
267
|
onclick={handleOverlayClick}
|
|
166
|
-
onkeydown={
|
|
167
|
-
role=
|
|
168
|
-
tabindex=
|
|
268
|
+
onkeydown={handleOverlayKeyDown}
|
|
269
|
+
role={overlayDismissible ? 'button' : null}
|
|
270
|
+
tabindex={overlayDismissible ? 0 : null}
|
|
271
|
+
aria-label={overlayAriaLabel ?? null}
|
|
169
272
|
data-pw={testId}
|
|
170
273
|
testID={testId}
|
|
171
274
|
>
|
|
172
275
|
<ModalAnimation enable={enableTransition} {align} {transitionType} {entryAnimation}>
|
|
173
|
-
<div
|
|
276
|
+
<div
|
|
277
|
+
bind:this={modalContent}
|
|
278
|
+
class="modal-content {size}"
|
|
279
|
+
role={role ?? null}
|
|
280
|
+
aria-modal={role != null ? 'true' : null}
|
|
281
|
+
aria-label={ariaLabel ?? null}
|
|
282
|
+
tabindex="-1"
|
|
283
|
+
>
|
|
174
284
|
{#if (typeof header?.leftImage === 'string' && header.leftImage.length > 0) || (typeof header?.text === 'string' && header.text.length > 0) || (typeof header?.rightImage === 'string' && header.rightImage.length > 0)}
|
|
175
285
|
<div class="header">
|
|
176
286
|
{#if typeof header.leftImage === 'string' && header.leftImage.length > 0}
|
|
@@ -300,6 +410,11 @@
|
|
|
300
410
|
border-radius: var(--modal-border-radius, var(--radius, 4px));
|
|
301
411
|
overflow: var(--modal-content-overflow, auto);
|
|
302
412
|
border-top: var(--modal-content-border-top);
|
|
413
|
+
/* tabindex="-1" makes this the focus-trap's fallback target when the modal
|
|
414
|
+
has no focusable content of its own (see focus-trap.ts); it is never in
|
|
415
|
+
the Tab order itself, so it needs no visible focus ring the way a real
|
|
416
|
+
focusable element would. Matches Sheet's sheet-panel. */
|
|
417
|
+
outline: none;
|
|
303
418
|
/* Viewport containment for every size class: only .fit-content used to carry
|
|
304
419
|
a max-height, so a size whose height var is overridden to fit-content (or
|
|
305
420
|
anything taller than the screen) grew past the viewport and pushed its
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focus-trap utility for Modal (#529). Pure DOM-reading functions, no Svelte
|
|
3
|
+
* runtime dependency, so they can be unit tested without mounting the
|
|
4
|
+
* component -- mirrors the split Tooltip already uses for its action logic
|
|
5
|
+
* (see tooltip-action.test.ts).
|
|
6
|
+
*
|
|
7
|
+
* Modal.svelte owns the event wiring (which key was pressed, calling
|
|
8
|
+
* .focus()); this module owns the two decisions that wiring needs answered:
|
|
9
|
+
* "what are the edges of the tab order in here" and "given where focus is and
|
|
10
|
+
* which way Tab was pressed, should it wrap, and to where".
|
|
11
|
+
*/
|
|
12
|
+
export type FocusTrapBoundary = {
|
|
13
|
+
readonly first: HTMLElement | null;
|
|
14
|
+
readonly last: HTMLElement | null;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* The first and last focusable descendants of `container`, in DOM order.
|
|
18
|
+
* Both are null when the container is null or holds nothing focusable.
|
|
19
|
+
*/
|
|
20
|
+
export declare function getFocusTrapBoundary(container: ParentNode | null): FocusTrapBoundary;
|
|
21
|
+
/**
|
|
22
|
+
* Given a Tab/Shift+Tab keydown inside a focus-trapped container, returns the
|
|
23
|
+
* element focus should move to, or null when the default browser behavior
|
|
24
|
+
* (move to the next/previous focusable element) should be left alone.
|
|
25
|
+
*
|
|
26
|
+
* Wrapping only happens exactly at an edge: focus sits on the last focusable
|
|
27
|
+
* element and Tab (not Shift+Tab) was pressed, or focus sits on the first and
|
|
28
|
+
* Shift+Tab was pressed. Anywhere else in the middle of the tab order, the
|
|
29
|
+
* browser's own handling is correct and this returns null.
|
|
30
|
+
*/
|
|
31
|
+
export declare function focusTrapTabTarget(params: {
|
|
32
|
+
readonly container: ParentNode | null;
|
|
33
|
+
readonly activeElement: Element | null;
|
|
34
|
+
readonly shiftKey: boolean;
|
|
35
|
+
}): HTMLElement | null;
|
|
36
|
+
/**
|
|
37
|
+
* Moves focus into `container` -- its first focusable descendant, or the
|
|
38
|
+
* container itself as a fallback for a modal with no focusable content (it
|
|
39
|
+
* must carry tabindex="-1" for that fallback to actually take focus). A no-op
|
|
40
|
+
* when the container is null.
|
|
41
|
+
*/
|
|
42
|
+
export declare function focusEntryPoint(container: HTMLElement | null): void;
|