@juspay/svelte-ui-components 4.27.5 → 4.28.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 +140 -36
- package/dist/Animations/ModalAnimation.svelte +15 -1
- package/dist/Animations/OverlayAnimation.svelte +7 -1
- package/dist/AreaChart/AreaChart.svelte +366 -85
- package/dist/AspectRatio/AspectRatio.svelte +51 -0
- package/dist/AspectRatio/AspectRatio.svelte.d.ts +3 -0
- package/dist/AspectRatio/properties.d.ts +27 -0
- package/dist/AspectRatio/properties.js +1 -0
- package/dist/Banner/Banner.svelte +10 -4
- package/dist/Banner/properties.d.ts +8 -0
- package/dist/BarChart/BarChart.svelte +156 -49
- package/dist/Book/Book.svelte +14 -1
- package/dist/BrandLoader/BrandLoader.svelte +76 -12
- package/dist/Button/Button.svelte +56 -11
- package/dist/Calendar/Calendar.svelte +88 -61
- package/dist/Card/Card.svelte +1 -1
- package/dist/Carousel/Carousel.svelte +6 -3
- package/dist/ChatComposer/ChatComposer.svelte +54 -4
- package/dist/ChatComposer/dictationState.d.ts +15 -0
- package/dist/ChatComposer/dictationState.js +9 -0
- package/dist/ChatComposer/properties.d.ts +28 -1
- package/dist/ChatMessage/ChatMessage.svelte +27 -1
- package/dist/CheckListItem/CheckListItem.svelte +3 -3
- package/dist/Checkbox/Checkbox.svelte +84 -3
- package/dist/Checkbox/properties.d.ts +45 -13
- package/dist/ChipInput/ChipInput.svelte +70 -3
- package/dist/ChipInput/properties.d.ts +16 -0
- package/dist/Choicebox/Choicebox.svelte +156 -1
- package/dist/Choicebox/group.d.ts +63 -0
- package/dist/Choicebox/group.js +127 -0
- package/dist/Choicebox/properties.d.ts +46 -0
- package/dist/ColorPicker/ColorPicker.svelte +169 -20
- package/dist/ColorPicker/properties.d.ts +16 -0
- package/dist/Combobox/Combobox.svelte +87 -25
- package/dist/Combobox/properties.d.ts +23 -0
- package/dist/CommandMenu/CommandMenu.svelte +27 -11
- package/dist/ContextMenu/ContextMenu.svelte +31 -37
- package/dist/DateRangePicker/DateRangePicker.svelte +171 -73
- package/dist/DeltaIndicator/DeltaIndicator.svelte +3 -3
- package/dist/Draggable/Draggable.svelte +12 -7
- package/dist/DualAxisBarChart/DualAxisBarChart.svelte +11 -4
- package/dist/EmptyState/EmptyState.svelte +1 -1
- package/dist/FileDropzoneTrigger/FileDropzoneTrigger.svelte +1 -1
- package/dist/FileInput/FileInput.svelte +50 -1
- package/dist/FileInput/properties.d.ts +7 -0
- package/dist/FunnelChart/FunnelChart.svelte +40 -12
- package/dist/FunnelChart/properties.d.ts +7 -1
- package/dist/Gallery/Gallery.svelte +20 -13
- package/dist/Gallery/properties.d.ts +8 -0
- package/dist/GridItem/GridItem.svelte +31 -1
- package/dist/HITL/HITL.svelte +35 -9
- package/dist/Icon/Icon.svelte +19 -3
- package/dist/Input/Input.svelte +24 -10
- package/dist/Input/properties.d.ts +37 -1
- package/dist/InputButton/InputButton.svelte +86 -11
- package/dist/InputButton/properties.d.ts +10 -1
- package/dist/Label/Label.svelte +54 -0
- package/dist/Label/Label.svelte.d.ts +3 -0
- package/dist/Label/properties.d.ts +26 -0
- package/dist/Label/properties.js +1 -0
- package/dist/LineChart/LineChart.svelte +385 -94
- package/dist/ListItem/ListItem.svelte +121 -15
- package/dist/Loader/Loader.svelte +22 -2
- package/dist/LoadingDots/LoadingDots.svelte +21 -0
- package/dist/MediaUpload/MediaUpload.svelte +13 -3
- package/dist/Menu/Menu.svelte +56 -28
- package/dist/Modal/Modal.svelte +77 -42
- package/dist/Pagination/Pagination.svelte +9 -2
- package/dist/Phone/Phone.svelte +4 -0
- package/dist/PieChart/PieChart.svelte +282 -38
- package/dist/PieChart/properties.d.ts +9 -0
- package/dist/Pill/Pill.svelte +14 -5
- package/dist/Progress/Progress.svelte +20 -0
- package/dist/Radio/Radio.svelte +58 -1
- package/dist/Radio/properties.d.ts +23 -0
- package/dist/RatingGroup/RatingGroup.svelte +283 -0
- package/dist/RatingGroup/RatingGroup.svelte.d.ts +4 -0
- package/dist/RatingGroup/properties.d.ts +73 -0
- package/dist/RatingGroup/properties.js +1 -0
- package/dist/RelativeTime/RelativeTime.svelte +1 -1
- package/dist/SankeyChart/SankeyChart.svelte +272 -50
- package/dist/SankeyChart/properties.d.ts +9 -0
- package/dist/Scroller/Scroller.svelte +37 -2
- package/dist/Select/Select.svelte +166 -46
- package/dist/Select/properties.d.ts +7 -0
- package/dist/Separator/Separator.svelte +48 -0
- package/dist/Separator/Separator.svelte.d.ts +3 -0
- package/dist/Separator/properties.d.ts +22 -0
- package/dist/Separator/properties.js +1 -0
- package/dist/Sheet/Sheet.svelte +60 -21
- package/dist/Shimmer/Shimmer.svelte +15 -0
- package/dist/Slider/Slider.svelte +87 -6
- package/dist/Slider/properties.d.ts +26 -0
- package/dist/Snippet/Snippet.svelte +4 -0
- package/dist/Snippet/copyState.svelte.d.ts +13 -1
- package/dist/Snippet/copyState.svelte.js +27 -1
- package/dist/Snippet/properties.d.ts +7 -0
- package/dist/SoundKit/SoundKit.js +9 -8
- package/dist/SplitInput/SplitInput.svelte +68 -2
- package/dist/SplitInput/properties.d.ts +23 -0
- package/dist/StatCard/StatCard.svelte +6 -6
- package/dist/Status/Status.svelte +2 -2
- package/dist/Stepper/Step.svelte +19 -6
- package/dist/Stepper/Stepper.svelte +108 -35
- package/dist/Table/BuiltinCell.svelte +2 -2
- package/dist/Table/Table.svelte +373 -55
- package/dist/Table/properties.d.ts +134 -3
- package/dist/Tabs/Tabs.svelte +263 -65
- package/dist/Tabs/properties.d.ts +23 -0
- package/dist/TaskList/TaskList.svelte +14 -5
- package/dist/ThemeSwitcher/ThemeSwitcher.svelte +23 -0
- package/dist/ThinkingIndicator/ThinkingIndicator.svelte +13 -5
- package/dist/Toast/Toast.svelte +27 -2
- package/dist/Toggle/Toggle.svelte +79 -7
- package/dist/Toggle/Toggle.svelte.d.ts +1 -1
- package/dist/Toggle/properties.d.ts +30 -7
- package/dist/ToolCallLog/ToolCallLog.svelte +59 -21
- package/dist/ToolCallLog/properties.d.ts +10 -0
- package/dist/Toolbar/Toolbar.svelte +1 -1
- package/dist/Tooltip/Tooltip.svelte +20 -2
- package/dist/Tooltip/tooltip-action.js +39 -4
- package/dist/TypewriterText/TypewriterText.svelte +34 -2
- package/dist/TypewriterText/properties.d.ts +18 -5
- package/dist/_chart/ChartContainer.svelte +12 -0
- package/dist/_chart/ChartTooltip.svelte +18 -4
- package/dist/_chart/Legend.svelte +21 -0
- package/dist/_chart/Legend.svelte.d.ts +15 -0
- package/dist/_chart/geometry.d.ts +54 -1
- package/dist/_chart/geometry.js +122 -14
- package/dist/_chart/interactions.d.ts +5 -1
- package/dist/_chart/interactions.js +9 -5
- package/dist/_chart/scales.js +16 -0
- package/dist/_chart/temporal.d.ts +45 -0
- package/dist/_chart/temporal.js +116 -0
- package/dist/_field/description.d.ts +50 -0
- package/dist/_field/description.js +51 -0
- package/dist/_interaction/dismissal.d.ts +67 -0
- package/dist/_interaction/dismissal.js +137 -0
- package/dist/_interaction/focus.d.ts +73 -0
- package/dist/_interaction/focus.js +143 -0
- package/dist/assets/star.svg +5 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.js +21 -1
- package/dist/reduced-motion.svelte.d.ts +37 -0
- package/dist/reduced-motion.svelte.js +44 -0
- package/dist/styles/theme-dark.css +778 -0
- package/dist/types.d.ts +4 -2
- package/dist/utils.d.ts +62 -1
- package/dist/utils.js +80 -21
- package/dist-codemod/{bin.js → codemod/bin.js} +13 -2
- package/dist-codemod/{cli.js → codemod/cli.js} +48 -3
- package/dist-codemod/codemod/migrate-audit.js +395 -0
- package/dist-codemod/codemod/migration-assets/legacy-display.css +120 -0
- package/dist-codemod/codemod/migration-assets/legacy-palette.css +106 -0
- package/dist-codemod/codemod/migration-assets/wc-display.json +492 -0
- package/dist-codemod/migrate/analyze.js +804 -0
- package/dist-codemod/migrate/chart-tooltip-selector.js +301 -0
- package/dist-wc/index.js +13992 -4323
- package/package.json +13 -6
- package/dist/Modal/focus-trap.d.ts +0 -42
- package/dist/Modal/focus-trap.js +0 -57
- /package/dist-codemod/{legacy-pairs.js → codemod/legacy-pairs.js} +0 -0
- /package/dist-codemod/{transform.js → codemod/transform.js} +0 -0
- /package/dist-codemod/{wc-children.js → codemod/wc-children.js} +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { flushSync } from 'svelte';
|
|
3
|
+
import { describeField } from '../_field/description';
|
|
3
4
|
import type { CheckboxProperties } from './properties';
|
|
4
5
|
import checkmarkSvg from '../assets/checkmark.svg?raw';
|
|
5
6
|
import minusSvg from '../assets/minus.svg?raw';
|
|
@@ -19,9 +20,37 @@
|
|
|
19
20
|
controlled = false,
|
|
20
21
|
attributes,
|
|
21
22
|
name,
|
|
22
|
-
value
|
|
23
|
+
value,
|
|
24
|
+
required = false,
|
|
25
|
+
form,
|
|
26
|
+
errorMessage,
|
|
27
|
+
infoMessage,
|
|
28
|
+
invalid = false
|
|
23
29
|
}: CheckboxProperties = $props();
|
|
24
30
|
|
|
31
|
+
/* The control and the text that explains it were never linked: a screen-reader
|
|
32
|
+
user reaching this control heard its name and nothing about why it was
|
|
33
|
+
rejected. `describeField` composes the reference from the messages that are
|
|
34
|
+
actually rendered, so aria-describedby never points at an id that is not in
|
|
35
|
+
the DOM -- which passes an attribute assertion and resolves to nothing in a
|
|
36
|
+
real reader. */
|
|
37
|
+
const fieldUid = $props.id();
|
|
38
|
+
const field = $derived(
|
|
39
|
+
describeField(fieldUid, { error: errorMessage, info: infoMessage, invalid })
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// A mixed box submits nothing and satisfies nothing, so the control behind it is
|
|
43
|
+
// unchecked while `indeterminate` holds — the native property, not a third state.
|
|
44
|
+
const submitsChecked: boolean = $derived(indeterminate ? false : checked);
|
|
45
|
+
const boxState: 'checked' | 'unchecked' | 'indeterminate' = $derived(
|
|
46
|
+
indeterminate ? 'indeterminate' : checked ? 'checked' : 'unchecked'
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
let box: HTMLSpanElement | null = $state(null);
|
|
50
|
+
// Release's own ref, kept: the native input is aria-hidden, tabindex="-1" and
|
|
51
|
+
// pointer-events: none -- a form-submission mirror that never receives a real
|
|
52
|
+
// click, so the browser never fires `change` on it either. handleClick
|
|
53
|
+
// dispatches input/change manually through this ref.
|
|
25
54
|
let nativeInputEl: HTMLInputElement | null = $state(null);
|
|
26
55
|
|
|
27
56
|
// Visible text wins over `ariaLabel`, which is what `properties.ts` has always
|
|
@@ -90,6 +119,14 @@
|
|
|
90
119
|
nativeInputEl?.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
|
|
91
120
|
}
|
|
92
121
|
|
|
122
|
+
// The form control is `tabindex="-1"`, so the browser's own "focus the invalid
|
|
123
|
+
// field" step would land on an element the user cannot see or reach. Focus the
|
|
124
|
+
// box that carries the role instead.
|
|
125
|
+
function handleInvalid(e: Event): void {
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
box?.focus();
|
|
128
|
+
}
|
|
129
|
+
|
|
93
130
|
function handleKeyDown(e: KeyboardEvent): void {
|
|
94
131
|
if (e.key === ' ' || e.key === 'Enter') {
|
|
95
132
|
e.preventDefault();
|
|
@@ -117,10 +154,14 @@
|
|
|
117
154
|
<input
|
|
118
155
|
type="checkbox"
|
|
119
156
|
class="native-checkbox"
|
|
120
|
-
{
|
|
157
|
+
checked={submitsChecked}
|
|
158
|
+
{indeterminate}
|
|
121
159
|
{disabled}
|
|
122
|
-
name={name ?? null}
|
|
123
160
|
value={value ?? null}
|
|
161
|
+
{required}
|
|
162
|
+
name={typeof name === 'string' ? name : null}
|
|
163
|
+
form={typeof form === 'string' ? form : null}
|
|
164
|
+
oninvalid={handleInvalid}
|
|
124
165
|
tabindex={-1}
|
|
125
166
|
aria-hidden="true"
|
|
126
167
|
onclick={(e: MouseEvent) => e.stopPropagation()}
|
|
@@ -129,10 +170,16 @@
|
|
|
129
170
|
testID={typeof testId === 'string' ? `${testId}-native-input` : null}
|
|
130
171
|
/>
|
|
131
172
|
<span
|
|
173
|
+
bind:this={box}
|
|
132
174
|
class="box"
|
|
133
175
|
class:checked
|
|
134
176
|
class:indeterminate
|
|
135
177
|
role="checkbox"
|
|
178
|
+
aria-describedby={field.describedBy}
|
|
179
|
+
aria-invalid={field.ariaInvalid}
|
|
180
|
+
data-state={boxState}
|
|
181
|
+
data-disabled={disabled ? '' : null}
|
|
182
|
+
aria-required={required ? 'true' : null}
|
|
136
183
|
tabindex={disabled ? -1 : 0}
|
|
137
184
|
aria-checked={indeterminate ? 'mixed' : checked}
|
|
138
185
|
aria-disabled={disabled}
|
|
@@ -163,6 +210,28 @@
|
|
|
163
210
|
<span class="label" hidden={text.length === 0}>{text}</span>
|
|
164
211
|
</label>
|
|
165
212
|
|
|
213
|
+
{#if field.showsError}
|
|
214
|
+
<div
|
|
215
|
+
id={field.errorId}
|
|
216
|
+
role="alert"
|
|
217
|
+
class="field-error"
|
|
218
|
+
data-pw={typeof testId === 'string' ? `${testId}-error-message` : null}
|
|
219
|
+
testID={typeof testId === 'string' ? `${testId}-error-message` : null}
|
|
220
|
+
>
|
|
221
|
+
{errorMessage}
|
|
222
|
+
</div>
|
|
223
|
+
{/if}
|
|
224
|
+
{#if field.showsInfo}
|
|
225
|
+
<div
|
|
226
|
+
id={field.infoId}
|
|
227
|
+
class="field-info"
|
|
228
|
+
data-pw={typeof testId === 'string' ? `${testId}-info-message` : null}
|
|
229
|
+
testID={typeof testId === 'string' ? `${testId}-info-message` : null}
|
|
230
|
+
>
|
|
231
|
+
{infoMessage}
|
|
232
|
+
</div>
|
|
233
|
+
{/if}
|
|
234
|
+
|
|
166
235
|
<style>
|
|
167
236
|
.container {
|
|
168
237
|
display: var(--checkbox-container-display, inline-flex);
|
|
@@ -241,4 +310,16 @@
|
|
|
241
310
|
font-weight: var(--checkbox-label-font-weight, 400);
|
|
242
311
|
color: var(--checkbox-label-color, #212121);
|
|
243
312
|
}
|
|
313
|
+
|
|
314
|
+
.field-error {
|
|
315
|
+
color: var(--field-error-color, #c5120a);
|
|
316
|
+
font-size: var(--field-error-font-size, 12px);
|
|
317
|
+
margin: var(--field-error-margin, 4px 0 0 0);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.field-info {
|
|
321
|
+
color: var(--field-info-color, #6b7280);
|
|
322
|
+
font-size: var(--field-info-font-size, 12px);
|
|
323
|
+
margin: var(--field-info-margin, 4px 0 0 0);
|
|
324
|
+
}
|
|
244
325
|
</style>
|
|
@@ -4,12 +4,37 @@ export type MandatoryCheckboxProperties = {
|
|
|
4
4
|
text?: string;
|
|
5
5
|
};
|
|
6
6
|
export type OptionalCheckboxProperties = {
|
|
7
|
-
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
/**
|
|
8
|
+
* Visible label. Optional, and empty by default -- matching Toggle's own `text`
|
|
9
|
+
* contract (Toggle/properties.ts) -- so a form-only checkbox
|
|
10
|
+
* (`<Checkbox name="agree" />`) does not need an empty string just to compile.
|
|
11
|
+
*
|
|
12
|
+
* It mattered beyond the type. `Checkbox` dereferences `text.length`, and the
|
|
13
|
+
* custom-element build type-checks nothing, so `<sui-checkbox name="terms">`
|
|
14
|
+
* with no `text` attribute threw during render and left an empty shadow root
|
|
15
|
+
* -- the same failure mode as the `attributes` collision, from a different
|
|
16
|
+
* cause. `Toggle` and `Radio` already declared it optional with a default.
|
|
17
|
+
*
|
|
18
|
+
* A checkbox with no visible text needs `ariaLabel` for an accessible name;
|
|
19
|
+
* see `ariaLabel` below.
|
|
20
|
+
*/
|
|
12
21
|
text?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Text shown, and announced, when the control is in error. Linked to the
|
|
24
|
+
* control through `aria-describedby`, and sets `aria-invalid` while present.
|
|
25
|
+
*/
|
|
26
|
+
errorMessage?: string | null;
|
|
27
|
+
/**
|
|
28
|
+
* Persistent helper text describing the control. Linked through
|
|
29
|
+
* `aria-describedby` too, so it is read before the user trips an error rather
|
|
30
|
+
* than only after.
|
|
31
|
+
*/
|
|
32
|
+
infoMessage?: string | null;
|
|
33
|
+
/**
|
|
34
|
+
* Marks the control invalid without supplying a message, for a consumer
|
|
35
|
+
* driving validity from a server or its own rules.
|
|
36
|
+
*/
|
|
37
|
+
invalid?: boolean;
|
|
13
38
|
checked?: boolean;
|
|
14
39
|
disabled?: boolean;
|
|
15
40
|
indeterminate?: boolean;
|
|
@@ -33,16 +58,23 @@ export type OptionalCheckboxProperties = {
|
|
|
33
58
|
* `aria-controls` to point at, or a consumer's own test attribute. Spread
|
|
34
59
|
* last, so a value here wins over the component's own `data-pw`. */
|
|
35
60
|
attributes?: Record<string, string>;
|
|
36
|
-
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
61
|
+
/** Submits the box under this name when it sits inside (or is associated with) a
|
|
62
|
+
* form. Omitted by default: with no `name` the box stays outside `FormData`,
|
|
63
|
+
* exactly as before this prop existed. Omitted from submission too when unchecked,
|
|
64
|
+
* indeterminate or disabled, which is what a native `<input type="checkbox">` does. */
|
|
40
65
|
name?: string;
|
|
41
|
-
/**
|
|
42
|
-
* checkbox default
|
|
43
|
-
* form-associate a plain "checked or not"
|
|
44
|
-
*
|
|
66
|
+
/** Value submitted while checked. Left unset, the attribute is omitted and the native
|
|
67
|
+
* checkbox's own `"on"` default is what submits -- so setting `name` alone is enough to
|
|
68
|
+
* form-associate a plain "checked or not" box, and an existing consumer's DOM is
|
|
69
|
+
* unchanged. The default is the platform's, not one this component hardcodes. */
|
|
45
70
|
value?: string;
|
|
71
|
+
/** Makes an unchecked box block submission. Invalid validation moves focus to the
|
|
72
|
+
* visible box, since the form control behind it is deliberately not a tab stop. */
|
|
73
|
+
required?: boolean;
|
|
74
|
+
/** `id` of a form elsewhere in the same document, for a box rendered outside it.
|
|
75
|
+
* Custom elements have no form association: inside `<sui-checkbox>` the control
|
|
76
|
+
* lives in a shadow root and cannot reach a form in the host document. */
|
|
77
|
+
form?: string;
|
|
46
78
|
};
|
|
47
79
|
export type CheckboxEventProperties = {
|
|
48
80
|
onclick?: (checked: boolean) => void;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { describeField } from '../_field/description';
|
|
2
3
|
import Input from '../Input/Input.svelte';
|
|
3
4
|
import Pill from '../Pill/Pill.svelte';
|
|
4
5
|
import type { ChipInputProperties } from './properties';
|
|
@@ -11,12 +12,33 @@
|
|
|
11
12
|
editable = false,
|
|
12
13
|
testId,
|
|
13
14
|
classes,
|
|
15
|
+
errorMessage,
|
|
16
|
+
infoMessage,
|
|
17
|
+
invalid = false,
|
|
14
18
|
onadd,
|
|
15
19
|
ondismiss,
|
|
16
20
|
onedit,
|
|
17
21
|
onchange
|
|
18
22
|
}: ChipInputProperties = $props();
|
|
19
23
|
|
|
24
|
+
/* The widget and the text that explains it were never linked: a screen-reader
|
|
25
|
+
user heard the draft field's name and nothing about why the last value was
|
|
26
|
+
rejected. `describeField` composes the reference from the messages that are
|
|
27
|
+
actually rendered, so aria-describedby never points at an id that is not in
|
|
28
|
+
the DOM -- which passes an attribute assertion and resolves to nothing in a
|
|
29
|
+
real reader.
|
|
30
|
+
|
|
31
|
+
It describes the GROUP rather than the draft field, because that is what the
|
|
32
|
+
message is about: "at most five tags" is a fact about the collection, not
|
|
33
|
+
about the box the next one is typed into. The role is conditional so a
|
|
34
|
+
ChipInput with no messages keeps the accessibility tree it had before this
|
|
35
|
+
prop existed -- an unnamed, undescribed group is announced noise. */
|
|
36
|
+
const fieldUid = $props.id();
|
|
37
|
+
const field = $derived(
|
|
38
|
+
describeField(fieldUid, { error: errorMessage, info: infoMessage, invalid })
|
|
39
|
+
);
|
|
40
|
+
const describedGroup = $derived(field.describedBy !== null || field.ariaInvalid !== null);
|
|
41
|
+
|
|
20
42
|
let draft = $state('');
|
|
21
43
|
|
|
22
44
|
// The chip currently swapped for an inline edit field, held by VALUE rather than by slot
|
|
@@ -131,7 +153,15 @@
|
|
|
131
153
|
}
|
|
132
154
|
</script>
|
|
133
155
|
|
|
134
|
-
<div
|
|
156
|
+
<div
|
|
157
|
+
class="chip-input {classes ?? ''}"
|
|
158
|
+
role={describedGroup ? 'group' : null}
|
|
159
|
+
aria-label={describedGroup ? ariaLabel : null}
|
|
160
|
+
aria-describedby={field.describedBy}
|
|
161
|
+
aria-invalid={field.ariaInvalid}
|
|
162
|
+
data-pw={testId}
|
|
163
|
+
testID={testId}
|
|
164
|
+
>
|
|
135
165
|
{#each values as chip, index (chip)}
|
|
136
166
|
{#if editable && editingChip === chip && !disabled}
|
|
137
167
|
<div class="chip-input-edit-wrap">
|
|
@@ -186,7 +216,41 @@
|
|
|
186
216
|
</div>
|
|
187
217
|
</div>
|
|
188
218
|
|
|
219
|
+
{#if field.showsError}
|
|
220
|
+
<div
|
|
221
|
+
id={field.errorId}
|
|
222
|
+
role="alert"
|
|
223
|
+
class="field-error"
|
|
224
|
+
data-pw={typeof testId === 'string' ? `${testId}-error-message` : null}
|
|
225
|
+
testID={typeof testId === 'string' ? `${testId}-error-message` : null}
|
|
226
|
+
>
|
|
227
|
+
{errorMessage}
|
|
228
|
+
</div>
|
|
229
|
+
{/if}
|
|
230
|
+
{#if field.showsInfo}
|
|
231
|
+
<div
|
|
232
|
+
id={field.infoId}
|
|
233
|
+
class="field-info"
|
|
234
|
+
data-pw={typeof testId === 'string' ? `${testId}-info-message` : null}
|
|
235
|
+
testID={typeof testId === 'string' ? `${testId}-info-message` : null}
|
|
236
|
+
>
|
|
237
|
+
{infoMessage}
|
|
238
|
+
</div>
|
|
239
|
+
{/if}
|
|
240
|
+
|
|
189
241
|
<style>
|
|
242
|
+
.field-error {
|
|
243
|
+
color: var(--field-error-color, #c5120a);
|
|
244
|
+
font-size: var(--field-error-font-size, 12px);
|
|
245
|
+
margin: var(--field-error-margin, 4px 0 0 0);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.field-info {
|
|
249
|
+
color: var(--field-info-color, #6b7280);
|
|
250
|
+
font-size: var(--field-info-font-size, 12px);
|
|
251
|
+
margin: var(--field-info-margin, 4px 0 0 0);
|
|
252
|
+
}
|
|
253
|
+
|
|
190
254
|
.chip-input {
|
|
191
255
|
display: flex;
|
|
192
256
|
flex-wrap: var(--chip-input-flex-wrap, wrap);
|
|
@@ -209,8 +273,11 @@
|
|
|
209
273
|
Tokens the component owns STRUCTURALLY — the draft field's inline padding, margin and
|
|
210
274
|
shadow, and its width/height — deliberately stay fixed, since those position the field among
|
|
211
275
|
the chips rather than describe how it looks. */
|
|
212
|
-
--chip-input-pill-background-default: var(
|
|
213
|
-
|
|
276
|
+
--chip-input-pill-background-default: var(
|
|
277
|
+
--pill-background,
|
|
278
|
+
var(--pill-tone-neutral-background, #e0e0e0)
|
|
279
|
+
);
|
|
280
|
+
--chip-input-pill-color-default: var(--pill-color, var(--pill-tone-neutral-color, #333333));
|
|
214
281
|
--chip-input-pill-dismiss-color-default: var(--pill-dismiss-color, currentColor);
|
|
215
282
|
--chip-input-pill-font-size-default: var(--pill-font-size, 13px);
|
|
216
283
|
--chip-input-pill-font-weight-default: var(--pill-font-weight, 500);
|
|
@@ -31,6 +31,22 @@ export type OptionalChipInputProperties = {
|
|
|
31
31
|
editable?: boolean;
|
|
32
32
|
testId?: string;
|
|
33
33
|
classes?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Text shown, and announced, when the chip collection is in error ("at most
|
|
36
|
+
* five tags"). Referenced by `aria-describedby` on the group wrapping the
|
|
37
|
+
* chips and the draft field, and sets `aria-invalid` while present.
|
|
38
|
+
*/
|
|
39
|
+
errorMessage?: string | null;
|
|
40
|
+
/**
|
|
41
|
+
* Persistent helper text describing the whole control. Referenced the same
|
|
42
|
+
* way, so it is read before the user trips an error rather than only after.
|
|
43
|
+
*/
|
|
44
|
+
infoMessage?: string | null;
|
|
45
|
+
/**
|
|
46
|
+
* Marks the control invalid without supplying a message, for a consumer
|
|
47
|
+
* driving validity from a server or its own rules.
|
|
48
|
+
*/
|
|
49
|
+
invalid?: boolean;
|
|
34
50
|
};
|
|
35
51
|
export type ChipInputEventProperties = {
|
|
36
52
|
onadd?: (value: string) => void;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { describeField } from '../_field/description';
|
|
3
|
+
import { joinChoiceboxGroup, moveWithinChoiceboxGroup, selectExclusively } from './group';
|
|
2
4
|
import type { ChoiceboxProperties } from './properties';
|
|
3
5
|
import checkmarkSvg from '../assets/checkmark.svg?raw';
|
|
4
6
|
|
|
@@ -10,9 +12,58 @@
|
|
|
10
12
|
showIndicator = true,
|
|
11
13
|
testId,
|
|
12
14
|
onclick,
|
|
13
|
-
classes
|
|
15
|
+
classes,
|
|
16
|
+
errorMessage,
|
|
17
|
+
infoMessage,
|
|
18
|
+
invalid = false,
|
|
19
|
+
name,
|
|
20
|
+
value = 'on',
|
|
21
|
+
required = false,
|
|
22
|
+
form
|
|
14
23
|
}: ChoiceboxProperties = $props();
|
|
15
24
|
|
|
25
|
+
/* The card and the text that explains it were never linked: a screen-reader
|
|
26
|
+
user reaching this control heard its name and nothing about why it was
|
|
27
|
+
rejected. `describeField` composes the reference from the messages that are
|
|
28
|
+
actually rendered, so aria-describedby never points at an id that is not in
|
|
29
|
+
the DOM -- which passes an attribute assertion and resolves to nothing in a
|
|
30
|
+
real reader. */
|
|
31
|
+
const fieldUid = $props.id();
|
|
32
|
+
const field = $derived(
|
|
33
|
+
describeField(fieldUid, { error: errorMessage, info: infoMessage, invalid })
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
/* Grouping is opt-in through `name`, so every card written before this prop
|
|
37
|
+
existed keeps behaving as an independent toggle. */
|
|
38
|
+
const groupKey = $derived(
|
|
39
|
+
mode === 'radio' && typeof name === 'string' && name !== '' ? name : null
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
let card: HTMLDivElement | null = $state(null);
|
|
43
|
+
|
|
44
|
+
/* Registers the card with its group and re-registers whenever an input the
|
|
45
|
+
group's single tab stop depends on changes. `selected` and `disabled` are
|
|
46
|
+
read HERE rather than only inside the callbacks: a closure reading them
|
|
47
|
+
later creates no dependency, and the tab stop has to move when a SIBLING's
|
|
48
|
+
selection changes, which no per-instance `$derived` can observe. */
|
|
49
|
+
function groupMembership(node: HTMLElement) {
|
|
50
|
+
const key = groupKey;
|
|
51
|
+
if (key === null) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const selectedNow = selected;
|
|
55
|
+
const disabledNow = disabled;
|
|
56
|
+
return joinChoiceboxGroup(key, {
|
|
57
|
+
element: node,
|
|
58
|
+
isSelected: () => selectedNow,
|
|
59
|
+
isDisabled: () => disabledNow,
|
|
60
|
+
setSelected: (next: boolean) => {
|
|
61
|
+
selected = next;
|
|
62
|
+
},
|
|
63
|
+
notify: (next: boolean) => onclick?.(next)
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
16
67
|
function handleClick(): void {
|
|
17
68
|
if (disabled) {
|
|
18
69
|
return;
|
|
@@ -20,32 +71,93 @@
|
|
|
20
71
|
if (mode === 'radio' && selected) {
|
|
21
72
|
return;
|
|
22
73
|
}
|
|
74
|
+
if (groupKey !== null && card !== null) {
|
|
75
|
+
// The group owns the exclusivity a native radio gets from the browser:
|
|
76
|
+
// selecting here deselects the siblings and tells each of them.
|
|
77
|
+
selectExclusively(card);
|
|
78
|
+
onclick?.(true);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
23
81
|
selected = !selected;
|
|
24
82
|
onclick?.(selected);
|
|
25
83
|
}
|
|
26
84
|
|
|
85
|
+
// The form control is `tabindex="-1"`, so the browser's own "focus the invalid
|
|
86
|
+
// field" step would land on an element the user cannot see or reach. Focus the
|
|
87
|
+
// card that carries the role instead.
|
|
88
|
+
function handleInvalid(e: Event): void {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
card?.focus();
|
|
91
|
+
}
|
|
92
|
+
|
|
27
93
|
function handleKeyDown(e: KeyboardEvent): void {
|
|
28
94
|
if (e.key === ' ' || e.key === 'Enter') {
|
|
29
95
|
e.preventDefault();
|
|
30
96
|
handleClick();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (groupKey === null || disabled || card === null) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Both axes, because a group of cards may be laid out as a row or a column
|
|
103
|
+
// and the user cannot tell which the author chose.
|
|
104
|
+
const step =
|
|
105
|
+
e.key === 'ArrowDown' || e.key === 'ArrowRight'
|
|
106
|
+
? 'next'
|
|
107
|
+
: e.key === 'ArrowUp' || e.key === 'ArrowLeft'
|
|
108
|
+
? 'previous'
|
|
109
|
+
: e.key === 'Home'
|
|
110
|
+
? 'first'
|
|
111
|
+
: e.key === 'End'
|
|
112
|
+
? 'last'
|
|
113
|
+
: null;
|
|
114
|
+
if (step === null) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (moveWithinChoiceboxGroup(card, step)) {
|
|
118
|
+
e.preventDefault();
|
|
31
119
|
}
|
|
32
120
|
}
|
|
33
121
|
</script>
|
|
34
122
|
|
|
35
123
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
124
|
+
<!-- `tabindex` is the ungrouped answer and the server-rendered baseline. A named
|
|
125
|
+
radio group has ONE tab stop rather than one per card, which depends on
|
|
126
|
+
every sibling's state and so is applied by `groupMembership` once the group
|
|
127
|
+
exists on the client. -->
|
|
36
128
|
<div
|
|
129
|
+
bind:this={card}
|
|
37
130
|
class="choicebox {classes ?? ''}"
|
|
38
131
|
class:selected
|
|
39
132
|
class:disabled
|
|
40
133
|
role={mode === 'radio' ? 'radio' : 'checkbox'}
|
|
41
134
|
aria-checked={selected}
|
|
42
135
|
aria-disabled={disabled}
|
|
136
|
+
aria-required={required ? 'true' : null}
|
|
137
|
+
aria-describedby={field.describedBy}
|
|
138
|
+
aria-invalid={field.ariaInvalid}
|
|
43
139
|
tabindex={disabled ? -1 : 0}
|
|
44
140
|
onclick={handleClick}
|
|
45
141
|
onkeydown={handleKeyDown}
|
|
46
142
|
data-pw={testId}
|
|
47
143
|
testID={testId}
|
|
144
|
+
{@attach groupMembership}
|
|
48
145
|
>
|
|
146
|
+
<input
|
|
147
|
+
type={mode === 'radio' ? 'radio' : 'checkbox'}
|
|
148
|
+
class="native-control"
|
|
149
|
+
checked={selected}
|
|
150
|
+
{disabled}
|
|
151
|
+
{value}
|
|
152
|
+
{required}
|
|
153
|
+
name={typeof name === 'string' ? name : null}
|
|
154
|
+
form={typeof form === 'string' ? form : null}
|
|
155
|
+
oninvalid={handleInvalid}
|
|
156
|
+
tabindex={-1}
|
|
157
|
+
aria-hidden="true"
|
|
158
|
+
data-pw={typeof testId === 'string' ? `${testId}-native-input` : null}
|
|
159
|
+
testID={typeof testId === 'string' ? `${testId}-native-input` : null}
|
|
160
|
+
/>
|
|
49
161
|
{#if typeof children === 'function'}
|
|
50
162
|
{@render children()}
|
|
51
163
|
{/if}
|
|
@@ -59,7 +171,50 @@
|
|
|
59
171
|
{/if}
|
|
60
172
|
</div>
|
|
61
173
|
|
|
174
|
+
{#if field.showsError}
|
|
175
|
+
<div
|
|
176
|
+
id={field.errorId}
|
|
177
|
+
role="alert"
|
|
178
|
+
class="field-error"
|
|
179
|
+
data-pw={typeof testId === 'string' ? `${testId}-error-message` : null}
|
|
180
|
+
testID={typeof testId === 'string' ? `${testId}-error-message` : null}
|
|
181
|
+
>
|
|
182
|
+
{errorMessage}
|
|
183
|
+
</div>
|
|
184
|
+
{/if}
|
|
185
|
+
{#if field.showsInfo}
|
|
186
|
+
<div
|
|
187
|
+
id={field.infoId}
|
|
188
|
+
class="field-info"
|
|
189
|
+
data-pw={typeof testId === 'string' ? `${testId}-info-message` : null}
|
|
190
|
+
testID={typeof testId === 'string' ? `${testId}-info-message` : null}
|
|
191
|
+
>
|
|
192
|
+
{infoMessage}
|
|
193
|
+
</div>
|
|
194
|
+
{/if}
|
|
195
|
+
|
|
62
196
|
<style>
|
|
197
|
+
.native-control {
|
|
198
|
+
position: absolute;
|
|
199
|
+
opacity: 0;
|
|
200
|
+
width: 0;
|
|
201
|
+
height: 0;
|
|
202
|
+
margin: 0;
|
|
203
|
+
pointer-events: none;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.field-error {
|
|
207
|
+
color: var(--field-error-color, #c5120a);
|
|
208
|
+
font-size: var(--field-error-font-size, 12px);
|
|
209
|
+
margin: var(--field-error-margin, 4px 0 0 0);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.field-info {
|
|
213
|
+
color: var(--field-info-color, #6b7280);
|
|
214
|
+
font-size: var(--field-info-font-size, 12px);
|
|
215
|
+
margin: var(--field-info-margin, 4px 0 0 0);
|
|
216
|
+
}
|
|
217
|
+
|
|
63
218
|
.choicebox {
|
|
64
219
|
display: var(--choicebox-display, flex);
|
|
65
220
|
align-items: var(--choicebox-align-items, center);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutual exclusion and arrow-key roving for `Choicebox`es that share a `name`.
|
|
3
|
+
*
|
|
4
|
+
* A native radio group is a browser behaviour rather than a component one: the
|
|
5
|
+
* browser finds the other `<input type="radio">`s carrying the same name and
|
|
6
|
+
* unchecks them. Choicebox's selection lives in a per-instance `selected` prop,
|
|
7
|
+
* so nothing was doing that job -- two cards a consumer thought of as one group
|
|
8
|
+
* could both be selected, and a keyboard user tabbed through every card instead
|
|
9
|
+
* of arrowing between them, which is not how a radio group behaves.
|
|
10
|
+
*
|
|
11
|
+
* Membership is keyed by name AND by root node, so two forms on one page that
|
|
12
|
+
* both use `name="plan"` stay separate groups and a card inside a shadow root
|
|
13
|
+
* does not join one in the document. That is narrower than the platform, which
|
|
14
|
+
* scopes a radio group to the owning form; form ownership is not readable from
|
|
15
|
+
* a card that may not be inside a form at all, and the root node is the closest
|
|
16
|
+
* boundary that always exists.
|
|
17
|
+
*
|
|
18
|
+
* The registry is imperative on purpose. Roving tabindex is a property of the
|
|
19
|
+
* WHOLE group -- the single tab stop moves when any member's selection or
|
|
20
|
+
* disabled state changes -- and a per-instance `$derived` cannot observe a
|
|
21
|
+
* sibling's props. Each member re-registers whenever its own inputs change,
|
|
22
|
+
* which is what re-runs the group-wide recalculation below.
|
|
23
|
+
*/
|
|
24
|
+
export type ChoiceboxGroupMember = {
|
|
25
|
+
readonly element: HTMLElement;
|
|
26
|
+
/**
|
|
27
|
+
* Snapshots taken when the member registered, NOT live reads: Choicebox
|
|
28
|
+
* re-registers whenever either value changes, and that re-registration is what
|
|
29
|
+
* recalculates the group. So a `refreshRoving` running in the same tick as a
|
|
30
|
+
* `setSelected` sees the pre-change answer and is corrected a moment later by
|
|
31
|
+
* the re-registration it caused.
|
|
32
|
+
*/
|
|
33
|
+
readonly isSelected: () => boolean;
|
|
34
|
+
readonly isDisabled: () => boolean;
|
|
35
|
+
/** Writes the member's own bindable `selected` prop. */
|
|
36
|
+
readonly setSelected: (next: boolean) => void;
|
|
37
|
+
/** Fires the member's `onclick`, so a group move reports like a click. */
|
|
38
|
+
readonly notify: (next: boolean) => void;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Adds a card to the group named `name`. Returns the function that removes it
|
|
42
|
+
* again; both ends recalculate the group's tab stop, so a card unmounting while
|
|
43
|
+
* it held the tab stop hands it on rather than leaving the group unreachable.
|
|
44
|
+
*/
|
|
45
|
+
export declare function joinChoiceboxGroup(name: string, member: ChoiceboxGroupMember): () => void;
|
|
46
|
+
/**
|
|
47
|
+
* Selects one card and deselects the rest, the way checking a native radio
|
|
48
|
+
* unchecks its group. Siblings are told through `setSelected` so a consumer
|
|
49
|
+
* binding `selected` on each card sees the change, and through `notify` so a
|
|
50
|
+
* consumer listening on `onclick` is not left believing a deselected card is
|
|
51
|
+
* still selected.
|
|
52
|
+
*/
|
|
53
|
+
export declare function selectExclusively(element: HTMLElement): void;
|
|
54
|
+
export type GroupStep = 'next' | 'previous' | 'first' | 'last';
|
|
55
|
+
/**
|
|
56
|
+
* Arrow-key movement inside a radio group. Moving selects, which is what a
|
|
57
|
+
* native radio group does and what makes the group usable without a mouse: the
|
|
58
|
+
* arrow keys are the only way to change the choice once Tab has entered it.
|
|
59
|
+
*
|
|
60
|
+
* Returns false when the element is not in a group, so the caller can leave the
|
|
61
|
+
* key alone rather than swallowing it.
|
|
62
|
+
*/
|
|
63
|
+
export declare function moveWithinChoiceboxGroup(element: HTMLElement, step: GroupStep): boolean;
|