@juspay/svelte-ui-components 4.29.1 → 4.31.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 (58) hide show
  1. package/README.md +15 -14
  2. package/dist/Accordion/Accordion.svelte +18 -0
  3. package/dist/AnimatedNumber/AnimatedNumber.svelte +776 -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 +58 -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 +129 -5
  44. package/dist/StatCard/metric.d.ts +22 -0
  45. package/dist/StatCard/metric.js +74 -0
  46. package/dist/StatCard/properties.d.ts +44 -0
  47. package/dist/Table/BuiltinCell.svelte +19 -2
  48. package/dist/Table/cellData.js +3 -0
  49. package/dist/Table/properties.d.ts +15 -0
  50. package/dist/Tabs/Tabs.svelte +16 -0
  51. package/dist/index.d.ts +2 -0
  52. package/dist/index.js +1 -0
  53. package/dist/styles/theme-dark.css +27 -3
  54. package/dist-codemod/codemod/migration-assets/legacy-display.css +2 -1
  55. package/dist-codemod/codemod/migration-assets/legacy-palette.css +4 -1
  56. package/dist-codemod/codemod/migration-assets/wc-display.json +5 -0
  57. package/dist-wc/index.js +1435 -747
  58. package/package.json +4 -2
@@ -11,6 +11,7 @@
11
11
  import { focusTrapTabTarget, getFocusTrapBoundary } from '../_interaction/focus';
12
12
  import { getActiveElement } from '../_interaction/focus';
13
13
  import { registerDismissible } from '../_interaction/dismissal';
14
+ import { describeField } from '../_field/description';
14
15
  import chevronDownSvg from '../assets/chevron-down.svg?raw';
15
16
  import checkmarkSvg from '../assets/checkmark.svg?raw';
16
17
  import chevronRightSvg from '../assets/chevron-right.svg?raw';
@@ -679,12 +680,20 @@
679
680
  The two layout branches below (`isInlineTime` and its `{:else}`) are
680
681
  mutually exclusive, so the same four ids are never in the document twice. */
681
682
  const drpFieldUid = $props.id();
682
- const startDateErrorId = `${drpFieldUid}-start-date-error`;
683
- const startTimeErrorId = `${drpFieldUid}-start-time-error`;
684
- const endDateErrorId = `${drpFieldUid}-end-date-error`;
685
- const endTimeErrorId = `${drpFieldUid}-end-time-error`;
686
683
  const DATE_HINT = 'Enter a date this picker accepts, like 12 Mar 2025.';
687
684
  const TIME_HINT = 'Enter a time like 12:00 AM.';
685
+ const startDateField = $derived(
686
+ describeField(`${drpFieldUid}-start-date`, { error: isStartDateValid ? null : DATE_HINT })
687
+ );
688
+ const startTimeField = $derived(
689
+ describeField(`${drpFieldUid}-start-time`, { error: isStartTimeValid ? null : TIME_HINT })
690
+ );
691
+ const endDateField = $derived(
692
+ describeField(`${drpFieldUid}-end-date`, { error: isEndDateValid ? null : DATE_HINT })
693
+ );
694
+ const endTimeField = $derived(
695
+ describeField(`${drpFieldUid}-end-time`, { error: isEndTimeValid ? null : TIME_HINT })
696
+ );
688
697
 
689
698
  // True when date's month is already one of the two visible calendar months (or the
690
699
  // single visible month outside dual-month mode) — used to avoid an unnecessary
@@ -1038,7 +1047,7 @@
1038
1047
  placeholder="Start date"
1039
1048
  aria-label="Start date"
1040
1049
  aria-invalid={!isStartDateValid}
1041
- aria-describedby={isStartDateValid ? null : startDateErrorId}
1050
+ aria-describedby={startDateField.describedBy}
1042
1051
  onblur={() => commitTypedDate('start')}
1043
1052
  onkeydown={(event) => handleDateInputKeyDown(event, 'start')}
1044
1053
  data-pw={testId ? `${testId}-start-date` : null}
@@ -1059,14 +1068,22 @@
1059
1068
  placeholder="12:00 AM"
1060
1069
  aria-label="Start time"
1061
1070
  aria-invalid={!isStartTimeValid}
1062
- aria-describedby={isStartTimeValid ? null : startTimeErrorId}
1071
+ aria-describedby={startTimeField.describedBy}
1063
1072
  data-pw={testId ? `${testId}-start-time` : null}
