@motion-proto/live-tokens 0.98.2 → 0.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/bin/contractRunner.mjs +2 -2
  3. package/bin/rules/a11y.mjs +1 -1
  4. package/dist-plugin/index.cjs +71 -15
  5. package/dist-plugin/index.js +71 -15
  6. package/package.json +1 -1
  7. package/src/editor/bootstrap.ts +3 -1
  8. package/src/editor/component-editor/SectionDividerEditor.svelte +9 -10
  9. package/src/editor/core/a11y/a11yReport.ts +85 -22
  10. package/src/editor/core/a11y/a11yReportTypes.ts +11 -3
  11. package/src/editor/core/a11y/contrastFixes.ts +41 -0
  12. package/src/editor/core/palettes/contrastPairs.ts +4 -1
  13. package/src/editor/docs/content/accessibility.md +67 -18
  14. package/src/editor/docs/content/editing-tokens.md +4 -0
  15. package/src/editor/docs/content.generated.ts +2 -2
  16. package/src/editor/pages/ComponentEditorPage.svelte +4 -3
  17. package/src/editor/pages/EditorShell.svelte +10 -8
  18. package/src/editor/styles/ui-editor.css +2 -0
  19. package/src/editor/ui/a11y/A11yByEye.svelte +264 -0
  20. package/src/editor/ui/a11y/A11yComponentIssues.svelte +34 -16
  21. package/src/editor/ui/a11y/A11yFindingRow.svelte +66 -4
  22. package/src/editor/ui/a11y/A11yIssueRow.svelte +55 -24
  23. package/src/editor/ui/a11y/A11yOverview.svelte +72 -68
  24. package/src/editor/ui/a11y/A11yPanel.svelte +29 -4
  25. package/src/editor/ui/a11y/A11yPropertyMark.svelte +8 -8
  26. package/src/editor/ui/a11y/A11yRailMark.svelte +11 -4
  27. package/src/editor/ui/a11y/AccessibilityTab.svelte +22 -35
  28. package/src/editor/ui/a11y/a11yView.ts +50 -11
  29. package/src/live-tokens/data/colors-and-type/halloween.json +1 -1
  30. package/src/live-tokens/data/themes/autumn.json +16 -16
  31. package/src/live-tokens/data/themes/halloween.json +17 -17
  32. package/src/live-tokens/data/themes/midnight-study.json +16 -16
  33. package/src/live-tokens/data/themes/ocean.json +16 -16
  34. package/src/live-tokens/data/themes/royal-velvet.json +16 -16
  35. package/src/live-tokens/data/themes/sketchy.json +16 -16
  36. package/src/live-tokens/data/themes/spring-meadow.json +16 -16
  37. package/src/live-tokens/data/themes/sunset.json +16 -16
  38. package/src/system/components/Button.svelte +3 -3
  39. package/src/system/components/InlineEditActions.svelte +1 -1
  40. package/src/system/components/Input.svelte +1 -1
  41. package/src/system/components/MenuSelect.svelte +1 -1
  42. package/src/system/components/ProgressBar.svelte +1 -1
  43. package/src/system/components/SlidePager.svelte +1 -1
  44. package/src/system/components/Slider.svelte +6 -6
  45. package/src/system/components/TabBar.svelte +1 -1
  46. package/src/system/components/Toggle.svelte +1 -1
  47. package/src/testing-js/{chunk-RWH46IU5.js → chunk-7QXUJRSO.js} +2 -2
  48. package/src/testing-js/chunk-GZKUYLDW.js +193 -0
  49. package/src/testing-js/chunk-GZKUYLDW.js.map +1 -0
  50. package/src/testing-js/{chunk-SHLPDNAQ.js → chunk-RTZQLW3F.js} +172 -181
  51. package/src/testing-js/chunk-RTZQLW3F.js.map +1 -0
  52. package/src/testing-js/{chunk-5HNZHIHG.js → chunk-RWOT2XDB.js} +2 -2
  53. package/src/testing-js/{chunk-5HNZHIHG.js.map → chunk-RWOT2XDB.js.map} +1 -1
  54. package/src/testing-js/{chunk-R54I7YL6.js → chunk-THWGMSRH.js} +2 -2
  55. package/src/testing-js/component-a11y.contract.js +53 -23
  56. package/src/testing-js/component-a11y.contract.js.map +1 -1
  57. package/src/testing-js/component-behavior.contract.js +1 -1
  58. package/src/testing-js/component-editor.contract.js +3 -3
  59. package/src/testing-js/component-render.contract.js +3 -3
  60. package/src/testing-js/index.js +4 -4
  61. package/src/testing-js/index.js.map +1 -1
  62. package/src/testing-js/page-compliance.contract.js +3 -3
  63. package/src/testing-js/vitest.js +2 -2
  64. package/template/_gitignore +3 -0
  65. package/src/testing-js/chunk-EWSGE27V.js +0 -168
  66. package/src/testing-js/chunk-EWSGE27V.js.map +0 -1
  67. package/src/testing-js/chunk-SHLPDNAQ.js.map +0 -1
  68. /package/src/testing-js/{chunk-RWH46IU5.js.map → chunk-7QXUJRSO.js.map} +0 -0
  69. /package/src/testing-js/{chunk-R54I7YL6.js.map → chunk-THWGMSRH.js.map} +0 -0
