@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.
- package/README.md +15 -14
- package/dist/Accordion/Accordion.svelte +18 -0
- package/dist/AnimatedNumber/AnimatedNumber.svelte +702 -0
- package/dist/AnimatedNumber/AnimatedNumber.svelte.d.ts +4 -0
- package/dist/AnimatedNumber/motion.d.ts +47 -0
- package/dist/AnimatedNumber/motion.js +190 -0
- package/dist/AnimatedNumber/properties.d.ts +42 -0
- package/dist/AnimatedNumber/properties.js +1 -0
- package/dist/Badge/Badge.svelte +20 -3
- package/dist/Badge/properties.d.ts +9 -0
- package/dist/Book/Book.svelte +9 -0
- package/dist/Calendar/Calendar.svelte +34 -2
- package/dist/Carousel/Carousel.svelte +17 -2
- package/dist/ChatBubble/ChatBubble.svelte +12 -0
- package/dist/ChatComposer/ChatComposer.svelte +9 -0
- package/dist/CommandMenu/CommandMenu.svelte +13 -0
- package/dist/DateRangePicker/DateRangePicker.svelte +77 -20
- package/dist/DeltaIndicator/DeltaIndicator.svelte +12 -1
- package/dist/DeltaIndicator/properties.d.ts +9 -0
- package/dist/Gallery/Gallery.svelte +16 -0
- package/dist/Gauge/Gauge.svelte +21 -2
- package/dist/Gauge/properties.d.ts +13 -0
- package/dist/Input/Input.svelte +34 -26
- package/dist/KeyboardInput/KeyboardInput.svelte +1 -1
- package/dist/ListItem/ListItem.svelte +27 -2
- package/dist/ListItem/label.d.ts +11 -0
- package/dist/ListItem/label.js +40 -0
- package/dist/ListItem/properties.d.ts +52 -0
- package/dist/MediaUpload/MediaUpload.svelte +12 -1
- package/dist/MediaUpload/properties.d.ts +9 -0
- package/dist/Menu/Menu.svelte +11 -2
- package/dist/Modal/Modal.svelte +14 -4
- package/dist/PieChart/PieChart.svelte +16 -1
- package/dist/PieChart/properties.d.ts +13 -0
- package/dist/Progress/Progress.svelte +21 -1
- package/dist/Progress/properties.d.ts +12 -0
- package/dist/ProportionBar/ProportionBar.svelte +7 -1
- package/dist/ProportionBar/ProportionBar.svelte.d.ts +0 -19
- package/dist/ProportionBar/properties.d.ts +10 -0
- package/dist/Select/Select.svelte +23 -8
- package/dist/SplitButton/SplitButton.svelte +4 -1
- package/dist/SplitInput/SplitInput.svelte +1 -1
- package/dist/StatCard/StatCard.svelte +22 -4
- package/dist/StatCard/properties.d.ts +12 -0
- package/dist/Table/BuiltinCell.svelte +19 -2
- package/dist/Table/cellData.js +3 -0
- package/dist/Table/properties.d.ts +15 -0
- package/dist/Tabs/Tabs.svelte +16 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/styles/theme-dark.css +27 -3
- package/dist-codemod/codemod/migration-assets/legacy-display.css +2 -1
- package/dist-codemod/codemod/migration-assets/legacy-palette.css +4 -1
- package/dist-codemod/codemod/migration-assets/wc-display.json +5 -0
- package/dist-wc/index.js +1334 -754
- package/package.json +9 -3
|
@@ -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={
|
|
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={
|
|
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(
|
|
1076
|
+
{@render drpFieldError(
|
|
1077
|
+
startDateField.errorId,
|
|
1078
|
+
startDateField.showsError,
|
|
1079
|
+
DATE_HINT
|
|
1080
|
+
)}
|
|
1068
1081
|
</div>
|
|
1069
|
-
{@render drpFieldError(
|
|
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={
|
|
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={
|
|
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(
|
|
1126
|
+
{@render drpFieldError(
|
|
1127
|
+
endDateField.errorId,
|
|
1128
|
+
endDateField.showsError,
|
|
1129
|
+
DATE_HINT
|
|
1130
|
+
)}
|
|
1110
1131
|
</div>
|
|
1111
|
-
{@render drpFieldError(
|
|
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={
|
|
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(
|
|
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={
|
|
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(
|
|
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={
|
|
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(
|
|
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={
|
|
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(
|
|
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">
|
|
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;
|
package/dist/Gauge/Gauge.svelte
CHANGED
|
@@ -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
|
|
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">
|
|
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
|
package/dist/Input/Input.svelte
CHANGED
|
@@ -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
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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={
|
|
340
|
-
aria-describedby={describedBy
|
|
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={
|
|
378
|
-
aria-describedby={describedBy
|
|
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
|
|
440
|
+
{#if field.showsError}
|
|
442
441
|
<div
|
|
443
|
-
id={
|
|
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
|
|
451
|
+
{#if field.showsInfo}
|
|
453
452
|
<div
|
|
454
|
-
id={
|
|
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
|
|
517
|
+
border: var(--input-focus-border, 1px solid var(--input-focus-border-color, #005fcc));
|
|
510
518
|
}
|
|
511
519
|
|
|
512
520
|
.input-error {
|
|
@@ -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
|
-
<!--
|
|
186
|
-
|
|
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, '&')
|
|
4
|
+
.replace(/</g, '<')
|
|
5
|
+
.replace(/>/g, '>')
|
|
6
|
+
.replace(/"/g, '"')
|
|
7
|
+
.replace(/'/g, ''');
|
|
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;
|