@happyvertical/smrt-ui 0.51.39 → 0.52.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/AGENTS.md +14 -2
- package/README.md +257 -5
- package/dist/actions/__tests__/swipe-dismiss.test.js +69 -0
- package/dist/actions/swipe-dismiss.d.ts +42 -0
- package/dist/actions/swipe-dismiss.d.ts.map +1 -0
- package/dist/actions/swipe-dismiss.js +69 -0
- package/dist/components/calendar/Calendar.svelte +5 -0
- package/dist/components/calendar/Calendar.svelte.d.ts.map +1 -1
- package/dist/components/calendar/CalendarView.svelte +1250 -0
- package/dist/components/calendar/CalendarView.svelte.d.ts +66 -0
- package/dist/components/calendar/CalendarView.svelte.d.ts.map +1 -0
- package/dist/components/calendar/DayView.svelte +5 -0
- package/dist/components/calendar/DayView.svelte.d.ts.map +1 -1
- package/dist/components/calendar/__tests__/CalendarView.test.js +226 -0
- package/dist/components/calendar/__tests__/calendar-model.test.js +138 -0
- package/dist/components/calendar/calendar-model.d.ts +178 -0
- package/dist/components/calendar/calendar-model.d.ts.map +1 -0
- package/dist/components/calendar/calendar-model.js +362 -0
- package/dist/components/calendar/index.d.ts +12 -1
- package/dist/components/calendar/index.d.ts.map +1 -1
- package/dist/components/calendar/index.js +11 -1
- package/dist/components/data/CollectionList.svelte +23 -5
- package/dist/components/data/CollectionList.svelte.d.ts +8 -2
- package/dist/components/data/CollectionList.svelte.d.ts.map +1 -1
- package/dist/components/data/DataTable.svelte +118 -40
- package/dist/components/data/DataTable.svelte.d.ts.map +1 -1
- package/dist/components/data/DataTableController.d.ts +4 -0
- package/dist/components/data/DataTableController.d.ts.map +1 -1
- package/dist/components/data/DataTableController.js +11 -4
- package/dist/components/data/ListSortSelect.svelte +121 -0
- package/dist/components/data/ListSortSelect.svelte.d.ts +22 -0
- package/dist/components/data/ListSortSelect.svelte.d.ts.map +1 -0
- package/dist/components/data/SortableHeader.svelte +169 -0
- package/dist/components/data/SortableHeader.svelte.d.ts +30 -0
- package/dist/components/data/SortableHeader.svelte.d.ts.map +1 -0
- package/dist/components/data/__tests__/CollectionList.test.js +37 -0
- package/dist/components/data/__tests__/DataTable.test.js +76 -0
- package/dist/components/data/__tests__/DataTableController.test.js +35 -0
- package/dist/components/data/__tests__/SortableHeader.test.js +101 -0
- package/dist/components/data/__tests__/link-step-surface.test.js +172 -0
- package/dist/components/data/__tests__/list-sort-state.svelte.test.js +71 -0
- package/dist/components/data/__tests__/list-sort.test.js +91 -0
- package/dist/components/data/__tests__/surface-navigation.test.js +121 -0
- package/dist/components/data/data-surface.d.ts +1 -0
- package/dist/components/data/data-surface.d.ts.map +1 -1
- package/dist/components/data/data-surface.js +1 -0
- package/dist/components/data/index.d.ts +6 -0
- package/dist/components/data/index.d.ts.map +1 -1
- package/dist/components/data/index.js +6 -0
- package/dist/components/data/link-surface.d.ts +79 -0
- package/dist/components/data/link-surface.d.ts.map +1 -0
- package/dist/components/data/link-surface.js +155 -0
- package/dist/components/data/list-sort-state.svelte.d.ts +39 -0
- package/dist/components/data/list-sort-state.svelte.d.ts.map +1 -0
- package/dist/components/data/list-sort-state.svelte.js +47 -0
- package/dist/components/data/list-sort.d.ts +94 -0
- package/dist/components/data/list-sort.d.ts.map +1 -0
- package/dist/components/data/list-sort.js +182 -0
- package/dist/components/data/step-surface.d.ts +73 -0
- package/dist/components/data/step-surface.d.ts.map +1 -0
- package/dist/components/data/step-surface.js +177 -0
- package/dist/components/data/surface-navigation.d.ts +49 -0
- package/dist/components/data/surface-navigation.d.ts.map +1 -0
- package/dist/components/data/surface-navigation.js +100 -0
- package/dist/components/data/types.d.ts +19 -0
- package/dist/components/data/types.d.ts.map +1 -1
- package/dist/components/feedback/PhoneSheet.svelte +224 -0
- package/dist/components/feedback/PhoneSheet.svelte.d.ts +35 -0
- package/dist/components/feedback/PhoneSheet.svelte.d.ts.map +1 -0
- package/dist/components/feedback/WorkingStrip.svelte +426 -0
- package/dist/components/feedback/WorkingStrip.svelte.d.ts +37 -0
- package/dist/components/feedback/WorkingStrip.svelte.d.ts.map +1 -0
- package/dist/components/feedback/__tests__/PhoneSheet.test.js +101 -0
- package/dist/components/feedback/__tests__/WorkingStrip.test.js +172 -0
- package/dist/components/feedback/index.d.ts +4 -0
- package/dist/components/feedback/index.d.ts.map +1 -1
- package/dist/components/feedback/index.js +5 -0
- package/dist/components/feedback/working-status.d.ts +34 -0
- package/dist/components/feedback/working-status.d.ts.map +1 -0
- package/dist/components/feedback/working-status.js +5 -0
- package/dist/components/forms/Checkbox.svelte +26 -2
- package/dist/components/forms/Combobox.svelte +68 -12
- package/dist/components/forms/Combobox.svelte.d.ts +6 -0
- package/dist/components/forms/Combobox.svelte.d.ts.map +1 -1
- package/dist/components/forms/DictationButton.svelte +128 -0
- package/dist/components/forms/DictationButton.svelte.d.ts +19 -0
- package/dist/components/forms/DictationButton.svelte.d.ts.map +1 -0
- package/dist/components/forms/DictationStatus.svelte +164 -0
- package/dist/components/forms/DictationStatus.svelte.d.ts +11 -0
- package/dist/components/forms/DictationStatus.svelte.d.ts.map +1 -0
- package/dist/components/forms/Fieldset.svelte +5 -1
- package/dist/components/forms/Fieldset.svelte.d.ts +2 -0
- package/dist/components/forms/Fieldset.svelte.d.ts.map +1 -1
- package/dist/components/forms/FormActionBar.svelte +84 -0
- package/dist/components/forms/FormActionBar.svelte.d.ts +23 -0
- package/dist/components/forms/FormActionBar.svelte.d.ts.map +1 -0
- package/dist/components/forms/Input.svelte +9 -0
- package/dist/components/forms/Radio.svelte +15 -4
- package/dist/components/forms/SearchInput.svelte +207 -0
- package/dist/components/forms/SearchInput.svelte.d.ts +22 -0
- package/dist/components/forms/SearchInput.svelte.d.ts.map +1 -0
- package/dist/components/forms/Select.svelte +9 -0
- package/dist/components/forms/StagedControlReview.svelte +21 -12
- package/dist/components/forms/StagedControlReview.svelte.d.ts.map +1 -1
- package/dist/components/forms/Switch.svelte +11 -5
- package/dist/components/forms/TagsInput.svelte +8 -1
- package/dist/components/forms/Textarea.svelte +8 -0
- package/dist/components/forms/__tests__/Combobox.test.js +87 -0
- package/dist/components/forms/__tests__/ControlHitArea.test.js +73 -0
- package/dist/components/forms/__tests__/DictationButton.test.js +176 -0
- package/dist/components/forms/__tests__/Fieldset.test.js +37 -0
- package/dist/components/forms/__tests__/FormActionBar.test.js +22 -0
- package/dist/components/forms/__tests__/SearchInput.test.js +92 -0
- package/dist/components/forms/__tests__/StagedControlReview.test.js +37 -3
- package/dist/components/forms/__tests__/combobox-labels.fixture.svelte +19 -0
- package/dist/components/forms/__tests__/combobox-labels.fixture.svelte.d.ts +11 -0
- package/dist/components/forms/__tests__/combobox-labels.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/composite-registration.fixture.svelte +29 -0
- package/dist/components/forms/__tests__/composite-registration.fixture.svelte.d.ts +8 -0
- package/dist/components/forms/__tests__/composite-registration.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/control-interaction.test.js +19 -1
- package/dist/components/forms/__tests__/control-proposals.test.js +63 -0
- package/dist/components/forms/__tests__/dictation-recording.test.js +582 -0
- package/dist/components/forms/__tests__/dictation.test.js +486 -0
- package/dist/components/forms/__tests__/long-press.test.js +175 -0
- package/dist/components/forms/__tests__/place-picker.fixture.svelte +33 -0
- package/dist/components/forms/__tests__/place-picker.fixture.svelte.d.ts +12 -0
- package/dist/components/forms/__tests__/place-picker.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/audio-capture.d.ts +69 -0
- package/dist/components/forms/audio-capture.d.ts.map +1 -0
- package/dist/components/forms/audio-capture.js +193 -0
- package/dist/components/forms/control-interaction.d.ts +6 -0
- package/dist/components/forms/control-interaction.d.ts.map +1 -1
- package/dist/components/forms/control-interaction.js +12 -1
- package/dist/components/forms/control-proposals.d.ts +55 -0
- package/dist/components/forms/control-proposals.d.ts.map +1 -0
- package/dist/components/forms/control-proposals.js +188 -0
- package/dist/components/forms/dictation-transcribe.d.ts +36 -0
- package/dist/components/forms/dictation-transcribe.d.ts.map +1 -0
- package/dist/components/forms/dictation-transcribe.js +65 -0
- package/dist/components/forms/dictation.svelte.d.ts +200 -0
- package/dist/components/forms/dictation.svelte.d.ts.map +1 -0
- package/dist/components/forms/dictation.svelte.js +686 -0
- package/dist/components/forms/index.d.ts +14 -1
- package/dist/components/forms/index.d.ts.map +1 -1
- package/dist/components/forms/index.js +14 -1
- package/dist/components/forms/insert-text.d.ts +12 -0
- package/dist/components/forms/insert-text.d.ts.map +1 -0
- package/dist/components/forms/insert-text.js +38 -0
- package/dist/components/forms/long-press.d.ts +88 -0
- package/dist/components/forms/long-press.d.ts.map +1 -0
- package/dist/components/forms/long-press.js +213 -0
- package/dist/components/forms/ready-beep.d.ts +24 -0
- package/dist/components/forms/ready-beep.d.ts.map +1 -0
- package/dist/components/forms/ready-beep.js +82 -0
- package/dist/components/layout/PageHeader.svelte +151 -17
- package/dist/components/layout/PageHeader.svelte.d.ts +47 -7
- package/dist/components/layout/PageHeader.svelte.d.ts.map +1 -1
- package/dist/components/layout/__tests__/PageHeader.test.js +87 -1
- package/dist/components/layout/index.d.ts +1 -0
- package/dist/components/layout/index.d.ts.map +1 -1
- package/dist/components/layout/index.js +1 -0
- package/dist/components/layout/page-header-context.d.ts +25 -0
- package/dist/components/layout/page-header-context.d.ts.map +1 -0
- package/dist/components/layout/page-header-context.js +16 -0
- package/dist/components/nav/Tabs.svelte +241 -0
- package/dist/components/nav/Tabs.svelte.d.ts +15 -0
- package/dist/components/nav/Tabs.svelte.d.ts.map +1 -1
- package/dist/components/nav/__tests__/LinkTabs.test.js +117 -0
- package/dist/components/nav/index.d.ts +1 -0
- package/dist/components/nav/index.d.ts.map +1 -1
- package/dist/components/nav/index.js +1 -0
- package/dist/components/nav/tabs-overflow.d.ts +12 -0
- package/dist/components/nav/tabs-overflow.d.ts.map +1 -0
- package/dist/components/nav/tabs-overflow.js +17 -0
- package/dist/components/nav/types.d.ts +8 -0
- package/dist/components/nav/types.d.ts.map +1 -1
- package/dist/components/ui/Button.svelte +7 -0
- package/dist/components/ui/Card.svelte +14 -5
- package/dist/components/ui/Dropdown.svelte +55 -1
- package/dist/components/ui/Dropdown.svelte.d.ts +8 -0
- package/dist/components/ui/Dropdown.svelte.d.ts.map +1 -1
- package/dist/components/ui/IconToggle.svelte +139 -0
- package/dist/components/ui/IconToggle.svelte.d.ts +32 -0
- package/dist/components/ui/IconToggle.svelte.d.ts.map +1 -0
- package/dist/components/ui/IconToggleGroup.svelte +56 -0
- package/dist/components/ui/IconToggleGroup.svelte.d.ts +21 -0
- package/dist/components/ui/IconToggleGroup.svelte.d.ts.map +1 -0
- package/dist/components/ui/Pagination.svelte +1 -1
- package/dist/components/ui/Popover.svelte +17 -1
- package/dist/components/ui/Popover.svelte.d.ts +8 -0
- package/dist/components/ui/Popover.svelte.d.ts.map +1 -1
- package/dist/components/ui/__tests__/IconToggle.test.js +59 -0
- package/dist/components/ui/__tests__/IconToggleGroup.test.js +30 -0
- package/dist/components/ui/__tests__/Popover.test.js +44 -0
- package/dist/components/ui/__tests__/dropdown-popover-icon.test.js +125 -0
- package/dist/components/ui/__tests__/phone-touch-targets.test.js +39 -0
- package/dist/components/ui/index.d.ts +2 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +2 -0
- package/dist/i18n/strings.ui.d.ts +41 -0
- package/dist/i18n/strings.ui.d.ts.map +1 -1
- package/dist/i18n/strings.ui.js +46 -0
- package/dist/svelte/__tests__/playground.test.js +1 -0
- package/dist/svelte/playground/CalendarPreview.svelte +140 -0
- package/dist/svelte/playground/CalendarPreview.svelte.d.ts +4 -0
- package/dist/svelte/playground/CalendarPreview.svelte.d.ts.map +1 -0
- package/dist/svelte/playground.d.ts.map +1 -1
- package/dist/svelte/playground.js +10 -0
- package/dist/types-generic.d.ts +2 -0
- package/dist/types-generic.d.ts.map +1 -1
- package/package.json +3 -3
|
@@ -83,18 +83,27 @@ function formatValue(value: unknown): string {
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
86
|
+
/**
|
|
87
|
+
* The words a person sees for a control: its label, or — for a control that
|
|
88
|
+
* registered none — its id made readable (`site_name` → "Site name"). The
|
|
89
|
+
* form id, subject type/id, and other registry addressing never show.
|
|
90
|
+
*/
|
|
91
|
+
function plainLabel(snapshot: ControlSnapshot): string {
|
|
92
|
+
const label = snapshot.metadata.label?.trim();
|
|
93
|
+
if (label) return label;
|
|
94
|
+
const tail = snapshot.identity.controlId.split('/').at(-1) ?? '';
|
|
95
|
+
const words = tail
|
|
96
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
97
|
+
.replace(/[-_.:]+/g, ' ')
|
|
98
|
+
.trim()
|
|
99
|
+
.toLowerCase();
|
|
100
|
+
return words ? words[0].toUpperCase() + words.slice(1) : tail;
|
|
91
101
|
}
|
|
92
102
|
|
|
93
103
|
function actionIdentity(snapshot: ControlSnapshot): string {
|
|
94
|
-
const label = snapshot
|
|
104
|
+
const label = plainLabel(snapshot);
|
|
95
105
|
const sameLabel = snapshots.filter(
|
|
96
|
-
(candidate) =>
|
|
97
|
-
(candidate.metadata.label ?? candidate.identity.controlId) === label,
|
|
106
|
+
(candidate) => plainLabel(candidate) === label,
|
|
98
107
|
);
|
|
99
108
|
if (sameLabel.length < 2) return label;
|
|
100
109
|
|
|
@@ -541,11 +550,11 @@ async function discardAll(event: MouseEvent): Promise<void> {
|
|
|
541
550
|
<ul>
|
|
542
551
|
{#each snapshots as snapshot (keyOf(snapshot))}
|
|
543
552
|
{@const staged = snapshot.state.staged}
|
|
544
|
-
{@const label = snapshot
|
|
553
|
+
{@const label = plainLabel(snapshot)}
|
|
545
554
|
{@const actionName = actionIdentity(snapshot)}
|
|
546
555
|
<li data-staged-review-item class:stale={staged?.stale} class:invalid={staged?.valid === false}>
|
|
547
556
|
<div class="proposal-heading">
|
|
548
|
-
<div><strong>{label}</strong
|
|
557
|
+
<div><strong>{label}</strong>{#if snapshot.identity.subject?.label}<span class="subject">{snapshot.identity.subject.label}</span>{/if}</div>
|
|
549
558
|
<span>{text.proposedBy} {staged?.provenance.actorId ?? staged?.provenance.source} · {text.stagedAt} {staged ? new Date(staged.stagedAt).toLocaleString() : ''}</span>
|
|
550
559
|
</div>
|
|
551
560
|
<dl>
|
|
@@ -620,8 +629,8 @@ async function discardAll(event: MouseEvent): Promise<void> {
|
|
|
620
629
|
header, .proposal-heading, .batch-actions, .item-actions { display: flex; align-items: center; gap: var(--smrt-spacing-2, .5rem); }
|
|
621
630
|
header { justify-content: space-between; align-items: flex-start; gap: var(--smrt-spacing-4, 1rem); }
|
|
622
631
|
h2, p { margin: 0; } h2 { font: var(--smrt-typography-title-medium-font, 600 1rem/1.4 system-ui); }
|
|
623
|
-
header p, .proposal-heading span,
|
|
624
|
-
.proposal-heading strong, .proposal-heading
|
|
632
|
+
header p, .proposal-heading span, dt { color: var(--smrt-color-on-surface-variant, #475569); font-size: .875rem; }
|
|
633
|
+
.proposal-heading strong, .proposal-heading .subject { display: block; }
|
|
625
634
|
ul { display: grid; gap: var(--smrt-spacing-3, .75rem); padding: 0; list-style: none; }
|
|
626
635
|
li { padding: var(--smrt-spacing-3, .75rem); background: var(--smrt-color-surface, #fff); border: 1px solid var(--smrt-color-outline-variant, #cbd5e1); border-radius: var(--smrt-radius-small, .375rem); }
|
|
627
636
|
li.stale, li.invalid { border-color: var(--smrt-color-error, #b3261e); }
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"StagedControlReview.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/StagedControlReview.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAEV,0BAA0B,EAE3B,MAAM,0BAA0B,CAAC;AAKlC,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAG5E,MAAM,WAAW,KAAK;IACpB,gEAAgE;IAChE,QAAQ,EAAE,0BAA0B,CAAC;IACrC,8CAA8C;IAC9C,MAAM,EAAE,MAAM,CAAC;IACf,mFAAmF;IACnF,WAAW,CAAC,EAAE,eAAe,GAAG,IAAI,CAAC;IACrC,2DAA2D;IAC3D,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,uDAAuD;IACvD,MAAM,CAAC,EAAE,OAAO,CAAC,yBAAyB,CAAC,CAAC;CAC7C;
|
|
1
|
+
{"version":3,"file":"StagedControlReview.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/StagedControlReview.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAEV,0BAA0B,EAE3B,MAAM,0BAA0B,CAAC;AAKlC,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAG5E,MAAM,WAAW,KAAK;IACpB,gEAAgE;IAChE,QAAQ,EAAE,0BAA0B,CAAC;IACrC,8CAA8C;IAC9C,MAAM,EAAE,MAAM,CAAC;IACf,mFAAmF;IACnF,WAAW,CAAC,EAAE,eAAe,GAAG,IAAI,CAAC;IACrC,2DAA2D;IAC3D,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,uDAAuD;IACvD,MAAM,CAAC,EAAE,OAAO,CAAC,yBAAyB,CAAC,CAAC;CAC7C;AA2kBD,QAAA,MAAM,mBAAmB,2CAAwC,CAAC;AAClE,KAAK,mBAAmB,GAAG,UAAU,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAClE,eAAe,mBAAmB,CAAC"}
|
|
@@ -113,25 +113,31 @@ useControlRegistration(() => {
|
|
|
113
113
|
});
|
|
114
114
|
</script>
|
|
115
115
|
|
|
116
|
-
<label class="switch switch--{size} {className}" class:switch--disabled={disabled} data-smrt-control={controlId} data-smrt-form={interactionContext?.formId}
|
|
116
|
+
<label class="switch switch--{size} {className}" class:switch--disabled={disabled} data-smrt-hit-target data-smrt-control={controlId} data-smrt-form={interactionContext?.formId}
|
|
117
117
|
data-smrt-subject-type={resolvedInteraction === false ? undefined : resolvedInteraction.subject?.type}
|
|
118
118
|
data-smrt-subject-id={resolvedInteraction === false ? undefined : resolvedInteraction.subject?.id}>
|
|
119
|
-
{#if label && labelPosition === 'left'}<span>{label}</span>{/if}
|
|
119
|
+
{#if label && labelPosition === 'left'}<span class="switch__label">{label}</span>{/if}
|
|
120
120
|
<span class="switch__control">
|
|
121
121
|
<input bind:this={inputEl} id={resolvedId} type="checkbox" role="switch" {name} {value} {disabled} {required} checked={checked}
|
|
122
122
|
aria-label={ariaLabel ?? label ?? formGroup?.().label} aria-describedby={formGroup?.().describedBy}
|
|
123
123
|
aria-invalid={formGroup?.().invalid ? 'true' : undefined} onchange={handleChange} {...rest} />
|
|
124
124
|
<span class="switch__track" aria-hidden="true"><span class="switch__thumb"></span></span>
|
|
125
125
|
</span>
|
|
126
|
-
{#if label && labelPosition === 'right'}<span>{label}</span>{/if}
|
|
126
|
+
{#if label && labelPosition === 'right'}<span class="switch__label">{label}</span>{/if}
|
|
127
127
|
</label>
|
|
128
128
|
|
|
129
129
|
<style>
|
|
130
130
|
.switch { display: inline-flex; align-items: center; gap: var(--smrt-spacing-2, .5rem); cursor: pointer; color: var(--smrt-color-on-surface); }
|
|
131
131
|
.switch--disabled { opacity: .5; cursor: not-allowed; }
|
|
132
|
-
|
|
132
|
+
/* Phones: the whole row (track and label) is at least a 44px touch target. */
|
|
133
|
+
@media (max-width: 48rem) { .switch { min-block-size: 2.75rem; } }
|
|
134
|
+
/* The 2.75rem-wide track gets a transparent 44px-tall ::before hit area (see Checkbox); the
|
|
135
|
+
track and label sit above every hit area. --smrt-control-hit-size resizes it (0px = off). */
|
|
136
|
+
.switch__control { --_hit: max(var(--smrt-control-hit-size, 2.75rem), 1.5rem); position: relative; display: inline-flex; }
|
|
137
|
+
.switch__control::before { content: ''; position: absolute; z-index: 0; inset-block-start: 50%; inset-inline: calc((100% - var(--_hit)) / 2); block-size: var(--_hit); margin-block-start: calc(var(--_hit) / -2); }
|
|
138
|
+
.switch__label { position: relative; z-index: 1; }
|
|
133
139
|
input { position: absolute; width: 1px; height: 1px; opacity: 0; }
|
|
134
|
-
.switch__track { width: 2.75rem; height: 1.5rem; padding: 2px; border-radius: var(--smrt-radius-full); background: var(--smrt-color-surface-container-highest); border: 1px solid var(--smrt-color-outline); transition: background var(--smrt-duration-short2); }
|
|
140
|
+
.switch__track { position: relative; z-index: 1; width: 2.75rem; height: 1.5rem; padding: 2px; border-radius: var(--smrt-radius-full); background: var(--smrt-color-surface-container-highest); border: 1px solid var(--smrt-color-outline); transition: background var(--smrt-duration-short2); }
|
|
135
141
|
.switch__thumb { display: block; width: 1.125rem; height: 1.125rem; border-radius: 50%; background: var(--smrt-color-outline); transition: transform var(--smrt-duration-short2), background var(--smrt-duration-short2); }
|
|
136
142
|
input:checked + .switch__track { background: var(--smrt-color-primary); border-color: var(--smrt-color-primary); }
|
|
137
143
|
input:checked + .switch__track .switch__thumb { transform: translateX(1.25rem); background: var(--smrt-color-on-primary); }
|
|
@@ -139,6 +139,13 @@ useControlRegistration(() => {
|
|
|
139
139
|
.tags-field { display: grid; gap: var(--smrt-spacing-1); color: var(--smrt-color-on-surface); } label { font: var(--smrt-typography-label-large-font); }
|
|
140
140
|
.tags { display: flex; flex-wrap: wrap; gap: var(--smrt-spacing-1); min-height: 2.5rem; padding: var(--smrt-spacing-1); border: 1px solid var(--smrt-color-outline); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface); }
|
|
141
141
|
.tag { display: inline-flex; align-items: center; gap: var(--smrt-spacing-1); padding: var(--smrt-spacing-1) var(--smrt-spacing-2); border-radius: var(--smrt-radius-full); background: var(--smrt-color-secondary-container); color: var(--smrt-color-on-secondary-container); }
|
|
142
|
-
.tag button {
|
|
142
|
+
.tag button { display: inline-flex; align-items: center; justify-content: center; border: 0; background: transparent; color: inherit; cursor: pointer; }
|
|
143
|
+
/* Phones and touch screens: the × is a 44px target (the tag grows with it). */
|
|
144
|
+
@media (max-width: 48rem), (pointer: coarse) {
|
|
145
|
+
.tag { padding-inline-end: 0; }
|
|
146
|
+
.tag button { min-inline-size: 2.75rem; min-block-size: 2.75rem; font-size: 1.25rem; }
|
|
147
|
+
}
|
|
148
|
+
.tag button:focus-visible { outline: 2px solid var(--smrt-color-primary); outline-offset: -2px; border-radius: var(--smrt-radius-full); }
|
|
149
|
+
.tags > input { flex: 1; min-width: 8rem; border: 0; outline: 0; background: transparent; color: inherit; }
|
|
143
150
|
:global(.tags[data-smrt-highlighted='true']) { outline: 3px solid var(--smrt-color-tertiary); outline-offset: 4px; }
|
|
144
151
|
</style>
|
|
@@ -181,6 +181,14 @@ export function getElement(): HTMLTextAreaElement | null {
|
|
|
181
181
|
color: var(--smrt-color-on-surface-variant, #9ca3af);
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
/* Phones: 16px text so iOS Safari doesn't zoom the page when the field
|
|
185
|
+
gets focus. */
|
|
186
|
+
@media (max-width: 48rem) {
|
|
187
|
+
.textarea {
|
|
188
|
+
font-size: max(var(--smrt-typography-body-medium-size, 0.875rem), 16px);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
184
192
|
@media (prefers-reduced-motion: reduce) {
|
|
185
193
|
.textarea {
|
|
186
194
|
transition: none;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/svelte';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { tick } from 'svelte';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import Fixture from './combobox-labels.fixture.svelte';
|
|
6
|
+
const towns = [
|
|
7
|
+
{ value: '0b9c-uuid-lacombe', label: 'Lacombe' },
|
|
8
|
+
{ value: '77aa-uuid-blackfalds', label: 'Blackfalds' },
|
|
9
|
+
{ value: '12cd-uuid-ponoka', label: 'Ponoka' },
|
|
10
|
+
];
|
|
11
|
+
describe('Combobox labels and reopening', () => {
|
|
12
|
+
it('shows the option label on first render, never the raw id', () => {
|
|
13
|
+
render(Fixture, {
|
|
14
|
+
props: { options: towns, value: '77aa-uuid-blackfalds' },
|
|
15
|
+
});
|
|
16
|
+
expect(screen.getByRole('combobox', { name: 'Town' })).toHaveValue('Blackfalds');
|
|
17
|
+
});
|
|
18
|
+
it('shows valueLabel (or nothing) until async options arrive, then the label', async () => {
|
|
19
|
+
const view = render(Fixture, {
|
|
20
|
+
props: { options: [], value: '12cd-uuid-ponoka', valueLabel: 'Ponoka' },
|
|
21
|
+
});
|
|
22
|
+
const input = screen.getByRole('combobox', { name: 'Town' });
|
|
23
|
+
expect(input).toHaveValue('Ponoka');
|
|
24
|
+
await view.rerender({
|
|
25
|
+
options: [],
|
|
26
|
+
value: '12cd-uuid-ponoka',
|
|
27
|
+
valueLabel: undefined,
|
|
28
|
+
});
|
|
29
|
+
await tick();
|
|
30
|
+
expect(input).toHaveValue('');
|
|
31
|
+
expect(input).not.toHaveValue('12cd-uuid-ponoka');
|
|
32
|
+
await view.rerender({ options: towns, value: '12cd-uuid-ponoka' });
|
|
33
|
+
await tick();
|
|
34
|
+
expect(input).toHaveValue('Ponoka');
|
|
35
|
+
});
|
|
36
|
+
it('reopening after a choice lists every option, not only the current one', async () => {
|
|
37
|
+
render(Fixture, { props: { options: towns, value: '' } });
|
|
38
|
+
const input = screen.getByRole('combobox', { name: 'Town' });
|
|
39
|
+
await userEvent.type(input, 'lacom');
|
|
40
|
+
expect(screen.getAllByRole('option')).toHaveLength(1);
|
|
41
|
+
await userEvent.click(screen.getByRole('option', { name: 'Lacombe' }));
|
|
42
|
+
expect(screen.getByTestId('value')).toHaveTextContent('0b9c-uuid-lacombe');
|
|
43
|
+
expect(input).toHaveValue('Lacombe');
|
|
44
|
+
await userEvent.click(document.body);
|
|
45
|
+
await userEvent.click(input);
|
|
46
|
+
expect(screen.getAllByRole('option')).toHaveLength(3);
|
|
47
|
+
expect(screen.getByRole('option', { name: 'Lacombe' })).toHaveAttribute('aria-selected', 'true');
|
|
48
|
+
});
|
|
49
|
+
it('puts the chosen label back when the person leaves without choosing', async () => {
|
|
50
|
+
render(Fixture, {
|
|
51
|
+
props: { options: towns, value: '77aa-uuid-blackfalds' },
|
|
52
|
+
});
|
|
53
|
+
const input = screen.getByRole('combobox', { name: 'Town' });
|
|
54
|
+
await userEvent.clear(input);
|
|
55
|
+
await userEvent.type(input, 'pon');
|
|
56
|
+
await userEvent.keyboard('{Escape}');
|
|
57
|
+
expect(input).toHaveValue('Blackfalds');
|
|
58
|
+
expect(screen.getByTestId('value')).toHaveTextContent('77aa-uuid-blackfalds');
|
|
59
|
+
});
|
|
60
|
+
it('posts the committed id under its name, not the visible label', async () => {
|
|
61
|
+
const { container } = render(Fixture, {
|
|
62
|
+
props: { options: towns, value: '77aa-uuid-blackfalds' },
|
|
63
|
+
});
|
|
64
|
+
const input = screen.getByRole('combobox', { name: 'Town' });
|
|
65
|
+
expect(input).not.toHaveAttribute('name');
|
|
66
|
+
const posted = container.querySelectorAll('input[name="town"]');
|
|
67
|
+
expect(posted).toHaveLength(1);
|
|
68
|
+
expect(posted[0]).toHaveValue('77aa-uuid-blackfalds');
|
|
69
|
+
await userEvent.click(input);
|
|
70
|
+
await userEvent.click(screen.getByRole('option', { name: 'Ponoka' }));
|
|
71
|
+
expect(container.querySelector('input[name="town"]')).toHaveValue('12cd-uuid-ponoka');
|
|
72
|
+
});
|
|
73
|
+
it('a required combobox is valid while its value is committed, even before its option loads', async () => {
|
|
74
|
+
const view = render(Fixture, {
|
|
75
|
+
props: { options: [], value: '12cd-uuid-ponoka', required: true },
|
|
76
|
+
});
|
|
77
|
+
const input = screen.getByRole('combobox', {
|
|
78
|
+
name: 'Town',
|
|
79
|
+
});
|
|
80
|
+
expect(input).toHaveValue('');
|
|
81
|
+
expect(input.checkValidity()).toBe(true);
|
|
82
|
+
expect(input).toHaveAttribute('aria-required', 'true');
|
|
83
|
+
await view.rerender({ options: towns, value: '', required: true });
|
|
84
|
+
await tick();
|
|
85
|
+
expect(input.checkValidity()).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkbox, Radio and Switch carry an invisible 44x44 hit area around the
|
|
3
|
+
* visible control. jsdom does no layout, so the geometry is asserted against
|
|
4
|
+
* the component source (the contract) and the click behavior against the DOM;
|
|
5
|
+
* the measured result is verified in a real browser.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, resolve } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { render, screen } from '@testing-library/svelte';
|
|
11
|
+
import userEvent from '@testing-library/user-event';
|
|
12
|
+
import { describe, expect, it } from 'vitest';
|
|
13
|
+
import Checkbox from '../Checkbox.svelte';
|
|
14
|
+
import Switch from '../Switch.svelte';
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const source = (name) => readFileSync(resolve(here, '..', `${name}.svelte`), 'utf8');
|
|
17
|
+
describe.each(['Checkbox', 'Radio', 'Switch'])('%s hit area', (name) => {
|
|
18
|
+
const css = source(name).slice(source(name).indexOf('<style>'));
|
|
19
|
+
it('draws a 44px (2.75rem) transparent ::before target that adds no layout', () => {
|
|
20
|
+
expect(css).toContain('--smrt-control-hit-size, 2.75rem');
|
|
21
|
+
expect(css).toMatch(/::before \{[^}]*position: absolute/);
|
|
22
|
+
expect(css).toMatch(/::before \{[^}]*content: ''/);
|
|
23
|
+
});
|
|
24
|
+
it('keeps the visible control and its label above every hit area', () => {
|
|
25
|
+
expect(css).toMatch(/__(box|mark|track)[^{]*\{[^}]*z-index: 1/);
|
|
26
|
+
expect(css).toMatch(/__label[^{]*\{[^}]*z-index: 1/);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe.each(['Checkbox', 'Radio'])('%s in table cells', (name) => {
|
|
30
|
+
it('never grows horizontally out of the cell', () => {
|
|
31
|
+
expect(source(name)).toMatch(/:global\(:is\(td, th\)\) \.(checkbox|radio)::before \{ inline-size: var\(--_box\)/);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe.each(['Checkbox', 'Radio'])('%s beside other content', (name) => {
|
|
35
|
+
it('keeps to its own column unless it stands alone, so it never covers a neighbouring link or text', () => {
|
|
36
|
+
expect(source(name)).toMatch(/\.(checkbox|radio):not\(:only-child\)::before \{ inline-size: var\(--_box\); margin-inline-start: calc\(var\(--_box\) \/ -2\)/);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe('Checkbox table cell fill', () => {
|
|
40
|
+
it('uses the whole cell, clipped to it, when the checkbox is its only content', () => {
|
|
41
|
+
const css = source('Checkbox');
|
|
42
|
+
expect(css).toContain('> [data-smrt-hit-target]:only-child');
|
|
43
|
+
expect(css).toMatch(/::before \{ inset: 0;/);
|
|
44
|
+
});
|
|
45
|
+
it('positions the cell at zero specificity, so sticky headers and pinned cells keep theirs', () => {
|
|
46
|
+
const css = source('Checkbox');
|
|
47
|
+
expect(css).toContain(':global(:where(td:has(> [data-smrt-hit-target]:only-child), th:has(> [data-smrt-hit-target]:only-child))) { position: relative; }');
|
|
48
|
+
expect(css).not.toMatch(/:global\(:is\(td, th\):has\([^)]*\)\)\) \{ position: relative/);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('hit area behavior', () => {
|
|
52
|
+
it('Checkbox toggles from a click on its label element', async () => {
|
|
53
|
+
const user = userEvent.setup();
|
|
54
|
+
const { container } = render(Checkbox, { props: { label: 'Accept' } });
|
|
55
|
+
expect(container.querySelector('label')).toHaveAttribute('data-smrt-hit-target');
|
|
56
|
+
await user.click(container.querySelector('label'));
|
|
57
|
+
expect(screen.getByRole('checkbox', { name: 'Accept' })).toBeChecked();
|
|
58
|
+
});
|
|
59
|
+
it('Checkbox without text keeps the label as the click target', async () => {
|
|
60
|
+
const user = userEvent.setup();
|
|
61
|
+
const { container } = render(Checkbox, {
|
|
62
|
+
props: { 'aria-label': 'Select row' },
|
|
63
|
+
});
|
|
64
|
+
await user.click(container.querySelector('label'));
|
|
65
|
+
expect(screen.getByRole('checkbox', { name: 'Select row' })).toBeChecked();
|
|
66
|
+
});
|
|
67
|
+
it('Switch toggles from a click on its label element', async () => {
|
|
68
|
+
const user = userEvent.setup();
|
|
69
|
+
const { container } = render(Switch, { props: { label: 'Alerts' } });
|
|
70
|
+
await user.click(container.querySelector('label'));
|
|
71
|
+
expect(screen.getByRole('switch', { name: 'Alerts' })).toBeChecked();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DictationButton + DictationStatus: a named 44px mic toggle with
|
|
3
|
+
* aria-pressed, and plain-words status for listening and each error.
|
|
4
|
+
*/
|
|
5
|
+
import { render, screen } from '@testing-library/svelte';
|
|
6
|
+
import userEvent from '@testing-library/user-event';
|
|
7
|
+
import { flushSync } from 'svelte';
|
|
8
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import { expectNoA11yViolations } from '../../../test-support/a11y';
|
|
10
|
+
import { DictationError } from '../audio-capture.js';
|
|
11
|
+
import DictationButton from '../DictationButton.svelte';
|
|
12
|
+
import DictationStatus from '../DictationStatus.svelte';
|
|
13
|
+
import { Dictation } from '../dictation.svelte.js';
|
|
14
|
+
function quietSource() {
|
|
15
|
+
const ends = new Set();
|
|
16
|
+
return {
|
|
17
|
+
start: async () => { },
|
|
18
|
+
stop: async () => {
|
|
19
|
+
for (const cb of ends)
|
|
20
|
+
cb();
|
|
21
|
+
},
|
|
22
|
+
onResult: () => () => { },
|
|
23
|
+
onError: () => () => { },
|
|
24
|
+
onEnd: (cb) => (ends.add(cb), () => ends.delete(cb)),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
describe('DictationButton', () => {
|
|
28
|
+
it('toggles listening with a name that follows the state', async () => {
|
|
29
|
+
const dictation = new Dictation({
|
|
30
|
+
source: quietSource,
|
|
31
|
+
onText: () => { },
|
|
32
|
+
beep: false,
|
|
33
|
+
});
|
|
34
|
+
const { container } = render(DictationButton, { props: { dictation } });
|
|
35
|
+
const button = screen.getByRole('button', {
|
|
36
|
+
name: 'Speak instead of typing',
|
|
37
|
+
});
|
|
38
|
+
expect(button).toHaveAttribute('aria-pressed', 'false');
|
|
39
|
+
await expectNoA11yViolations(container);
|
|
40
|
+
await userEvent.click(button);
|
|
41
|
+
await vi.waitFor(() => expect(dictation.state).toBe('listening'));
|
|
42
|
+
flushSync();
|
|
43
|
+
const stop = screen.getByRole('button', { name: 'Stop listening' });
|
|
44
|
+
expect(stop).toHaveAttribute('aria-pressed', 'true');
|
|
45
|
+
await userEvent.click(stop);
|
|
46
|
+
await vi.waitFor(() => expect(dictation.state).toBe('idle'));
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('DictationButton — one click, one toggle', () => {
|
|
50
|
+
it('a single click starts once and does not stop again', async () => {
|
|
51
|
+
const start = vi.fn(async () => { });
|
|
52
|
+
const stop = vi.fn(async () => { });
|
|
53
|
+
const source = {
|
|
54
|
+
start,
|
|
55
|
+
stop,
|
|
56
|
+
onResult: () => () => { },
|
|
57
|
+
onError: () => () => { },
|
|
58
|
+
onEnd: () => () => { },
|
|
59
|
+
};
|
|
60
|
+
const dictation = new Dictation({
|
|
61
|
+
source: () => source,
|
|
62
|
+
onText: () => { },
|
|
63
|
+
beep: false,
|
|
64
|
+
});
|
|
65
|
+
render(DictationButton, { props: { dictation } });
|
|
66
|
+
// userEvent.click sends pointerdown, pointerup and click.
|
|
67
|
+
await userEvent.click(screen.getByRole('button', { name: 'Speak instead of typing' }));
|
|
68
|
+
await vi.waitFor(() => expect(dictation.state).toBe('listening'));
|
|
69
|
+
expect(start).toHaveBeenCalledTimes(1);
|
|
70
|
+
expect(stop).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe('DictationStatus', () => {
|
|
74
|
+
function erroringSource(code) {
|
|
75
|
+
const errors = new Set();
|
|
76
|
+
return {
|
|
77
|
+
start: async () => {
|
|
78
|
+
queueMicrotask(() => {
|
|
79
|
+
for (const cb of errors)
|
|
80
|
+
cb(Object.assign(new Error(code), { speechError: code }));
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
stop: async () => { },
|
|
84
|
+
onResult: () => () => { },
|
|
85
|
+
onError: (cb) => (errors.add(cb), () => errors.delete(cb)),
|
|
86
|
+
onEnd: () => () => { },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
it.each([
|
|
90
|
+
[
|
|
91
|
+
'network',
|
|
92
|
+
"Speech recognition isn't available in this browser. Brave blocks it",
|
|
93
|
+
],
|
|
94
|
+
[
|
|
95
|
+
'service-not-allowed',
|
|
96
|
+
"Speech recognition isn't available in this browser",
|
|
97
|
+
],
|
|
98
|
+
['not-allowed', 'The microphone is blocked'],
|
|
99
|
+
['no-speech', "Didn't hear anything. Tap the mic and try again."],
|
|
100
|
+
['audio-capture', "Couldn't use the microphone"],
|
|
101
|
+
['aborted', 'Listening stopped straight away'],
|
|
102
|
+
])('shows a plain message for %s instead of "Listening"', async (code, text) => {
|
|
103
|
+
const source = erroringSource(code);
|
|
104
|
+
const dictation = new Dictation({
|
|
105
|
+
source: () => source,
|
|
106
|
+
onText: () => { },
|
|
107
|
+
beep: false,
|
|
108
|
+
log: () => { },
|
|
109
|
+
});
|
|
110
|
+
const { container } = render(DictationStatus, { props: { dictation } });
|
|
111
|
+
await dictation.start();
|
|
112
|
+
await vi.waitFor(() => expect(dictation.state).toBe('error'));
|
|
113
|
+
flushSync();
|
|
114
|
+
expect(screen.getByRole('alert')).toHaveTextContent(text);
|
|
115
|
+
expect(screen.getByRole('alert')).toHaveAttribute('data-dictation-error', code);
|
|
116
|
+
expect(screen.getByRole('status')).not.toHaveTextContent('Listening');
|
|
117
|
+
expect(container.querySelector('[data-dictation-error]')).not.toBeNull();
|
|
118
|
+
});
|
|
119
|
+
it('says it is listening, then explains a blocked microphone', async () => {
|
|
120
|
+
const dictation = new Dictation({
|
|
121
|
+
source: quietSource,
|
|
122
|
+
onText: () => { },
|
|
123
|
+
beep: false,
|
|
124
|
+
});
|
|
125
|
+
render(DictationStatus, { props: { dictation } });
|
|
126
|
+
await dictation.start();
|
|
127
|
+
flushSync();
|
|
128
|
+
expect(screen.getByRole('status')).toHaveTextContent('Listening. Tap the microphone when you are done.');
|
|
129
|
+
const blocked = new Dictation({ source: null, onText: () => { } });
|
|
130
|
+
render(DictationStatus, { props: { dictation: blocked } });
|
|
131
|
+
await blocked.start();
|
|
132
|
+
flushSync();
|
|
133
|
+
expect(screen.getAllByRole('alert').at(-1)).toHaveTextContent("Speech recognition isn't available in this browser");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
describe('Dictation recording fallback in words', () => {
|
|
137
|
+
it('says "Writing it down…" while a recording is written down, then explains a failure', async () => {
|
|
138
|
+
let finish;
|
|
139
|
+
const dictation = new Dictation({
|
|
140
|
+
onText: () => { },
|
|
141
|
+
beep: false,
|
|
142
|
+
log: () => { },
|
|
143
|
+
capture: () => ({
|
|
144
|
+
start: async () => { },
|
|
145
|
+
stop: async () => ({
|
|
146
|
+
audio: new Blob(['x'], { type: 'audio/webm' }),
|
|
147
|
+
mimeType: 'audio/webm',
|
|
148
|
+
durationMs: 1000,
|
|
149
|
+
reachedTimeLimit: false,
|
|
150
|
+
}),
|
|
151
|
+
cancel: () => { },
|
|
152
|
+
onLimit: () => () => { },
|
|
153
|
+
}),
|
|
154
|
+
transcribe: () => new Promise((_resolve, reject) => (finish = reject)),
|
|
155
|
+
});
|
|
156
|
+
const { container } = render(DictationStatus, { props: { dictation } });
|
|
157
|
+
render(DictationButton, { props: { dictation } });
|
|
158
|
+
await dictation.start();
|
|
159
|
+
flushSync();
|
|
160
|
+
expect(screen.getByRole('status')).toHaveTextContent('Listening');
|
|
161
|
+
void dictation.stop();
|
|
162
|
+
await vi.waitFor(() => expect(finish).toBeTypeOf('function'));
|
|
163
|
+
flushSync();
|
|
164
|
+
expect(screen.getByRole('status')).toHaveTextContent('Writing it down…');
|
|
165
|
+
const button = screen.getByRole('button', {
|
|
166
|
+
name: 'Speak instead of typing',
|
|
167
|
+
});
|
|
168
|
+
expect(button).toBeDisabled();
|
|
169
|
+
expect(button).toHaveAttribute('aria-busy', 'true');
|
|
170
|
+
await expectNoA11yViolations(container);
|
|
171
|
+
finish(new DictationError('unavailable', 'not set up'));
|
|
172
|
+
await vi.waitFor(() => expect(dictation.state).toBe('error'));
|
|
173
|
+
flushSync();
|
|
174
|
+
expect(screen.getByRole('alert')).toHaveTextContent("Speaking isn't set up here yet. Please type instead.");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/svelte';
|
|
2
|
+
import { createRawSnippet } from 'svelte';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { expectNoA11yViolations } from '../../../test-support/a11y';
|
|
5
|
+
import Fieldset from '../Fieldset.svelte';
|
|
6
|
+
const fields = createRawSnippet(() => ({
|
|
7
|
+
render: () => '<label>Headline <input name="headline" /></label>',
|
|
8
|
+
}));
|
|
9
|
+
describe('Fieldset', () => {
|
|
10
|
+
it('is a group named by its legend', () => {
|
|
11
|
+
render(Fieldset, { props: { legend: 'The ad', children: fields } });
|
|
12
|
+
expect(screen.getByRole('group', { name: 'The ad' })).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
it('flows its content unless asked to stack it', () => {
|
|
15
|
+
const { container } = render(Fieldset, {
|
|
16
|
+
props: { legend: 'The ad', children: fields },
|
|
17
|
+
});
|
|
18
|
+
expect(container.querySelector('.content--stack')).toBeNull();
|
|
19
|
+
});
|
|
20
|
+
it('stacks its fields in one column with a gap when asked', () => {
|
|
21
|
+
const { container } = render(Fieldset, {
|
|
22
|
+
props: { legend: 'The ad', stack: true, children: fields },
|
|
23
|
+
});
|
|
24
|
+
expect(container.querySelector('.content.content--stack')).not.toBeNull();
|
|
25
|
+
});
|
|
26
|
+
it('is axe-clean', async () => {
|
|
27
|
+
const { container } = render(Fieldset, {
|
|
28
|
+
props: {
|
|
29
|
+
legend: 'The ad',
|
|
30
|
+
description: 'Shown to readers',
|
|
31
|
+
stack: true,
|
|
32
|
+
children: fields,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
await expectNoA11yViolations(container);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/svelte';
|
|
2
|
+
import { createRawSnippet } from 'svelte';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import FormActionBar from '../FormActionBar.svelte';
|
|
5
|
+
const actions = createRawSnippet(() => ({
|
|
6
|
+
render: () => '<span><button type="button">Cancel</button><button type="submit">Publish</button></span>',
|
|
7
|
+
}));
|
|
8
|
+
describe('FormActionBar', () => {
|
|
9
|
+
it('is a labelled group carrying the shell contract attribute', () => {
|
|
10
|
+
render(FormActionBar, {
|
|
11
|
+
props: { label: 'Video actions', children: actions },
|
|
12
|
+
});
|
|
13
|
+
const group = screen.getByRole('group', { name: 'Video actions' });
|
|
14
|
+
expect(group.hasAttribute('data-form-action-bar')).toBe(true);
|
|
15
|
+
expect(screen.getByRole('button', { name: 'Publish' })).toBeInTheDocument();
|
|
16
|
+
});
|
|
17
|
+
it('defaults its accessible name and supports the plain variant', () => {
|
|
18
|
+
render(FormActionBar, { props: { plain: true, children: actions } });
|
|
19
|
+
const group = screen.getByRole('group', { name: 'Form actions' });
|
|
20
|
+
expect(group.classList.contains('plain')).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/svelte';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { expectNoA11yViolations } from '../../../test-support/a11y';
|
|
5
|
+
import SearchInput from '../SearchInput.svelte';
|
|
6
|
+
describe('SearchInput', () => {
|
|
7
|
+
beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
|
|
8
|
+
afterEach(() => vi.useRealTimers());
|
|
9
|
+
it('is a labelled search field in a search landmark', () => {
|
|
10
|
+
render(SearchInput, {
|
|
11
|
+
props: { value: '', onsearch: vi.fn(), placeholder: 'Search by title' },
|
|
12
|
+
});
|
|
13
|
+
expect(screen.getByRole('search')).toBeInTheDocument();
|
|
14
|
+
expect(screen.getByRole('searchbox', { name: 'Search' })).toHaveAttribute('placeholder', 'Search by title');
|
|
15
|
+
});
|
|
16
|
+
it('searches once, trimmed and single-line, after the last key', async () => {
|
|
17
|
+
const onsearch = vi.fn();
|
|
18
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
19
|
+
render(SearchInput, { props: { value: '', onsearch, debounceMs: 300 } });
|
|
20
|
+
await user.type(screen.getByRole('searchbox'), ' council hall ');
|
|
21
|
+
expect(onsearch).not.toHaveBeenCalled();
|
|
22
|
+
vi.advanceTimersByTime(300);
|
|
23
|
+
expect(onsearch).toHaveBeenCalledTimes(1);
|
|
24
|
+
expect(onsearch).toHaveBeenCalledWith('council hall');
|
|
25
|
+
});
|
|
26
|
+
it('searches at once on Enter', async () => {
|
|
27
|
+
const onsearch = vi.fn();
|
|
28
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
29
|
+
render(SearchInput, { props: { value: '', onsearch } });
|
|
30
|
+
await user.type(screen.getByRole('searchbox'), 'roads{Enter}');
|
|
31
|
+
expect(onsearch).toHaveBeenCalledWith('roads');
|
|
32
|
+
});
|
|
33
|
+
it('does not search for the search it is already showing', async () => {
|
|
34
|
+
const onsearch = vi.fn();
|
|
35
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
36
|
+
render(SearchInput, { props: { value: 'roads', onsearch } });
|
|
37
|
+
await user.type(screen.getByRole('searchbox'), '{Enter}');
|
|
38
|
+
expect(onsearch).not.toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
it('clears with the x and focuses the field', async () => {
|
|
41
|
+
const onsearch = vi.fn();
|
|
42
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
43
|
+
render(SearchInput, { props: { value: 'roads', onsearch } });
|
|
44
|
+
const field = screen.getByRole('searchbox');
|
|
45
|
+
expect(field).toHaveValue('roads');
|
|
46
|
+
await user.click(screen.getByRole('button', { name: 'Clear search' }));
|
|
47
|
+
expect(onsearch).toHaveBeenCalledWith('');
|
|
48
|
+
expect(field).toHaveValue('');
|
|
49
|
+
expect(field).toHaveFocus();
|
|
50
|
+
expect(screen.queryByRole('button', { name: 'Clear search' })).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
it('follows the applied search when it changes from outside', async () => {
|
|
53
|
+
const { rerender } = render(SearchInput, {
|
|
54
|
+
props: { value: 'roads', onsearch: vi.fn() },
|
|
55
|
+
});
|
|
56
|
+
await rerender({ value: 'parks', onsearch: vi.fn() });
|
|
57
|
+
expect(screen.getByRole('searchbox')).toHaveValue('parks');
|
|
58
|
+
});
|
|
59
|
+
it('inside a host form, Enter searches and never submits the host form', async () => {
|
|
60
|
+
const onsearch = vi.fn();
|
|
61
|
+
const onsubmit = vi.fn((event) => event.preventDefault());
|
|
62
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
63
|
+
const host = document.createElement('form');
|
|
64
|
+
host.addEventListener('submit', onsubmit);
|
|
65
|
+
document.body.append(host);
|
|
66
|
+
render(SearchInput, { target: host, props: { value: '', onsearch } });
|
|
67
|
+
expect(host.querySelector('form')).toBeNull();
|
|
68
|
+
await user.type(screen.getByRole('searchbox'), 'roads{Enter}');
|
|
69
|
+
expect(onsearch).toHaveBeenCalledWith('roads');
|
|
70
|
+
expect(onsubmit).not.toHaveBeenCalled();
|
|
71
|
+
host.remove();
|
|
72
|
+
});
|
|
73
|
+
it('a value the host normalized differently does not swallow a later one', async () => {
|
|
74
|
+
const onsearch = vi.fn();
|
|
75
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
76
|
+
const view = render(SearchInput, { props: { value: '', onsearch } });
|
|
77
|
+
await user.type(screen.getByRole('searchbox'), 'Roads{Enter}');
|
|
78
|
+
expect(onsearch).toHaveBeenCalledWith('Roads');
|
|
79
|
+
// The host applies its own normalization, then later navigates to the
|
|
80
|
+
// exact text the box once sent: the box must follow both.
|
|
81
|
+
await view.rerender({ value: 'roads', onsearch });
|
|
82
|
+
expect(screen.getByRole('searchbox')).toHaveValue('roads');
|
|
83
|
+
await view.rerender({ value: 'Roads', onsearch });
|
|
84
|
+
expect(screen.getByRole('searchbox')).toHaveValue('Roads');
|
|
85
|
+
});
|
|
86
|
+
it('is axe-clean', async () => {
|
|
87
|
+
const { container } = render(SearchInput, {
|
|
88
|
+
props: { value: 'roads', onsearch: vi.fn() },
|
|
89
|
+
});
|
|
90
|
+
await expectNoA11yViolations(container);
|
|
91
|
+
});
|
|
92
|
+
});
|