@@ -14,6 +14,7 @@
14
14
  A11Y_REVIEW_SECTION,
15
15
  A11Y_RUN_SECTION,
16
16
  a11ySectionId,
17
+ componentSections,
17
18
  componentsWithIssues,
18
19
  issuesLabel,
19
20
  setA11yViewLink,
@@ -135,12 +136,13 @@
135
136
  let screenBarHeight = $state(0);
136
137
 
137
138
  let a11yComponentNavItems: NavItem[] = $derived(
138
- componentsWithIssues($a11yIssues).map(({ component, label, icon, count }) => ({
139
+ componentSections($a11yIssues).map(({ component, label, icon, count }) => ({
139
140
  id: a11ySectionId(component), label, icon, count,
140
141
  })),
141
142
  );
142
- let a11yMarked = $derived(new Set(componentsWithIssues($a11yIssues).map((row) => row.component)));
143
- let a11yHasReview = $derived(Object.values($a11yIssues).some((issues) => issues.review.length > 0));
143
+ /** Each marked component, true when its only results are to check by eye. A result marked as looking right marks nothing. */
144
+ let a11yMarked = $derived(new Map(componentsWithIssues($a11yIssues).map((row) => [row.component, row.count === 0])));
145
+ let a11yHasReview = $derived(Object.values($a11yIssues).some((issues) => issues.review.length + issues.confirmed.length > 0));
144
146
 
145
147
  // The narrow layout shows one section at a time, so the run section and the
146
148
  // check-by-eye line join the component sections the rail lists.
@@ -255,7 +257,7 @@
255
257
  onclick={() => pickSection(item.id)}
256
258
  >
257
259
  <i class={item.icon}></i>
258
- <span class="nav-label">{#if $editorView === 'components' && a11yMarked.has(item.id)}<A11yRailMark />{/if}{item.label}</span>
260
+ <span class="nav-label">{#if $editorView === 'components' && a11yMarked.has(item.id)}<A11yRailMark review={a11yMarked.get(item.id)} />{/if}{item.label}</span>
259
261
  {#if item.count !== undefined}
260
262
  <span class="nav-count">{item.count}</span>
261
263
  {/if}
@@ -382,12 +384,12 @@
382
384
  onclick={() => selectComponent(item.id)}
383
385
  >
384
386
  <i class={item.icon}></i>
385
- <span class="nav-label">{#if a11yMarked.has(item.id)}<A11yRailMark />{/if}{item.label}</span>
387
+ <span class="nav-label">{#if a11yMarked.has(item.id)}<A11yRailMark review={a11yMarked.get(item.id)} />{/if}{item.label}</span>
386
388
  {#if $componentDirty[item.id]}
387
389
  <span class="dirty-dot" aria-label="Unsaved changes" title="Unsaved changes"></span>
388
390
  {/if}
389
391
  {#if condensed && a11yMarked.has(item.id)}
390
- <A11yRailMark placement="corner" />
392
+ <A11yRailMark placement="corner" review={a11yMarked.get(item.id)} />
391
393
  {/if}
392
394
  </button>
393
395
  {/each}
@@ -405,12 +407,12 @@
405
407
  onclick={() => selectComponent(item.id)}
406
408
  >
407
409
  <i class={item.icon}></i>
408
- <span class="nav-label">{#if a11yMarked.has(item.id)}<A11yRailMark />{/if}{item.label}</span>
410
+ <span class="nav-label">{#if a11yMarked.has(item.id)}<A11yRailMark review={a11yMarked.get(item.id)} />{/if}{item.label}</span>
409
411
  {#if $componentDirty[item.id]}
410
412
  <span class="dirty-dot" aria-label="Unsaved changes" title="Unsaved changes"></span>
411
413
  {/if}
412
414
  {#if condensed && a11yMarked.has(item.id)}
413
- <A11yRailMark placement="corner" />
415
+ <A11yRailMark placement="corner" review={a11yMarked.get(item.id)} />
414
416
  {/if}
415
417
  </button>
416
418
  {/each}
@@ -49,6 +49,8 @@
49
49
 
50
50
  /* Shared "this is live" amber: selection, focus, dirty state. */
51
51
  --ui-highlight: #ffac28;
52
+ /* An accessibility failure the editor now measures as passing. */
53
+ --ui-fixed: #5fd38d;
52
54
 
53
55
  /* Link-state bar: active = attached to control edge, broken = popped into column gap. */
54
56
  --ui-link-active: #6cd0c2;
@@ -0,0 +1,264 @@
1
+ <script lang="ts">
2
+ import { tick } from 'svelte';
3
+ import { a11yIssues, confirmReview } from '../../core/a11y/a11yReport';
4
+ import UIPillButton from '../UIPillButton.svelte';
5
+ import A11yFindingRow from './A11yFindingRow.svelte';
6
+ import { A11Y_REVIEW_SECTION, openComponentAt, reviewResults, type A11yReviewResult } from './a11yView';
7
+
8
+ const titleId = $props.id();
9
+
10
+ let results = $derived(reviewResults($a11yIssues));
11
+ let open = $derived(results.filter((r) => !r.confirmed));
12
+ let confirmed = $derived(results.filter((r) => r.confirmed));
13
+ let tally = $derived(
14
+ [open.length > 0 ? `${open.length} to check` : '', confirmed.length > 0 ? `${confirmed.length} look right` : '']
15
+ .filter(Boolean)
16
+ .join(', '),
17
+ );
18
+ let done = $derived(results.length > 0 ? confirmed.length / results.length : 0);
19
+
20
+ let openList: HTMLUListElement | undefined = $state();
21
+ let confirmedList: HTMLUListElement | undefined = $state();
22
+ let heading: HTMLElement | undefined = $state();
23
+ let markingAll = $state(false);
24
+ let markAllError: string | null = $state(null);
25
+
26
+ const keyOf = (r: A11yReviewResult) => `${r.component}\n${r.row.findings.map((f) => f.message).join('\n')}`;
27
+
28
+ /** Moves focus to the toggle that took the pressed row's place, so Enter or Space walks the list. */
29
+ async function focusAt(list: HTMLUListElement | undefined, index: number) {
30
+ await tick();
31
+ const toggles = [...(list?.querySelectorAll<HTMLButtonElement>('.a11y-confirm button') ?? [])];
32
+ (toggles[index] ?? toggles[index - 1] ?? heading)?.focus();
33
+ }
34
+
35
+ async function markAll() {
36
+ markingAll = true;
37
+ markAllError = null;
38
+ try {
39
+ await confirmReview(open.flatMap((r) => r.row.findings), true);
40
+ await focusAt(undefined, 0);
41
+ } catch (err) {
42
+ markAllError = (err as Error).message;
43
+ } finally {
44
+ markingAll = false;
45
+ }
46
+ }
47
+ </script>
48
+
49
+ <section id={A11Y_REVIEW_SECTION} class="editor-section-card a11y-byeye" aria-labelledby={titleId}>
50
+ <header class="a11y-byeye-head">
51
+ <i class="fa-solid fa-eye a11y-byeye-mark" aria-hidden="true"></i>
52
+ <h3 id={titleId} class="editor-subsection-title" tabindex="-1" bind:this={heading}>Check by eye</h3>
53
+ <span class="a11y-byeye-tally">{tally}</span>
54
+ {#if open.length > 0}
55
+ <UIPillButton size="compact" variant="outline" icon="fa-check-double" disabled={markingAll} onclick={markAll}>
56
+ Mark all as looking right
57
+ </UIPillButton>
58
+ {/if}
59
+ </header>
60
+
61
+ <div
62
+ class="a11y-byeye-meter"
63
+ role="progressbar"
64
+ aria-label="Results marked as looking right"
65
+ aria-valuemin={0}
66
+ aria-valuemax={results.length}
67
+ aria-valuenow={confirmed.length}
68
+ >
69
+ <span class="a11y-byeye-meter-fill" style:transform={`scaleX(${done})`}></span>
70
+ </div>
71
+
72
+ {#if markAllError}
73
+ <p class="a11y-byeye-error" role="alert">{markAllError}</p>
74
+ {/if}
75
+
76
+ {#if open.length > 0}
77
+ <p class="a11y-byeye-lead">
78
+ The tools could not decide these. Judge each element against its help, then press <strong>Looks right</strong>.
79
+ The next result moves up under the same button.
80
+ </p>
81
+ <ul class="a11y-byeye-list" bind:this={openList}>
82
+ {#each open as result, i (keyOf(result))}
83
+ <A11yFindingRow
84
+ component={result.component}
85
+ row={result.row}
86
+ review
87
+ prefix={result.label}
88
+ onreveal={(variable) => openComponentAt(result.component, variable)}
89
+ onopen={() => openComponentAt(result.component)}
90
+ onconfirmchange={() => focusAt(openList, i)}
91
+ />
92
+ {/each}
93
+ </ul>
94
+ {:else}
95
+ <p class="a11y-byeye-clear">
96
+ <i class="fa-solid fa-circle-check" aria-hidden="true"></i>
97
+ Every result looks right. Open <strong>Looks right</strong> below to check one again.
98
+ </p>
99
+ {/if}
100
+
101
+ {#if confirmed.length > 0}
102
+ <details class="a11y-byeye-done">
103
+ <summary class="a11y-byeye-group">
104
+ <i class="fa-solid fa-chevron-right a11y-byeye-chevron" aria-hidden="true"></i>
105
+ Looks right
106
+ <span class="a11y-byeye-group-count">{confirmed.length}</span>
107
+ </summary>
108
+ <ul class="a11y-byeye-list" bind:this={confirmedList}>
109
+ {#each confirmed as result, i (keyOf(result))}
110
+ <A11yFindingRow
111
+ component={result.component}
112
+ row={result.row}
113
+ review
114
+ confirmed
115
+ prefix={result.label}
116
+ onreveal={(variable) => openComponentAt(result.component, variable)}
117
+ onopen={() => openComponentAt(result.component)}
118
+ onconfirmchange={() => focusAt(confirmedList, i)}
119
+ />
120
+ {/each}
121
+ </ul>
122
+ </details>
123
+ {/if}
124
+ </section>
125
+
126
+ <style>
127
+ .a11y-byeye {
128
+ gap: var(--ui-space-12);
129
+ }
130
+
131
+ .a11y-byeye-head {
132
+ display: flex;
133
+ flex-wrap: wrap;
134
+ align-items: center;
135
+ gap: var(--ui-space-8) var(--ui-space-12);
136
+ color: var(--ui-text-primary);
137
+ }
138
+
139
+ .a11y-byeye-head :global(.ui-pill) {
140
+ margin-left: auto;
141
+ }
142
+
143
+ .a11y-byeye-head h3:focus {
144
+ outline: none;
145
+ }
146
+
147
+ .a11y-byeye-mark {
148
+ color: var(--ui-text-tertiary);
149
+ }
150
+
151
+ .a11y-byeye-tally {
152
+ font-size: var(--ui-font-size-sm);
153
+ color: var(--ui-text-tertiary);
154
+ font-variant-numeric: tabular-nums;
155
+ }
156
+
157
+ .a11y-byeye-meter {
158
+ height: 2px;
159
+ overflow: hidden;
160
+ background: var(--ui-border-low);
161
+ border-radius: var(--ui-radius-full);
162
+ }
163
+
164
+ .a11y-byeye-meter-fill {
165
+ display: block;
166
+ height: 100%;
167
+ background: var(--ui-text-primary);
168
+ transform-origin: left;
169
+ transition: transform 240ms ease;
170
+ }
171
+
172
+ @media (prefers-reduced-motion: reduce) {
173
+ .a11y-byeye-meter-fill {
174
+ transition: none;
175
+ }
176
+ }
177
+
178
+ .a11y-byeye-lead,
179
+ .a11y-byeye-clear,
180
+ .a11y-byeye-error {
181
+ margin: 0;
182
+ font-size: var(--ui-font-size-sm);
183
+ }
184
+
185
+ .a11y-byeye-lead {
186
+ color: var(--ui-text-tertiary);
187
+ }
188
+
189
+ .a11y-byeye-lead strong,
190
+ .a11y-byeye-clear strong {
191
+ font-weight: var(--ui-font-weight-semibold);
192
+ color: var(--ui-text-secondary);
193
+ }
194
+
195
+ .a11y-byeye-clear {
196
+ display: flex;
197
+ align-items: center;
198
+ gap: var(--ui-space-8);
199
+ padding: var(--ui-space-12) 0;
200
+ color: var(--ui-text-secondary);
201
+ }
202
+
203
+ .a11y-byeye-error {
204
+ color: var(--ui-highlight);
205
+ }
206
+
207
+ .a11y-byeye-done {
208
+ margin-top: var(--ui-space-12);
209
+ border-top: 1px solid var(--ui-border);
210
+ }
211
+
212
+ .a11y-byeye-group {
213
+ display: flex;
214
+ align-items: center;
215
+ gap: var(--ui-space-8);
216
+ padding-top: var(--ui-space-12);
217
+ font-size: var(--ui-font-size-xs);
218
+ font-weight: var(--ui-font-weight-semibold);
219
+ color: var(--ui-text-secondary);
220
+ list-style: none;
221
+ cursor: pointer;
222
+ transition: color var(--ui-transition-fast);
223
+ }
224
+
225
+ .a11y-byeye-group::-webkit-details-marker {
226
+ display: none;
227
+ }
228
+
229
+ .a11y-byeye-group:hover {
230
+ color: var(--ui-text-primary);
231
+ }
232
+
233
+ .a11y-byeye-chevron {
234
+ font-size: var(--ui-font-size-xs);
235
+ transition: transform var(--ui-transition-fast);
236
+ }
237
+
238
+ .a11y-byeye-done[open] .a11y-byeye-chevron {
239
+ transform: rotate(90deg);
240
+ }
241
+
242
+ @media (prefers-reduced-motion: reduce) {
243
+ .a11y-byeye-chevron {
244
+ transition: none;
245
+ }
246
+ }
247
+
248
+ .a11y-byeye-group-count {
249
+ font-weight: var(--ui-font-weight-normal);
250
+ color: var(--ui-text-tertiary);
251
+ font-variant-numeric: tabular-nums;
252
+ }
253
+
254
+ .a11y-byeye-list {
255
+ margin: 0;
256
+ padding: 0;
257
+ list-style: none;
258
+ }
259
+
260
+ .a11y-byeye-list :global(code) {
261
+ font-family: var(--ui-font-mono);
262
+ font-size: var(--ui-font-size-sm);
263
+ }
264
+ </style>
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { ComponentA11yIssues } from '../../core/a11y/a11yReport';
3
+ import type { A11yContrastFailure } from '../../core/a11y/a11yReportTypes';
3
4
  import { CONTRAST_RULES } from '../../core/palettes/contrast';
4
5
  import { formatFloor, formatRatio } from '../contrastReadout';
5
6
  import A11yFindingRow from './A11yFindingRow.svelte';
@@ -23,30 +24,42 @@
23
24
  let reviewRows = $derived(withReview ? findingRows(issues.review) : []);
24
25
  </script>
25
26
 
26
- {#if issues.contrast.length > 0 || propertyRows.length > 0}
27
+ {#snippet contrastRow(failure: A11yContrastFailure, fixedRatio: number | undefined)}
28
+ {@const label = propertyLabel(component, failure.variable) ?? failure.variable}
29
+ {@const rule = CONTRAST_RULES[failure.role]}
30
+ <A11yIssueRow fixed={fixedRatio !== undefined} href={rule.url} linkLabel={`Success criterion ${rule.criterion}`}>
31
+ {#if fixedRatio === undefined}
32
+ {label} {formatRatio(failure.ratio)}, needs {formatFloor(failure.floor)}
33
+ {:else}
34
+ <i class="fa-solid fa-check a11y-fixed-mark" aria-hidden="true"></i>
35
+ {label} {formatRatio(failure.ratio)} → {formatRatio(fixedRatio)}, meets {formatFloor(failure.floor)}
36
+ {/if}
37
+ {#snippet detail()}
38
+ <code>{failure.variable}</code>{#if failure.context}, {failure.context}{/if}
39
+ {/snippet}
40
+ {#snippet links()}
41
+ {#if onreveal}
42
+ <button type="button" class="a11y-link" aria-label={`Go to ${label}`} title={failure.variable} onclick={() => onreveal(failure.variable)}>
43
+ {label} <i class="fas fa-arrow-right" aria-hidden="true"></i>
44
+ </button>
45
+ {/if}
46
+ {/snippet}
47
+ </A11yIssueRow>
48
+ {/snippet}
49
+
50
+ {#if issues.contrast.length > 0 || propertyRows.length > 0 || issues.fixed.length > 0}
27
51
  <div class="a11y-group">
28
52
  <h4 class="a11y-group-title">Fix with a property</h4>
29
53
  <ul class="a11y-issues">
30
54
  {#each issues.contrast as failure, i (i)}
31
- {@const label = propertyLabel(component, failure.variable) ?? failure.variable}
32
- {@const rule = CONTRAST_RULES[failure.role]}
33
- <A11yIssueRow href={rule.url} linkLabel={`Success criterion ${rule.criterion}`}>
34
- {label} {formatRatio(failure.ratio)}, needs {formatFloor(failure.floor)}
35
- {#snippet detail()}
36
- <code>{failure.variable}</code>{#if failure.context}, {failure.context}{/if}
37
- {/snippet}
38
- {#snippet links()}
39
- {#if onreveal}
40
- <button type="button" class="a11y-link" aria-label={`Go to ${label}`} title={failure.variable} onclick={() => onreveal(failure.variable)}>
41
- {label} <i class="fas fa-arrow-right" aria-hidden="true"></i>
42
- </button>
43
- {/if}
44
- {/snippet}
45
- </A11yIssueRow>
55
+ {@render contrastRow(failure, undefined)}
46
56
  {/each}
47
57
  {#each propertyRows as row, i (i)}
48
58
  <A11yFindingRow {component} {row} {onreveal} {onopen} />
49
59
  {/each}
60
+ {#each issues.fixed as { failure, ratio }, i (i)}
61
+ {@render contrastRow(failure, ratio)}
62
+ {/each}
50
63
  </ul>
51
64
  </div>
52
65
  {/if}
@@ -95,6 +108,11 @@
95
108
  font-family: var(--ui-font-mono);
96
109
  }
97
110
 
111
+ .a11y-fixed-mark {
112
+ margin-right: var(--ui-space-4);
113
+ font-size: var(--ui-font-size-sm);
114
+ }
115
+
98
116
  .a11y-issues {
99
117
  margin: 0;
100
118
  padding: 0;
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import { confirmReview } from '../../core/a11y/a11yReport';
3
+ import UIPillButton from '../UIPillButton.svelte';
2
4
  import A11yIssueRow from './A11yIssueRow.svelte';
3
5
  import {
4
6
  componentLabel,
@@ -13,23 +15,61 @@
13
15
  component: string;
14
16
  row: A11yFindingRow;
15
17
  review?: boolean;
18
+ /** A result to check by eye that someone marked as looking right. */
19
+ confirmed?: boolean;
16
20
  /** Leads the text, as the component's name does in a list that spans components. */
17
21
  prefix?: string;
18
22
  onreveal?: (variable: string) => void;
19
23
  /** Opens the component, for a row that traced no property. Absent on the component's own page. */
20
24
  onopen?: () => void;
25
+ /** Runs once a press of Looks right has saved, with the row's new state. */
26
+ onconfirmchange?: (confirmed: boolean) => void;
21
27
  }
22
28
 
23
- let { component, row, review = false, prefix, onreveal, onopen }: Props = $props();
29
+ let { component, row, review = false, confirmed = false, prefix, onreveal, onopen, onconfirmchange }: Props = $props();
24
30
 
25
31
  let properties = $derived(row.variables.map((variable) => ({ variable, label: propertyLabel(component, variable) ?? variable })));
26
32
  let where = $derived([row.targets.length > 1 ? elementsLabel(row.targets.length) : '', row.views].filter(Boolean).join(', '));
27
33
  let helpUrl = $derived(row.finding.details?.helpUrl);
28
- let hasLinks = $derived((properties.length > 0 && onreveal !== undefined) || (properties.length === 0 && onopen !== undefined));
34
+ let hasNavigation = $derived((properties.length > 0 && onreveal !== undefined) || (properties.length === 0 && onopen !== undefined));
35
+
36
+ let saving = $state(false);
37
+ let saveError: string | null = $state(null);
38
+
39
+ async function toggleConfirmed() {
40
+ saving = true;
41
+ saveError = null;
42
+ // Read before the save: the save moves the row to the other list, which unmounts this one.
43
+ const next = !confirmed;
44
+ const done = onconfirmchange;
45
+ try {
46
+ await confirmReview(row.findings, next);
47
+ done?.(next);
48
+ } catch (err) {
49
+ saveError = (err as Error).message;
50
+ } finally {
51
+ saving = false;
52
+ }
53
+ }
29
54
  </script>
30
55
 
31
56
  {#snippet location()}
32
57
  {#if row.targets.length === 1}<code>{row.targets[0]}</code>{#if row.views}{`, ${row.views}`}{/if}{:else}{where}{/if}
58
+ {#if saveError}<span class="a11y-confirm-error" role="alert">{saveError}</span>{/if}
59
+ {/snippet}
60
+
61
+ {#snippet confirmToggle()}
62
+ <span class="a11y-confirm">
63
+ <UIPillButton
64
+ size="compact"
65
+ variant="outline"
66
+ icon={confirmed ? 'fa-circle-check' : 'fa-check'}
67
+ ariaPressed={confirmed}
68
+ disabled={saving}
69
+ title={confirmed ? 'Marked as looking right. Press to clear the mark.' : 'Mark this result as looking right'}
70
+ onclick={toggleConfirmed}
71
+ >Looks right</UIPillButton>
72
+ </span>
33
73
  {/snippet}
34
74
 
35
75
  {#snippet links()}
@@ -48,10 +88,32 @@
48
88
 
49
89
  <A11yIssueRow
50
90
  {review}
91
+ {confirmed}
51
92
  href={helpUrl}
52
93
  linkLabel={helpUrl ? guidanceLabel(helpUrl) : undefined}
53
- detail={row.targets.length > 0 || row.views ? location : undefined}
54
- links={hasLinks ? links : undefined}
94
+ detail={row.targets.length > 0 || row.views || saveError ? location : undefined}
95
+ links={hasNavigation ? links : undefined}
96
+ action={review ? confirmToggle : undefined}
55
97
  >
98
+ {#if review}<i class="fa-solid {confirmed ? 'fa-check' : 'fa-eye'} a11y-review-mark" aria-hidden="true"></i>{/if}
56
99
  {#if prefix}{prefix}: {/if}{findingText(row.finding)}
57
100
  </A11yIssueRow>
101
+
102
+ <style>
103
+ .a11y-review-mark {
104
+ margin-right: var(--ui-space-4);
105
+ font-size: var(--ui-font-size-sm);
106
+ color: var(--ui-text-tertiary);
107
+ }
108
+
109
+ .a11y-confirm-error {
110
+ margin-left: var(--ui-space-8);
111
+ color: var(--ui-highlight);
112
+ }
113
+
114
+ /* One width for both states, so a column of toggles lines up. */
115
+ .a11y-confirm :global(.ui-pill) {
116
+ min-width: 8.5rem;
117
+ justify-content: center;
118
+ }
119
+ </style>
@@ -4,6 +4,10 @@
4
4
  interface Props {
5
5
  /** A result to check by eye: its text in grey, where an issue's is amber. */
6
6
  review?: boolean;
7
+ /** A result to check by eye marked as looking right: its text dimmer still. */
8
+ confirmed?: boolean;
9
+ /** An issue the editor now measures as passing: its text in green. */
10
+ fixed?: boolean;
7
11
  href?: string;
8
12
  /** Names what `href` points at, such as "Rule" or "Success criterion 1.4.11". */
9
13
  linkLabel?: string;
@@ -11,44 +15,63 @@
11
15
  detail?: Snippet;
12
16
  /** The in-editor links that lead the link group, such as the traced properties. */
13
17
  links?: Snippet;
18
+ /** Sits in its own column at the row's right edge, so it keeps its place from row to row. */
19
+ action?: Snippet;
14
20
  }
15
21
 
16
- let { review = false, href, linkLabel = 'Guidance', children, detail, links }: Props = $props();
22
+ let { review = false, confirmed = false, fixed = false, href, linkLabel = 'Guidance', children, detail, links, action }: Props = $props();
17
23
  </script>
18
24
 
19
- <li class="a11y-issue" class:review>
20
- <div class="a11y-issue-head">
21
- <p class="a11y-issue-text">{@render children()}</p>
22
- {#if links || href}
23
- <div class="a11y-issue-links">
24
- {@render links?.()}
25
- {#if href}
26
- <a
27
- class="a11y-link a11y-issue-guidance"
28
- {href}
29
- target="_blank"
30
- rel="noopener"
31
- title={href}
32
- aria-label={`${linkLabel} (opens in a new tab)`}
33
- >
34
- {linkLabel}
35
- <i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i>
36
- </a>
37
- {/if}
38
- </div>
25
+ <li class="a11y-issue" class:review class:confirmed class:fixed>
26
+ <div class="a11y-issue-body">
27
+ <div class="a11y-issue-head">
28
+ <p class="a11y-issue-text">{@render children()}</p>
29
+ {#if links || href}
30
+ <div class="a11y-issue-links">
31
+ {@render links?.()}
32
+ {#if href}
33
+ <a
34
+ class="a11y-link a11y-issue-guidance"
35
+ {href}
36
+ target="_blank"
37
+ rel="noopener"
38
+ title={href}
39
+ aria-label={`${linkLabel} (opens in a new tab)`}
40
+ >
41
+ {linkLabel}
42
+ <i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i>
43
+ </a>
44
+ {/if}
45
+ </div>
46
+ {/if}
47
+ </div>
48
+ {#if detail}
49
+ <p class="a11y-issue-detail">{@render detail()}</p>
39
50
  {/if}
40
51
  </div>
41
- {#if detail}
42
- <p class="a11y-issue-detail">{@render detail()}</p>
52
+ {#if action}
53
+ <div class="a11y-issue-action">{@render action()}</div>
43
54
  {/if}
44
55
  </li>
45
56
 
46
57
  <style>
47
58
  .a11y-issue {
48
59
  display: flex;
60
+ gap: var(--ui-space-16);
61
+ padding: var(--ui-space-12) 0;
62
+ }
63
+
64
+ .a11y-issue-body {
65
+ display: flex;
66
+ flex: 1;
49
67
  flex-direction: column;
50
68
  gap: var(--ui-space-4);
51
- padding: var(--ui-space-12) 0;
69
+ min-width: 0;
70
+ }
71
+
72
+ .a11y-issue-action {
73
+ flex: none;
74
+ align-self: flex-start;
52
75
  }
53
76
 
54
77
  .a11y-issue + :global(.a11y-issue) {
@@ -79,6 +102,14 @@
79
102
  color: var(--ui-text-secondary);
80
103
  }
81
104
 
105
+ .confirmed .a11y-issue-text {
106
+ color: var(--ui-text-tertiary);
107
+ }
108
+
109
+ .fixed .a11y-issue-text {
110
+ color: var(--ui-fixed);
111
+ }
112
+
82
113
  .a11y-issue-detail {
83
114
  font-size: var(--ui-font-size-sm);
84
115
  color: var(--ui-text-tertiary);