1064
1073
  testID={testId ? `${testId}-start-time` : null}
1065
1074
  />
1066
1075
  </div>
1067
- {@render drpFieldError(startDateErrorId, !isStartDateValid, DATE_HINT)}
1076
+ {@render drpFieldError(
1077
+ startDateField.errorId,
1078
+ startDateField.showsError,
1079
+ DATE_HINT
1080
+ )}
1068
1081
  </div>
1069
- {@render drpFieldError(startTimeErrorId, !isStartTimeValid, TIME_HINT)}
1082
+ {@render drpFieldError(
1083
+ startTimeField.errorId,
1084
+ startTimeField.showsError,
1085
+ TIME_HINT
1086
+ )}
1070
1087
  <!-- eslint-disable-next-line svelte/no-at-html-tags -->
1071
1088
  <span class="drp-datetime-arrow" aria-hidden="true">{@html chevronRightSvg}</span>
1072
1089
  <div class="drp-date-time-group">
@@ -1080,7 +1097,7 @@
1080
1097
  placeholder="End date"
1081
1098
  aria-label="End date"
1082
1099
  aria-invalid={!isEndDateValid}
1083
- aria-describedby={isEndDateValid ? null : endDateErrorId}
1100
+ aria-describedby={endDateField.describedBy}
1084
1101
  onblur={() => commitTypedDate('end')}
1085
1102
  onkeydown={(event) => handleDateInputKeyDown(event, 'end')}
1086
1103
  data-pw={testId ? `${testId}-end-date` : null}
@@ -1101,14 +1118,18 @@
1101
1118
  placeholder="11:59 PM"
1102
1119
  aria-label="End time"
1103
1120
  aria-invalid={!isEndTimeValid}
1104
- aria-describedby={isEndTimeValid ? null : endTimeErrorId}
1121
+ aria-describedby={endTimeField.describedBy}
1105
1122
  data-pw={testId ? `${testId}-end-time` : null}
1106
1123
  testID={testId ? `${testId}-end-time` : null}
1107
1124
  />
1108
1125
  </div>
1109
- {@render drpFieldError(endDateErrorId, !isEndDateValid, DATE_HINT)}
1126
+ {@render drpFieldError(
1127
+ endDateField.errorId,
1128
+ endDateField.showsError,
1129
+ DATE_HINT
1130
+ )}
1110
1131
  </div>
1111
- {@render drpFieldError(endTimeErrorId, !isEndTimeValid, TIME_HINT)}
1132
+ {@render drpFieldError(endTimeField.errorId, endTimeField.showsError, TIME_HINT)}
1112
1133
  {:else}
1113
1134
  <div class="drp-date-input" class:drp-date-input-invalid={!isStartDateValid}>
1114
1135
  <input
@@ -1120,13 +1141,17 @@
1120
1141
  placeholder="Start date"
1121
1142
  aria-label="Start date"
1122
1143
  aria-invalid={!isStartDateValid}
1123
- aria-describedby={isStartDateValid ? null : startDateErrorId}
1144
+ aria-describedby={startDateField.describedBy}
1124
1145
  onblur={() => commitTypedDate('start')}
1125
1146
  onkeydown={(event) => handleDateInputKeyDown(event, 'start')}
1126
1147
  data-pw={testId ? `${testId}-start-date` : null}
1127
1148
  testID={testId ? `${testId}-start-date` : null}
1128
1149
  />
1129
- {@render drpFieldError(startDateErrorId, !isStartDateValid, DATE_HINT)}
1150
+ {@render drpFieldError(
1151
+ startDateField.errorId,
1152
+ startDateField.showsError,
1153
+ DATE_HINT
1154
+ )}
1130
1155
  </div>
1131
1156
  <!-- eslint-disable-next-line svelte/no-at-html-tags -->
1132
1157
  <span class="drp-datetime-arrow" aria-hidden="true">{@html chevronRightSvg}</span>
@@ -1140,13 +1165,17 @@
1140
1165
  placeholder="End date"
1141
1166
  aria-label="End date"
1142
1167
  aria-invalid={!isEndDateValid}
1143
- aria-describedby={isEndDateValid ? null : endDateErrorId}
1168
+ aria-describedby={endDateField.describedBy}
1144
1169
  onblur={() => commitTypedDate('end')}
1145
1170
  onkeydown={(event) => handleDateInputKeyDown(event, 'end')}
