@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.
@@ -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
- const isAnchor = $derived(typeof href === 'string' && href.length > 0);
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={isAnchor ? 'a' : 'div'}
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
- !disabled &&
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
- onsubmit?.(value, attachments);
88
- value = '';
89
- attachments = [];
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
- {disabled}
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 onclick={() => onvoice?.()} {disabled} ariaLabel={voiceLabel} testId={voiceTestId}>
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 onclick={() => onaction()} {disabled} ariaLabel={actionLabel} testId={actionTestId}>
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
- onsubmit?: (value: string, attachments: File[]) => void;
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
- }, "value" | "open" | "selected" | "inputValue" | "highlightedIndex">;
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.map((protocol) => protocol.toLowerCase()));
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
- return allowedTags ? new Set(allowedTags) : null;
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);
@@ -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={handleKeyDown}
167
- role="button"
168
- tabindex="0"
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 class="modal-content {size}">
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;