@juspay/svelte-ui-components 4.29.0 → 4.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +15 -14
  2. package/dist/Accordion/Accordion.svelte +18 -0
  3. package/dist/AnimatedNumber/AnimatedNumber.svelte +702 -0
  4. package/dist/AnimatedNumber/AnimatedNumber.svelte.d.ts +4 -0
  5. package/dist/AnimatedNumber/motion.d.ts +47 -0
  6. package/dist/AnimatedNumber/motion.js +190 -0
  7. package/dist/AnimatedNumber/properties.d.ts +42 -0
  8. package/dist/AnimatedNumber/properties.js +1 -0
  9. package/dist/Badge/Badge.svelte +20 -3
  10. package/dist/Badge/properties.d.ts +9 -0
  11. package/dist/Book/Book.svelte +9 -0
  12. package/dist/Calendar/Calendar.svelte +34 -2
  13. package/dist/Carousel/Carousel.svelte +17 -2
  14. package/dist/ChatBubble/ChatBubble.svelte +12 -0
  15. package/dist/ChatComposer/ChatComposer.svelte +9 -0
  16. package/dist/CommandMenu/CommandMenu.svelte +13 -0
  17. package/dist/DateRangePicker/DateRangePicker.svelte +77 -20
  18. package/dist/DeltaIndicator/DeltaIndicator.svelte +12 -1
  19. package/dist/DeltaIndicator/properties.d.ts +9 -0
  20. package/dist/Gallery/Gallery.svelte +16 -0
  21. package/dist/Gauge/Gauge.svelte +21 -2
  22. package/dist/Gauge/properties.d.ts +13 -0
  23. package/dist/Input/Input.svelte +34 -26
  24. package/dist/KeyboardInput/KeyboardInput.svelte +1 -1
  25. package/dist/ListItem/ListItem.svelte +27 -2
  26. package/dist/ListItem/label.d.ts +11 -0
  27. package/dist/ListItem/label.js +40 -0
  28. package/dist/ListItem/properties.d.ts +52 -0
  29. package/dist/MediaUpload/MediaUpload.svelte +12 -1
  30. package/dist/MediaUpload/properties.d.ts +9 -0
  31. package/dist/Menu/Menu.svelte +11 -2
  32. package/dist/Modal/Modal.svelte +14 -4
  33. package/dist/PieChart/PieChart.svelte +16 -1
  34. package/dist/PieChart/properties.d.ts +13 -0
  35. package/dist/Progress/Progress.svelte +21 -1
  36. package/dist/Progress/properties.d.ts +12 -0
  37. package/dist/ProportionBar/ProportionBar.svelte +7 -1
  38. package/dist/ProportionBar/ProportionBar.svelte.d.ts +0 -19
  39. package/dist/ProportionBar/properties.d.ts +10 -0
  40. package/dist/Select/Select.svelte +23 -8
  41. package/dist/SplitButton/SplitButton.svelte +4 -1
  42. package/dist/SplitInput/SplitInput.svelte +1 -1
  43. package/dist/StatCard/StatCard.svelte +22 -4
  44. package/dist/StatCard/properties.d.ts +12 -0
  45. package/dist/Table/BuiltinCell.svelte +19 -2
  46. package/dist/Table/cellData.js +3 -0
  47. package/dist/Table/properties.d.ts +15 -0
  48. package/dist/Tabs/Tabs.svelte +16 -0
  49. package/dist/index.d.ts +2 -0
  50. package/dist/index.js +1 -0
  51. package/dist/styles/theme-dark.css +27 -3
  52. package/dist-codemod/codemod/migration-assets/legacy-display.css +2 -1
  53. package/dist-codemod/codemod/migration-assets/legacy-palette.css +4 -1
  54. package/dist-codemod/codemod/migration-assets/wc-display.json +5 -0
  55. package/dist-wc/index.js +1334 -754
  56. package/package.json +9 -3