1146
1171
  data-pw={testId ? `${testId}-end-date` : null}
1147
1172
  testID={testId ? `${testId}-end-date` : null}
1148
1173
  />
1149
- {@render drpFieldError(endDateErrorId, !isEndDateValid, DATE_HINT)}
1174
+ {@render drpFieldError(
1175
+ endDateField.errorId,
1176
+ endDateField.showsError,
1177
+ DATE_HINT
1178
+ )}
1150
1179
  </div>
1151
1180
  {#if showTimeSelection}
1152
1181
  <button
@@ -1178,11 +1207,15 @@
1178
1207
  placeholder="12:00 AM"
1179
1208
  aria-label="Start time"
1180
1209
  aria-invalid={!isStartTimeValid}
1181
- aria-describedby={isStartTimeValid ? null : startTimeErrorId}
1210
+ aria-describedby={startTimeField.describedBy}
1182
1211
  data-pw={testId ? `${testId}-start-time` : null}
1183
1212
  testID={testId ? `${testId}-start-time` : null}
1184
1213
  />
1185
- {@render drpFieldError(startTimeErrorId, !isStartTimeValid, TIME_HINT)}
1214
+ {@render drpFieldError(
1215
+ startTimeField.errorId,
1216
+ startTimeField.showsError,
1217
+ TIME_HINT
1218
+ )}
1186
1219
  </div>
1187
1220
  <!-- eslint-disable-next-line svelte/no-at-html-tags -->
1188
1221
  <span class="drp-datetime-arrow" aria-hidden="true">{@html chevronRightSvg}</span>
@@ -1197,11 +1230,15 @@
1197
1230
  placeholder="11:59 PM"
1198
1231
  aria-label="End time"
1199
1232
  aria-invalid={!isEndTimeValid}
1200
- aria-describedby={isEndTimeValid ? null : endTimeErrorId}
1233
+ aria-describedby={endTimeField.describedBy}
1201
1234
  data-pw={testId ? `${testId}-end-time` : null}
1202
1235
  testID={testId ? `${testId}-end-time` : null}
1203
1236
  />
1204
- {@render drpFieldError(endTimeErrorId, !isEndTimeValid, TIME_HINT)}
1237
+ {@render drpFieldError(
1238
+ endTimeField.errorId,
1239
+ endTimeField.showsError,
1240
+ TIME_HINT
1241
+ )}
1205
1242
  </div>
1206
1243
  </div>
1207
1244
  {/if}
@@ -1612,6 +1649,18 @@
1612
1649
  border-color: var(--drp-date-input-invalid-border, #e5484d);
1613
1650
  }
1614
1651
 
1652
+ /* .drp-date-input-value below sets outline: none with nothing standing in
1653
+ for it, so the visible replacement lives on the bordered wrapper instead
1654
+ -- :focus-within lights up whenever the text input inside is focused.
1655
+ class:drp-date-input-invalid sits alongside .drp-date-input on the same
1656
+ div (see markup), and :focus-within's extra pseudo-class outweighs that
1657
+ single class, so a focused invalid field shows the focus border, not the
1658
+ invalid one -- focus location is the more time-sensitive signal. */
1659
+ .drp-date-input:focus-within {
1660
+ border-color: var(--drp-date-input-focus-border, #2563eb);
1661
+ box-shadow: var(--drp-date-input-focus-shadow, 0 0 0 2px rgba(37, 99, 235, 0.2));
1662
+ }
1663
+
1615
1664
  .drp-date-input-value {
1616
1665
  display: block;
1617
1666
  width: 100%;
@@ -1691,6 +1740,14 @@
1691
1740
  border-color: var(--drp-time-input-invalid-border, #e5484d);
1692
1741
  }
1693
1742
 
1743
+ /* .drp-time-field below sets outline: none with nothing standing in for it;
1744
+ same fix as .drp-date-input:focus-within above, same specificity
1745
+ reasoning against .drp-time-input-invalid. */
1746
+ .drp-time-input:focus-within {
1747
+ border-color: var(--drp-time-input-focus-border, #2563eb);
1748
+ box-shadow: var(--drp-time-input-focus-shadow, 0 0 0 2px rgba(37, 99, 235, 0.2));
1749
+ }
1750
+
1694
1751
  .drp-time-input-icon {
1695
1752
  display: inline-flex;
1696
1753
  flex-shrink: 0;
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import AnimatedNumber from '../AnimatedNumber/AnimatedNumber.svelte';
2
3
  import type { DeltaIndicatorProperties, DeltaDirection } from './properties';
3
4
 
4
5
  let {
@@ -7,6 +8,7 @@
7
8
  invertColors = false,
8
9
  hideArrow = false,
9
10
  neutralThreshold = 0,
11
+ animateValue = false,
10
12
  testId,
11
13
  classes
12
14
  }: DeltaIndicatorProperties = $props();
@@ -50,7 +52,16 @@
50
52
  place of the trend arrow, so a flat delta still reads as a stated state. -->
51
53
  <span class="delta-indicator-dash" aria-hidden="true">—</span>
52
54
  {/if}
53
- <span class="delta-indicator-text">{text}</span>
55
+ <span class="delta-indicator-text">
56
+ {#if animateValue}
57
+ <!-- The formatter's STRING output, not `value` -- a consumer's own `format`
58
+ must still animate, and this component has no business re-deriving or
59
+ second-guessing what that formatter produced. -->
60
+ <AnimatedNumber value={text} />
61
+ {:else}
62
+ {text}
63
+ {/if}
64
+ </span>
54
65
  </span>
55
66
 
56
67
  <style>
@@ -19,6 +19,15 @@ export type OptionalDeltaIndicatorProperties = {
19
19
  hideArrow?: boolean;
20
20
  /** Absolute values at or below this are treated as `neutral` (default 0). */
21
21
  neutralThreshold?: number;
22
+ /**
23
+ * Roll the displayed text through `AnimatedNumber` instead of setting it as
24
+ * plain text on every `value`/`format` change. The formatter's OUTPUT
25
+ * STRING is what gets passed through, not the raw `value` -- so a custom
26
+ * `format` still animates without this component having to guess at, or
27
+ * reproduce, its formatting.
28
+ * @default false
29
+ */
30
+ animateValue?: boolean;
22
31
  /** Test selector applied as the `data-pw` attribute. */
23
32
  testId?: string | null;
24
33
  /** Additional CSS classes for theming. */
@@ -533,6 +533,22 @@
533
533
  outline: none;
534
534
  }
535
535
 
536
+ /* lightboxAction focuses this element directly on open (and goToIndex
537
+ re-focuses it whenever focus isn't already inside), and it's the
538
+ Tab-trap's own boundary, so the unconditional :focus suppression above
539
+ left a keyboard user with no visible sign focus had moved into the
540
+ lightbox at all. :focus-visible wins the cascade over the plain :focus
541
+ rule above (equal specificity, later in source, and a strict subset) only
542
+ when the browser judges this came from keyboard/programmatic focus, so
543
+ the thumbnail click that opens the lightbox still shows nothing new.
544
+ outline-offset is negative (inset), matching Sheet's sheet-panel: the
545
+ lightbox sits flush against all four viewport edges, so a positive offset
546
+ would clip. */
547
+ .lightbox:focus-visible {
548
+ outline: var(--gallery-lightbox-focus-outline, 2px solid #2563eb);
549
+ outline-offset: var(--gallery-lightbox-focus-outline-offset, -2px);
550
+ }
551
+
536
552
  .lightbox-figure {
537
553
  display: flex;
538
554
  flex-direction: column;
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import AnimatedNumber from '../AnimatedNumber/AnimatedNumber.svelte';
2
3
  import type { GaugeProperties } from './properties';
3
4
 
4
5
  let {
@@ -6,6 +7,7 @@
6
7
  max = 100,
7
8
  showLabel = true,
8
9
  labelFormatter,
10
+ animateValue = false,
9
11
  ariaLabel,
10
12
  testId,
11
13
  classes
@@ -24,7 +26,8 @@
24
26
  // labelFormatter receives the raw (value, max) pair so callers have full
25
27
  // context. The built-in fallback shows the computed percentage string
26
28
  // (e.g. value=50, max=200 → "25%").
27
- let labelText = $derived(labelFormatter ? labelFormatter(value, max) : `${Math.round(clamped)}%`);
29
+ let roundedPercentage = $derived(Math.round(clamped));
30
+ let labelText = $derived(labelFormatter ? labelFormatter(value, max) : `${roundedPercentage}%`);
28
31
  </script>
29
32
 
30
33
  <div
@@ -52,7 +55,23 @@
52
55
  />
53
56
  </svg>
54
57
  {#if showLabel}
55
- <div class="label">{labelText}</div>
58
+ <div class="label">
59
+ {#if animateValue}
60
+ {#if labelFormatter}
61
+ <AnimatedNumber value={labelText} />
62
+ {:else}
63
+ <!-- The unit goes INSIDE AnimatedNumber, not beside it. The odometer
64
+ carries role="img" with its own aria-label, so a unit left outside is
65
+ invisible to anyone navigating by graphic -- the rotor reads "75" with
66
+ no sign of what it measures. Passing the formatted string keeps the
67
+ unit in the accessible name; only the digits move, since the string
68
+ path rolls the characters that changed and leaves the rest. -->
69
+ <AnimatedNumber value={`${roundedPercentage}%`} />
70
+ {/if}
71
+ {:else}
72
+ {labelText}
73
+ {/if}
74
+ </div>
56
75
  {/if}
57
76
  </div>
58
77
 
@@ -19,6 +19,19 @@ export type OptionalGaugeProperties = {
19
19
  * Example: `labelFormatter={(v, m) => \`${v} / ${m}\`}` shows "50 / 200".
20
20
  */
21
21
  labelFormatter?: (value: number, max: number) => string;
22
+ /**
23
+ * Routes the centered label through `AnimatedNumber` so it rolls to its new
24
+ * value instead of jumping. Both routes pass a complete STRING and diff it
25
+ * character by character: `labelFormatter`'s own output when it is set, and
26
+ * `` `${roundedPercentage}%` `` when it is not. The unit goes inside rather
27
+ * than beside, because the odometer carries `role="img"` with its own
28
+ * accessible name and anything left outside is invisible to a reader
29
+ * navigating by graphic. Only the digits move; the `%` is a literal column
30
+ * that never rolls. Off by default so an unset prop renders the exact static
31
+ * text node it always has.
32
+ * @default false
33
+ */
34
+ animateValue?: boolean;
22
35
  /**
23
36
  * Accessible label for the gauge element (`role="progressbar"`). Falls back
24
37
  * to the computed `labelText` (e.g. `"75%"`) when not provided, so assistive
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { prefersReducedMotion } from '../utils';
3
3
  import { getActiveElement } from '../_interaction/focus';
4
+ import { describeField } from '../_field/description';
4
5
  import { validateInput } from '../utils';
5
6
  import type { InputProperties } from './properties';
6
7
  import type { ValidationState } from '../types';
@@ -137,25 +138,23 @@
137
138
  // forceError lets consumers drive the error border from server/runtime validation,
138
139
  // independent of validationPattern.
139
140
  const showError = $derived(showErrorMessage || forceError);
140
- // The error text has to be reachable FROM the field it describes and announced when it
141
- // appears. Without both, a screen-reader user submits, hears nothing, and is left on a form
142
- // that did not move -- the message is on screen and absent from the accessibility tree.
143
- const errorMessageId = $derived(`${effectiveId}-error`);
141
+ // `actionInput` renders no error/info UI at all, so it gates the whole field
142
+ // description off rather than being threaded through describeField itself.
143
+ const effectiveShowError = $derived(showError && !actionInput);
144
144
  // `onErrorMessage` is `string | null`, so null has to be excluded explicitly: `null !== ''`
145
145
  // is true, which would describe the field by an alert element carrying no message.
146
- const isShowingError = $derived(
147
- onErrorMessage != null && onErrorMessage !== '' && showError && !actionInput
148
- );
149
- const infoMessageId = $derived(`${effectiveId}-info`);
150
- const isShowingInfo = $derived(infoMessage !== '' && !actionInput);
151
- // Helper text is part of the field's description, not decoration: a consumer's `infoMessage`
152
- // ("Enter a percentage between 1 and 100") is exactly the guidance a screen-reader user needs
153
- // BEFORE they trip an error. Reference both, in reading order, so the field is described by
154
- // everything visibly attached to it rather than only by its failure.
155
- const describedBy = $derived(
156
- [isShowingError ? errorMessageId : null, isShowingInfo ? infoMessageId : null]
157
- .filter(Boolean)
158
- .join(' ')
146
+ // forceError with no message still has to mark the field invalid -- describeField's
147
+ // `invalid` override exists for exactly that -- but it cannot be described by an
148
+ // element that will not be rendered, so only a real message is passed as `error`.
149
+ const field = $derived(
150
+ describeField(effectiveId, {
151
+ error:
152
+ effectiveShowError && onErrorMessage != null && onErrorMessage !== ''
153
+ ? onErrorMessage
154
+ : null,
155
+ info: !actionInput && infoMessage !== '' ? infoMessage : null,
156
+ invalid: effectiveShowError
157
+ })
159
158
  );
160
159
  const hasLeftIcon = $derived(typeof leftIcon === 'function');
161
160
  const hasRightIcon = $derived(typeof rightIcon === 'function');
@@ -336,8 +335,8 @@
336
335
  aria-controls={ariaControls}
337
336
  aria-activedescendant={ariaActivedescendant}
338
337
  aria-required={isRequired || null}
339
- aria-invalid={showError && !actionInput ? 'true' : null}
340
- aria-describedby={describedBy || null}
338
+ aria-invalid={field.ariaInvalid}
339
+ aria-describedby={field.describedBy}
341
340
  required={isRequired || null}
342
341
  {onfocus}
343
342
  onfocusout={_onFocusOut}
@@ -374,8 +373,8 @@
374
373
  aria-controls={ariaControls}
375
374
  aria-activedescendant={ariaActivedescendant}
376
375
  aria-required={isRequired || null}
377
- aria-invalid={showError && !actionInput ? 'true' : null}
378
- aria-describedby={describedBy || null}
376
+ aria-invalid={field.ariaInvalid}
377
+ aria-describedby={field.describedBy}
379
378
  required={isRequired || null}
380
379
  {onfocus}
381
380
  onfocusout={_onFocusOut}
@@ -438,9 +437,9 @@
438
437
  {@render fieldElement()}
439
438
  {/if}
440
439
 
441
- {#if isShowingError}
440
+ {#if field.showsError}
442
441
  <div
443
- id={errorMessageId}
442
+ id={field.errorId}
444
443
  role="alert"
445
444
  class="error-message"
446
445
  data-pw={typeof testId === 'string' && testId.length > 0 ? `${testId}-error-message` : null}
@@ -449,9 +448,9 @@
449
448
  {onErrorMessage}
450
449
  </div>
451
450
  {/if}
452
- {#if isShowingInfo}
451
+ {#if field.showsInfo}
453
452
  <div
454
- id={infoMessageId}
453
+ id={field.infoId}
455
454
  class="info-message"
456
455
  data-pw={typeof testId === 'string' && testId.length > 0 ? `${testId}-info-message` : null}
457
456
  testID={typeof testId === 'string' && testId.length > 0 ? `${testId}-info-message` : null}
@@ -504,9 +503,18 @@
504
503
  color: var(--input-text-color, #333333);
505
504
  }
506
505
 
506
+ /* The fallback used to be `1px solid transparent` -- a border that changes
507
+ width/style but not colour, so a mouse-focused OR keyboard-focused field
508
+ looked identical to unfocused. Text inputs use a real :focus (not
509
+ :focus-visible): unlike a button-shaped control, a text field's focus
510
+ ring has to stay visible regardless of input modality, since a mouse
511
+ click into a field is itself the start of a keyboard interaction.
512
+ --input-focus-border-color already exists for exactly this colour (see
513
+ .input-icon-button:focus-visible below), so this reuses it instead of
514
+ minting a second token for the same ring. */
507
515
  textarea:focus,
508
516
  input:focus {
509
- border: var(--input-focus-border, 1px solid transparent);
517
+ border: var(--input-focus-border, 1px solid var(--input-focus-border-color, #005fcc));
510
518
  }
511
519
 
512
520
  .input-error {
@@ -79,7 +79,7 @@
79
79
  }
80
80
 
81
81
  .separator {
82
- color: var(--keyboard-input-separator-color, #888888);
82
+ color: var(--keyboard-input-separator-color, #6b7280);
83
83
  font-size: var(--keyboard-input-separator-font-size, 0.85em);
84
84
  user-select: none;
85
85
  }
@@ -3,12 +3,14 @@
3
3
  import Loader from '../Loader/Loader.svelte';
4
4
  import Img from '../Img/Img.svelte';
5
5
  import type { ListItemProperties } from './properties';
6
+ import { resolveLabelHtml } from './label';
6
7
 
7
8
  let {
8
9
  leftImageUrl,
9
10
  leftImageFallbackUrl,
10
11
  rightImageUrl,
11
12
  label,
13
+ sanitize,
12
14
  useAccordion = false,
13
15
  rightContentText,
14
16
  testId,
@@ -60,6 +62,11 @@
60
62
  );
61
63
  let itemInteractive = $derived(!suppressRoleAndTabindex && itemRole !== 'option');
62
64
 
65
+ // Escaped plain text by default; markup only through a consumer-supplied
66
+ // sanitizer -- see ListItemSanitizeOptions in properties.ts and
67
+ // docs/ListItem.md "Rendering markup in the label" for the full contract.
68
+ let labelHtml = $derived(typeof label === 'string' ? resolveLabelHtml(label, sanitize) : '');
69
+
63
70
  function handleLeftImageClick(event: MouseEvent): void {
64
71
  onleftimageclick?.(event);
65
72
  }
@@ -182,8 +189,11 @@
182
189
  data-pw={centerTextTestId}
183
190
  testID={centerTextTestId}
184
191
  >
185
- <!-- eslint-disable-next-line -->
186
- {@html label}
192
+ <!-- labelHtml is escaped by default; markup only reaches here through a
193
+ consumer-supplied sanitizer -- see ListItemSanitizeOptions.htmlSanitizer
194
+ in properties.ts. -->
195
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
196
+ {@html labelHtml}
187
197
  </div>
188
198
  {/if}
189
199
  {#if typeof centerContent === 'function'}
@@ -404,6 +414,21 @@
404
414
  .prevent-focus:focus {
405
415
  outline: none;
406
416
  }
417
+
418
+ /* preventFocus does not touch tabindex (see the root's tabindex expression
419
+ above -- itemRole/suppressRoleAndTabindex govern that, unconditional on
420
+ this prop), so every element carrying .prevent-focus stays a real,
421
+ keyboard-Tab-reachable stop by default. docs/ListItem.md documents the
422
+ prop as removing the outline "for non-keyboard navigation contexts" --
423
+ :focus-visible is that exact distinction already built into the
424
+ platform: it does not match a mouse/touch-triggered focus (the case the
425
+ prop exists for) but does match a real keyboard Tab, so this restores
426
+ WCAG 2.4.7 visibility for the one case preventFocus was never meant to
427
+ hide it from. */
428
+ .prevent-focus:focus-visible {
429
+ outline: var(--list-item-focus-outline, 2px solid #2563eb);
430
+ outline-offset: var(--list-item-focus-outline-offset, -2px);
431
+ }
407
432
  /* The loader is not the only motion in this file, and this block sits at the
408
433
  END on purpose: `.item` is declared after the guard above, so an override
409
434
  placed there would lose the same-specificity tie on source order -- the
@@ -0,0 +1,11 @@
1
+ import type { ListItemSanitizeOptions } from './properties';
2
+ /**
3
+ * Resolves what ListItem's `label` renders as through `{@html}`.
4
+ *
5
+ * Defaults to escaped plain text. `sanitize.rawHtml: 'sanitize'` with a
6
+ * `sanitize.htmlSanitizer` supplied renders `label` as markup instead, through
7
+ * that sanitizer — see `ListItemSanitizeOptions.htmlSanitizer` for the full
8
+ * contract (called once, falls back to escaping on throw or a non-string
9
+ * return, `'sanitize'` with no sanitizer supplied is exactly `'escape'`).
10
+ */
11
+ export declare function resolveLabelHtml(label: string, sanitize?: ListItemSanitizeOptions): string;
@@ -0,0 +1,40 @@
1
+ function escapeHtml(value) {
2
+ return value
3
+ .replace(/&/g, '&amp;')
4
+ .replace(/</g, '&lt;')
5
+ .replace(/>/g, '&gt;')
6
+ .replace(/"/g, '&quot;')
7
+ .replace(/'/g, '&#39;');
8
+ }
9
+ /**
10
+ * A consumer's sanitizer is arbitrary code. `null` here means it did not
11
+ * return usable markup, and the caller falls back to escaping — never to
12
+ * the unsanitized string.
13
+ */
14
+ function runSanitizer(html, htmlSanitizer) {
15
+ try {
16
+ const sanitized = htmlSanitizer(html);
17
+ return typeof sanitized === 'string' ? sanitized : null;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ /**
24
+ * Resolves what ListItem's `label` renders as through `{@html}`.
25
+ *
26
+ * Defaults to escaped plain text. `sanitize.rawHtml: 'sanitize'` with a
27
+ * `sanitize.htmlSanitizer` supplied renders `label` as markup instead, through
28
+ * that sanitizer — see `ListItemSanitizeOptions.htmlSanitizer` for the full
29
+ * contract (called once, falls back to escaping on throw or a non-string
30
+ * return, `'sanitize'` with no sanitizer supplied is exactly `'escape'`).
31
+ */
32
+ export function resolveLabelHtml(label, sanitize) {
33
+ if (sanitize?.rawHtml === 'sanitize' && typeof sanitize.htmlSanitizer === 'function') {
34
+ const sanitized = runSanitizer(label, sanitize.htmlSanitizer);
35
+ if (sanitized !== null) {
36
+ return sanitized;
37
+ }
38
+ }
39
+ return escapeHtml(label);
40
+ }
@@ -5,7 +5,23 @@ export type ListItemProperties = ListItemEventProperties & {
5
5
  rightImageUrl?: string | null;
6
6
  /** Rewrites SVG markup before either image is inlined. Providing it enables SVG inlining. */
7
7
  transformSvg?: (svg: string) => string;
8
+ /**
9
+ * Center text content. Used for the main list item label.
10
+ *
11
+ * Rendered as **escaped plain text by default**: `<b>Bold</b>` shows those
12
+ * characters literally instead of rendering as bold. Pass
13
+ * `sanitize={{ rawHtml: 'sanitize', htmlSanitizer }}` to render `label` as
14
+ * markup instead — see `ListItemSanitizeOptions.htmlSanitizer`. A consumer
15
+ * relying on the library's previous unconditional `{@html label}` behaviour
16
+ * needs that opt-in now; without it, a label that used to render as markup
17
+ * renders as visible tag text.
18
+ */
8
19
  label?: string | null;
20
+ /**
21
+ * Governs how `label` is rendered when it contains markup. Omitting this
22
+ * keeps `label` as escaped plain text. See `ListItemSanitizeOptions`.
23
+ */
24
+ sanitize?: ListItemSanitizeOptions;
9
25
  useAccordion?: boolean;
10
26
  rightContentText?: string | null;
11
27
  testId?: string;
@@ -31,6 +47,42 @@ export type ListItemProperties = ListItemEventProperties & {
31
47
  ariaSelected?: boolean;
32
48
  id?: string;
33
49
  };
50
+ /**
51
+ * `label` does not implement HTML sanitization and does not depend on a
52
+ * sanitizer — the same position `MarkdownText.htmlSanitizer` takes, and for
53
+ * the same reason: escaping everything is a provably safe default, and a
54
+ * sanitizer written into this package could only subtract from that safety
55
+ * while adding a specialist problem (mutation XSS, namespace confusion,
56
+ * parse-then-reparse divergence) to a dependency this many projects install.
57
+ */
58
+ export type ListItemSanitizeOptions = {
59
+ /**
60
+ * `'escape'` (the default) renders `label` as plain text, with `&`, `<`,
61
+ * `>`, `"` and `'` escaped. `'sanitize'` renders `label` as markup after
62
+ * passing it once through `htmlSanitizer`. `'sanitize'` with no
63
+ * `htmlSanitizer` supplied behaves exactly like `'escape'` — the unsafe
64
+ * path is unreachable by omission, not by discipline.
65
+ */
66
+ rawHtml?: 'escape' | 'sanitize';
67
+ /**
68
+ * Sanitizer consulted only while `rawHtml` is `'sanitize'`. Called once,
69
+ * with the raw `label` string, and whatever it returns is rendered as
70
+ * markup via `{@html}` — unmodified, since re-checking it would mean
71
+ * parsing and rewriting the very output this option exists to delegate.
72
+ *
73
+ * Pass DOMPurify's `sanitize`, or anything you trust. If it throws or
74
+ * returns a non-string, `label` is escaped instead; the unsanitized string
75
+ * is never emitted.
76
+ *
77
+ * It must work on the server as well as in the browser: ListItem renders
78
+ * this through `{@html}` from a `$derived`, so a sanitizer that only works
79
+ * client-side (DOMPurify without a DOM, say) throws during SSR, falls back
80
+ * to escaped output there, and succeeds on hydration — the two renders
81
+ * then disagree. Give it a server implementation, or leave `rawHtml` at
82
+ * its default on server-rendered pages.
83
+ */
84
+ htmlSanitizer?: (html: string) => string;
85
+ };
34
86
  export type ListItemEventProperties = {
35
87
  onleftimageclick?: (event: MouseEvent) => void;
36
88
  onrightimageclick?: (event: MouseEvent) => void;