@@ -0,0 +1,4 @@
1
+ import type { AnimatedNumberProperties } from './properties';
2
+ declare const AnimatedNumber: import("svelte").Component<AnimatedNumberProperties, {}, "">;
3
+ type AnimatedNumber = ReturnType<typeof AnimatedNumber>;
4
+ export default AnimatedNumber;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The odometer's motion arithmetic, kept out of the component so it can be
3
+ * tested as plain functions -- jsdom has no `Element.animate`, no `CSS` global
4
+ * and no running transitions, so none of this is observable through a rendered
5
+ * component there.
6
+ *
7
+ * The mechanism, which is worth stating once because it is not the obvious one:
8
+ * a glyph's position is never itself animated. Each of the ten glyphs in a
9
+ * column resolves its own offset as a pure function of one scalar, and only that
10
+ * scalar moves. Animating each glyph's own `transform` instead interpolates the
11
+ * RESOLVED before and after values, so a glyph parked off-screen above whose
12
+ * offset wrapped to below is tweened straight THROUGH the visible window -- a
13
+ * full-size digit sliding across the number. Moving the scalar and recomputing
14
+ * the offsets every frame cannot produce that, because every glyph's position
15
+ * stays a continuous function of one moving value.
16
+ *
17
+ * The scalar is `--_animated-number-digit + --_animated-number-delta`. The digit
18
+ * jumps to its new value immediately and the delta animates from `-delta` to 0,
19
+ * so their sum travels from the old digit to the new one without anything
20
+ * needing to remember where it started.
21
+ */
22
+ /** Digits per column. Every column carries a full 0-9 wheel. */
23
+ export declare const WHEEL_LENGTH = 10;
24
+ /** Registered so it INTERPOLATES: an unregistered custom property animates discretely. */
25
+ export declare const DELTA_PROPERTY = "--_animated-number-delta";
26
+ /**
27
+ * How far this column's wheel turns, signed, in digits.
28
+ *
29
+ * `trend` is the direction the number as a whole moved, and passing it is the
30
+ * single thing that makes 99 -> 100 roll each 9 forward by one step. Judged per
31
+ * column instead, a 9 -> 0 column sees a difference of -9 and takes nine steps
32
+ * backwards while its neighbours step forwards. Zero means "no opinion", which
33
+ * falls back to each column's own direction.
34
+ */
35
+ export declare const wheelDelta: (digit: number, previous: number, trend: number) => number;
36
+ /**
37
+ * Which way the whole number moved: 1 up, -1 down, 0 unchanged.
38
+ *
39
+ * Numbers are compared as numbers. A pre-formatted string has no numeric value
40
+ * to compare, so its digit runs are compared instead -- longer wins, then
41
+ * lexicographically, which is exactly numeric order for two runs of digits and
42
+ * stays correct past `Number.MAX_SAFE_INTEGER`, where subtracting would not.
43
+ */
44
+ export declare const trendBetween: (previous: number | string, next: number | string, digitOf?: (character: string) => number) => number;
45
+ /** A CSS `<time>` in milliseconds. Anything unparseable reads as 0, i.e. no motion. */
46
+ export declare const durationMs: (value: string) => number;
47
+ export declare const canSpin: () => boolean;
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The odometer's motion arithmetic, kept out of the component so it can be
3
+ * tested as plain functions -- jsdom has no `Element.animate`, no `CSS` global
4
+ * and no running transitions, so none of this is observable through a rendered
5
+ * component there.
6
+ *
7
+ * The mechanism, which is worth stating once because it is not the obvious one:
8
+ * a glyph's position is never itself animated. Each of the ten glyphs in a
9
+ * column resolves its own offset as a pure function of one scalar, and only that
10
+ * scalar moves. Animating each glyph's own `transform` instead interpolates the
11
+ * RESOLVED before and after values, so a glyph parked off-screen above whose
12
+ * offset wrapped to below is tweened straight THROUGH the visible window -- a
13
+ * full-size digit sliding across the number. Moving the scalar and recomputing
14
+ * the offsets every frame cannot produce that, because every glyph's position
15
+ * stays a continuous function of one moving value.
16
+ *
17
+ * The scalar is `--_animated-number-digit + --_animated-number-delta`. The digit
18
+ * jumps to its new value immediately and the delta animates from `-delta` to 0,
19
+ * so their sum travels from the old digit to the new one without anything
20
+ * needing to remember where it started.
21
+ */
22
+ /** Digits per column. Every column carries a full 0-9 wheel. */
23
+ export const WHEEL_LENGTH = 10;
24
+ /** Registered so it INTERPOLATES: an unregistered custom property animates discretely. */
25
+ export const DELTA_PROPERTY = '--_animated-number-delta';
26
+ /**
27
+ * How far this column's wheel turns, signed, in digits.
28
+ *
29
+ * `trend` is the direction the number as a whole moved, and passing it is the
30
+ * single thing that makes 99 -> 100 roll each 9 forward by one step. Judged per
31
+ * column instead, a 9 -> 0 column sees a difference of -9 and takes nine steps
32
+ * backwards while its neighbours step forwards. Zero means "no opinion", which
33
+ * falls back to each column's own direction.
34
+ */
35
+ export const wheelDelta = (digit, previous, trend) => {
36
+ const difference = digit - previous;
37
+ const direction = trend === 0 ? Math.sign(difference) : trend;
38
+ if (direction < 0 && digit > previous) {
39
+ return digit - WHEEL_LENGTH - previous;
40
+ }
41
+ if (direction > 0 && digit < previous) {
42
+ return WHEEL_LENGTH - previous + digit;
43
+ }
44
+ return difference;
45
+ };
46
+ /**
47
+ * The digits of a string, rewritten as ASCII so two runs can be compared.
48
+ *
49
+ * `digitOf` is the caller's own glyph lookup, so a numbering system whose digits
50
+ * are neither ASCII nor contiguous still reduces to something comparable. The
51
+ * previous `replace(/\D/g, '')` was ASCII-only without the `u` flag, so every
52
+ * Arabic-Indic, Devanagari or Bengali numeral was stripped and BOTH runs came
53
+ * back empty -- the trend then read 0, and a 9 -> 0 column with no direction to
54
+ * follow rolls nine positions backwards. Rewriting to ASCII rather than keeping
55
+ * the original glyphs also keeps the comparison below honest for a
56
+ * non-contiguous system, where code-point order is not value order.
57
+ */
58
+ const digitRun = (text, digitOf) => {
59
+ let run = '';
60
+ for (const character of text) {
61
+ const digit = digitOf(character);
62
+ if (digit >= 0) {
63
+ run += String(digit);
64
+ }
65
+ }
66
+ return run;
67
+ };
68
+ /**
69
+ * Fallback lookup for a caller with no numbering system of its own to offer.
70
+ *
71
+ * Unicode assigns the General_Category Nd only to decimal digits, and each such
72
+ * block is exactly ten consecutive code points beginning at its own zero. So the
73
+ * value of a digit is how far it sits above the first Nd code point in its run,
74
+ * found by walking down at most nine places. That covers every numbering system
75
+ * whose digits ARE decimal digits -- Arabic-Indic, Devanagari, Bengali, the
76
+ * supplementary-plane ones -- without a table.
77
+ *
78
+ * It returns -1 for `hanidec`, whose Han numerals are not Nd at all. Nothing can
79
+ * infer those from the character alone, which is exactly why the component hands
80
+ * in its own map rather than relying on this.
81
+ */
82
+ const unicodeDigitOf = (character) => {
83
+ const code = character.codePointAt(0);
84
+ if (!Number.isInteger(code) || !/\p{Nd}/u.test(character)) {
85
+ return -1;
86
+ }
87
+ const value = Number(code);
88
+ let zero = value;
89
+ while (value - zero < 9 && /\p{Nd}/u.test(String.fromCodePoint(zero - 1))) {
90
+ zero -= 1;
91
+ }
92
+ return value - zero;
93
+ };
94
+ /**
95
+ * Which way the whole number moved: 1 up, -1 down, 0 unchanged.
96
+ *
97
+ * Numbers are compared as numbers. A pre-formatted string has no numeric value
98
+ * to compare, so its digit runs are compared instead -- longer wins, then
99
+ * lexicographically, which is exactly numeric order for two runs of digits and
100
+ * stays correct past `Number.MAX_SAFE_INTEGER`, where subtracting would not.
101
+ */
102
+ export const trendBetween = (previous, next, digitOf = unicodeDigitOf) => {
103
+ if (typeof previous === 'number' && typeof next === 'number') {
104
+ // `|| 0` folds away both the signed zero `Math.sign` returns for `0 -> -0`
105
+ // and the NaN it returns when either side is non-finite: neither is a
106
+ // direction, and both would otherwise be passed on as one.
107
+ return Math.sign(next - previous) || 0;
108
+ }
109
+ const before = digitRun(String(previous), digitOf);
110
+ const after = digitRun(String(next), digitOf);
111
+ if (before.length !== after.length) {
112
+ return Math.sign(after.length - before.length);
113
+ }
114
+ return before < after ? 1 : before > after ? -1 : 0;
115
+ };
116
+ /** A CSS `<time>` in milliseconds. Anything unparseable reads as 0, i.e. no motion. */
117
+ export const durationMs = (value) => {
118
+ const text = value.trim();
119
+ const amount = Number.parseFloat(text);
120
+ if (!Number.isFinite(amount)) {
121
+ return 0;
122
+ }
123
+ return text.endsWith('ms') ? amount : text.endsWith('s') ? amount * 1000 : 0;
124
+ };
125
+ /**
126
+ * Registers the delta property on the document, returning whether it is
127
+ * registered afterwards.
128
+ *
129
+ * It has to be done from script rather than by an `@property` rule in the
130
+ * component's own stylesheet: an `@property` inside a shadow root does not
131
+ * register (measured in both Chromium and WebKit -- the probe's initial value
132
+ * never applied). That stylesheet is the only one reaching inside the shadow
133
+ * root a custom-element consumer gets, so relying on the at-rule would have left
134
+ * `<sui-animated-number>` silently un-animated while the Svelte component moved.
135
+ * `CSS.registerProperty` registers per document, so one call covers both.
136
+ */
137
+ const registerDelta = () => {
138
+ try {
139
+ CSS.registerProperty({
140
+ name: DELTA_PROPERTY,
141
+ syntax: '<number>',
142
+ inherits: true,
143
+ initialValue: '0'
144
+ });
145
+ return true;
146
+ }
147
+ catch (error) {
148
+ // Registering the same name twice throws, and so does a malformed
149
+ // descriptor. Only the first means the property is there -- which happens
150
+ // whenever a second copy of this library shares the page.
151
+ return error instanceof DOMException && error.name === 'InvalidModificationError';
152
+ }
153
+ };
154
+ /**
155
+ * Whether this engine can run the roll at all.
156
+ *
157
+ * `mod()` and `round()` place the glyphs, `linear()` carries the spring easing,
158
+ * and the registration above is what lets the delta interpolate rather than jump
159
+ * at the halfway point. Without all four the component still renders correctly
160
+ * -- `transform` falls back to `none`, which is where the current glyph already
161
+ * sits, and the other nine stay `display: none` because nothing ever adds the
162
+ * spinning class -- it just changes instantly.
163
+ */
164
+ let supported = null;
165
+ export const canSpin = () => {
166
+ if (supported !== null) {
167
+ return supported;
168
+ }
169
+ if (typeof CSS === 'undefined' || typeof CSS.supports !== 'function') {
170
+ supported = false;
171
+ return supported;
172
+ }
173
+ // Probed by attempting it rather than through CSS.supports, which reports
174
+ // false positives for `linear()` against what `animate()` will actually take.
175
+ const linear = (() => {
176
+ try {
177
+ document.createElement('div').animate({ opacity: 0 }, { easing: 'linear(0, 1)' });
178
+ return true;
179
+ }
180
+ catch {
181
+ return false;
182
+ }
183
+ })();
184
+ supported =
185
+ CSS.supports('line-height', 'mod(1,1)') &&
186
+ CSS.supports('line-height', 'round(down,1,1)') &&
187
+ linear &&
188
+ registerDelta();
189
+ return supported;
190
+ };
@@ -0,0 +1,42 @@
1
+ /** How value changes are exposed to assistive technology. */
2
+ export type AnimatedNumberLive = 'off' | 'polite' | 'assertive';
3
+ export type AnimatedNumberProperties = MandatoryAnimatedNumberProperties & OptionalAnimatedNumberProperties;
4
+ export type MandatoryAnimatedNumberProperties = {
5
+ /**
6
+ * The value to display. A `number` is formatted through `Intl.NumberFormat`
7
+ * and decomposed semantically, so the component knows a group separator from
8
+ * a currency symbol. A `string` is taken as already-formatted and diffed
9
+ * against the previous string instead -- which is what lets a consumer's own
10
+ * formatter, or a pre-formatted prop like `StatCard.value`, animate without
11
+ * anyone having to surface the underlying number.
12
+ */
13
+ value: number | string;
14
+ };
15
+ export type OptionalAnimatedNumberProperties = {
16
+ /**
17
+ * BCP 47 locale used to format a numeric `value`. Defaults to a fixed
18
+ * `'en-US'` rather than the runtime's ambient default: `Intl.NumberFormat`
19
+ * with an undefined locale resolves against whatever the executing
20
+ * environment reports, which differs between the process that prerenders a
21
+ * page and the browser that hydrates it. Pinning it keeps the server and the
22
+ * first client frame byte-identical.
23
+ * @default 'en-US'
24
+ */
25
+ locale?: string;
26
+ /** `Intl.NumberFormat` options applied when `value` is a number. */
27
+ format?: Intl.NumberFormatOptions;
28
+ /**
29
+ * Whether value changes are announced. Left `'off'`, the accessible name
30
+ * still updates -- it is simply not interrupted into. Reserve `'polite'` for
31
+ * values that change infrequently; a rapidly changing value queues one
32
+ * announcement per change, with no coalescing.
33
+ * @default 'off'
34
+ */
35
+ live?: AnimatedNumberLive;
36
+ /** Overrides the accessible name, which otherwise mirrors the visible text. */
37
+ ariaLabel?: string;
38
+ /** Value for the `data-pw` attribute on the root element for E2E selection. */
39
+ testId?: string;
40
+ /** Extra CSS classes applied to the root element for consumer theming. */
41
+ classes?: string;
42
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import AnimatedNumber from '../AnimatedNumber/AnimatedNumber.svelte';
2
3
  import type { BadgeProperties } from './properties';
3
4
 
4
5
  let {
@@ -9,7 +10,8 @@
9
10
  hidden = false,
10
11
  ariaLabel,
11
12
  testId,
12
- classes
13
+ classes,
14
+ animateValue = false
13
15
  }: BadgeProperties = $props();
14
16
 
15
17
  let showImage = $derived(typeof image === 'string' && image.length > 0);
@@ -20,6 +22,11 @@
20
22
  let standaloneAriaLabel = $derived(
21
23
  isDot ? null : (ariaLabel ?? (typeof value === 'string' ? value : null))
22
24
  );
25
+
26
+ // `value` is a general-purpose display string ('99+' included), so the dot
27
+ // check gates AnimatedNumber the same way it already gates the plain text --
28
+ // dot mode never had a value to animate in the first place.
29
+ let animateThisValue = $derived(animateValue && !isDot);
23
30
  </script>
24
31
 
25
32
  {#if showImage}
@@ -31,7 +38,13 @@
31
38
  <div class="badge-wrap">
32
39
  <img class="icon-img" src={image} {alt} />
33
40
  {#if showBadge}
34
- <div class="badge" class:badge-dot={isDot}>{isDot ? '' : (value ?? '')}</div>
41
+ <div class="badge" class:badge-dot={isDot}>
42
+ {#if animateThisValue}
43
+ <AnimatedNumber value={value ?? ''} />
44
+ {:else}
45
+ {isDot ? '' : (value ?? '')}
46
+ {/if}
47
+ </div>
35
48
  {/if}
36
49
  </div>
37
50
  </div>
@@ -44,7 +57,11 @@
44
57
  data-pw={typeof testId === 'string' ? testId : null}
45
58
  testID={typeof testId === 'string' ? testId : null}
46
59
  >
47
- {isDot ? '' : (value ?? '')}
60
+ {#if animateThisValue}
61
+ <AnimatedNumber value={value ?? ''} />
62
+ {:else}
63
+ {isDot ? '' : (value ?? '')}
64
+ {/if}
48
65
  </div>
49
66
  {/if}
50
67
 
@@ -10,5 +10,14 @@ export type OptionalBadgeProperties = {
10
10
  ariaLabel?: string;
11
11
  testId?: string;
12
12
  classes?: string;
13
+ /**
14
+ * Rolls `value` through AnimatedNumber instead of swapping it in place.
15
+ * `value` stays the same string prop -- AnimatedNumber diffs it by
16
+ * character position, so '99+' rolls the two digits and leaves the '+'
17
+ * alone, with no separate numeric prop required here. No effect in
18
+ * `mode="dot"`, which never renders `value` at all.
19
+ * @default false
20
+ */
21
+ animateValue?: boolean;
13
22
  };
14
23
  export type BadgeEventProperties = Record<never, never>;
@@ -185,6 +185,15 @@
185
185
  outline: none;
186
186
  }
187
187
 
188
+ /* role="region" tabindex="0" above makes this a real Tab stop (arrow keys
189
+ then page it via handleKeyDown), so removing its outline unconditionally
190
+ left a keyboard user with no visible sign it was focused. :focus-visible
191
+ so tapping/clicking into the book to swipe doesn't show a ring. */
192
+ .book:focus-visible {
193
+ outline: var(--book-focus-outline, 2px solid #2563eb);
194
+ outline-offset: var(--book-focus-outline-offset, 2px);
195
+ }
196
+
188
197
  .book-viewport {
189
198
  position: relative;
190
199
  display: flex;
@@ -202,7 +202,31 @@
202
202
  return false;
203
203
  }
204
204
 
205
+ // Bounds the prev/next month CONTROLS, distinct from isDateDisabled's per-day check
206
+ // above: a month is only unreachable when every one of its days would fail that same
207
+ // check, i.e. the whole month falls outside [minDate, maxDate] -- not merely when
208
+ // today's selection or the currently displayed month does. Comparing the previous
209
+ // month's LAST day against minDate (and the next month's FIRST day against maxDate)
210
+ // is the correct boundary: any later/earlier day in that month is enabled already.
211
+ const isPrevMonthDisabled = $derived(
212
+ minDate !== null &&
213
+ new SvelteDate(displayDate.getFullYear(), displayDate.getMonth(), 0).getTime() <
214
+ normalizeDate(minDate).getTime()
215
+ );
216
+
217
+ const isNextMonthDisabled = $derived(
218
+ maxDate !== null &&
219
+ new SvelteDate(displayDate.getFullYear(), displayDate.getMonth() + 1, 1).getTime() >
220
+ normalizeDate(maxDate).getTime()
221
+ );
222
+
205
223
  function navigateMonth(delta: number): void {
224
+ if (delta < 0 && isPrevMonthDisabled) {
225
+ return;
226
+ }
227
+ if (delta > 0 && isNextMonthDisabled) {
228
+ return;
229
+ }
206
230
  displayDate.setMonth(displayDate.getMonth() + delta);
207
231
  focusedDay = null;
208
232
  onmonthchange?.({ year: displayDate.getFullYear(), month: displayDate.getMonth() });
@@ -325,7 +349,11 @@
325
349
  >
326
350
  <div class="header">
327
351
  <div class="nav-button nav-prev">
328
- <Button onclick={() => navigateMonth(-1)} ariaLabel="Previous month">
352
+ <Button
353
+ onclick={() => navigateMonth(-1)}
354
+ ariaLabel="Previous month"
355
+ disabled={isPrevMonthDisabled}
356
+ >
329
357
  {#if typeof previousMonthIcon === 'function'}
330
358
  {@render previousMonthIcon()}
331
359
  {:else}
@@ -336,7 +364,11 @@
336
364
  </div>
337
365
  <span class="header-label">{headerLabel}</span>
338
366
  <div class="nav-button nav-next">
339
- <Button onclick={() => navigateMonth(1)} ariaLabel="Next month">
367
+ <Button
368
+ onclick={() => navigateMonth(1)}
369
+ ariaLabel="Next month"
370
+ disabled={isNextMonthDisabled}
371
+ >
340
372
  {#if typeof nextMonthIcon === 'function'}
341
373
  {@render nextMonthIcon()}
342
374
  {:else}
@@ -233,6 +233,17 @@
233
233
  transition: transform var(--carousel-track-transition-duration, var(--motion-duration, 0.5s))
234
234
  var(--carousel-track-transition-easing, var(--motion-easing, ease-in-out));
235
235
  }
236
+ /* The track slide is the component's motion. The dots below transition only
237
+ `background`, which is a colour change rather than movement, so they are
238
+ deliberately left alone -- silencing them would remove feedback the
239
+ preference never asked to remove. Finite, so dropping it lands the track on
240
+ the slide it was moving to. */
241
+ @media (prefers-reduced-motion: reduce) {
242
+ .slidesDiv {
243
+ transition: none;
244
+ }
245
+ }
246
+
236
247
  .dots-wrapper {
237
248
  gap: var(--dot-gap, 10px);
238
249
  padding-top: var(--dot-padding-top, 10px);
@@ -245,7 +256,10 @@
245
256
  border-radius: 50%;
246
257
  background: var(--carousel-dot-color, #c4c4c4);
247
258
  cursor: pointer;
248
- transition: all var(--carousel-dot-transition-duration, var(--motion-duration, 0.3s))
259
+ /* Only background actually differs between .dot and .active-dot, so only
260
+ background is transitioned. `transition: all` used to catch `outline`
261
+ too, animating the keyboard focus ring in on every Tab. */
262
+ transition: background var(--carousel-dot-transition-duration, var(--motion-duration, 0.3s))
249
263
  var(--carousel-dot-transition-easing, var(--motion-easing, ease));
250
264
  }
251
265
 
@@ -255,7 +269,8 @@
255
269
  border-radius: 50%;
256
270
  cursor: pointer;
257
271
  background: var(--carousel-dot-active-color, #000000);
258
- transition: all var(--carousel-dot-transition-duration, var(--motion-duration, 0.3s))
272
+ /* See .dot above: scoped to background so outline is never transitioned. */
273
+ transition: background var(--carousel-dot-transition-duration, var(--motion-duration, 0.3s))
259
274
  var(--carousel-dot-transition-easing, var(--motion-easing, ease));
260
275
  }
261
276
 
@@ -385,6 +385,18 @@
385
385
  outline: none;
386
386
  }
387
387
 
388
+ /* setOpen() (see script) calls panel.focus() directly on open, and the panel
389
+ -- not a child -- is the Tab-trap's own boundary, so removing its outline
390
+ unconditionally left a keyboard user with no visible sign focus had moved
391
+ into the launcher's panel at all. :focus-visible restores a ring only for
392
+ keyboard/programmatic focus, so the launcher click that opens the panel
393
+ shows nothing new. Matches Sheet's sheet-panel fix for the identical
394
+ shape of problem. */
395
+ .panel:focus-visible {
396
+ outline: var(--chat-bubble-panel-focus-outline, 2px solid #2563eb);
397
+ outline-offset: var(--chat-bubble-panel-focus-outline-offset, -2px);
398
+ }
399
+
388
400
  .launcher {
389
401
  --button-width: var(--chat-bubble-width, var(--chat-bubble-size, 56px));
390
402
  --button-height: var(--chat-bubble-height, var(--chat-bubble-size, 56px));
@@ -356,6 +356,15 @@
356
356
  opacity: var(--chat-composer-disabled-opacity, 0.6);
357
357
  }
358
358
 
359
+ /* .input below sets outline: none with nothing standing in for it, so the
360
+ visible replacement lives here on the surrounding surface instead --
361
+ :focus-within lights up whenever the textarea inside is focused, the same
362
+ pattern Select's .select-trigger uses for its nested search input. */
363
+ .chat-composer:focus-within {
364
+ border-color: var(--chat-composer-focus-border-color, #2563eb);
365
+ box-shadow: var(--chat-composer-focus-shadow, 0 0 0 2px rgba(37, 99, 235, 0.2));
366
+ }
367
+
359
368
  .attachments-rich {
360
369
  padding: var(--chat-composer-attachments-padding, 2px 4px 0);
361
370
  }
@@ -431,6 +431,19 @@
431
431
  caret-color: var(--command-menu-input-caret-color, #3b82f6);
432
432
  }
433
433
 
434
+ /* The input is the only real Tab stop the overlay currently renders (list
435
+ items are a roving aria-activedescendant selection, not DOM focus -- see
436
+ .active below), so it is also the only element here that WCAG 2.4.7
437
+ applies to. A real text input, so :focus rather than :focus-visible: the
438
+ ring must stay visible regardless of input modality, matching Input.svelte's
439
+ own textarea/input convention. Positive offset (not Sheet/Modal's inset
440
+ -2px) because the input sits inside .command-menu-input-wrapper's own
441
+ padding, not flush against the dialog's edge. */
442
+ .command-menu-input:focus {
443
+ outline: var(--command-menu-input-focus-outline, 2px solid #2563eb);
444
+ outline-offset: var(--command-menu-input-focus-outline-offset, 2px);
445
+ }
446
+
434
447
  .command-menu-input::placeholder {
435
448
  color: var(--command-menu-input-placeholder-color, #94a3b8);
436
449
  }