@joewinke/jatui 0.33.0 → 0.34.1
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/CLAUDE.md +15 -0
- package/dist/actions/reviewKeys.d.ts +159 -0
- package/dist/actions/reviewKeys.js +353 -0
- package/dist/components/review/MediaZoomLightbox.svelte +628 -0
- package/dist/components/review/MediaZoomLightbox.svelte.d.ts +56 -0
- package/dist/components/review/ReviewFilterBar.svelte +296 -0
- package/dist/components/review/ReviewFilterBar.svelte.d.ts +45 -0
- package/dist/components/review/ReviewNote.svelte +213 -0
- package/dist/components/review/ReviewNote.svelte.d.ts +37 -0
- package/dist/components/review/ReviewProgress.svelte +99 -0
- package/dist/components/review/ReviewProgress.svelte.d.ts +32 -0
- package/dist/components/review/ReviewShortcuts.svelte +80 -0
- package/dist/components/review/ReviewShortcuts.svelte.d.ts +15 -0
- package/dist/components/review/RoundHistory.svelte +151 -0
- package/dist/components/review/RoundHistory.svelte.d.ts +35 -0
- package/dist/components/review/VerdictControl.svelte +163 -0
- package/dist/components/review/VerdictControl.svelte.d.ts +23 -0
- package/dist/components/review/index.d.ts +24 -0
- package/dist/components/review/index.js +23 -0
- package/dist/components/review/mediaZoom.d.ts +115 -0
- package/dist/components/review/mediaZoom.js +161 -0
- package/dist/components/review/reviewSummary.d.ts +101 -0
- package/dist/components/review/reviewSummary.js +122 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +12 -0
- package/package.json +6 -1
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ReviewFilterBar (jatui-xmrod) — the row on top of every review block:
|
|
4
|
+
* filter chips with live counts (All · Needs you · Undecided · per verdict ·
|
|
5
|
+
* Retest), one compact Sort/Group menu button and the Shortcuts button.
|
|
6
|
+
* Ported from MOS artifact-blocks/review/ReviewToolbar.svelte.
|
|
7
|
+
*
|
|
8
|
+
* In flow, NOT sticky (a sticky bar collided with the host page's own
|
|
9
|
+
* sticky header). The chips are one row that scrolls sideways on a phone
|
|
10
|
+
* instead of wrapping. Chips come from the host — `buildReviewFilters()`
|
|
11
|
+
* builds the standard set from verdict counts.
|
|
12
|
+
*
|
|
13
|
+
* Sort and Group choices are passed in; both live in ONE menu button so the
|
|
14
|
+
* row stays a single line. Omit both option lists and the button is hidden;
|
|
15
|
+
* omit `onHelp` and the Shortcuts button is hidden.
|
|
16
|
+
*/
|
|
17
|
+
import { tick, type Snippet } from 'svelte';
|
|
18
|
+
import { TONE_BG, type ReviewFilterChip } from './reviewSummary';
|
|
19
|
+
|
|
20
|
+
interface MenuOption {
|
|
21
|
+
id: string;
|
|
22
|
+
label: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
filters: ReviewFilterChip[];
|
|
27
|
+
/** Active filter id (bindable). */
|
|
28
|
+
active?: string;
|
|
29
|
+
onFilterChange?: (id: string) => void;
|
|
30
|
+
sortOptions?: MenuOption[];
|
|
31
|
+
/** Active sort id (bindable). */
|
|
32
|
+
sort?: string;
|
|
33
|
+
onSortChange?: (id: string) => void;
|
|
34
|
+
groupOptions?: MenuOption[];
|
|
35
|
+
/** Active group id (bindable). */
|
|
36
|
+
group?: string;
|
|
37
|
+
onGroupChange?: (id: string) => void;
|
|
38
|
+
/** Opens the shortcuts help. Omit to hide the button. */
|
|
39
|
+
onHelp?: () => void;
|
|
40
|
+
/** Accessible name of the whole row. */
|
|
41
|
+
label?: string;
|
|
42
|
+
/** Extra content at the end of the row (e.g. a compact ReviewProgress). */
|
|
43
|
+
trailing?: Snippet;
|
|
44
|
+
class?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let {
|
|
48
|
+
filters,
|
|
49
|
+
active = $bindable('all'),
|
|
50
|
+
onFilterChange,
|
|
51
|
+
sortOptions = [],
|
|
52
|
+
sort = $bindable(sortOptions[0]?.id ?? ''),
|
|
53
|
+
onSortChange,
|
|
54
|
+
groupOptions = [],
|
|
55
|
+
group = $bindable(groupOptions[0]?.id ?? ''),
|
|
56
|
+
onGroupChange,
|
|
57
|
+
onHelp,
|
|
58
|
+
label = 'Filter items',
|
|
59
|
+
trailing,
|
|
60
|
+
class: className = ''
|
|
61
|
+
}: Props = $props();
|
|
62
|
+
|
|
63
|
+
const uid = $props.id();
|
|
64
|
+
const menuId = `review-filter-menu-${uid}`;
|
|
65
|
+
let menuOpen = $state(false);
|
|
66
|
+
let menuButton = $state<HTMLButtonElement>();
|
|
67
|
+
let menuEl = $state<HTMLDivElement>();
|
|
68
|
+
|
|
69
|
+
const hasMenu = $derived(sortOptions.length > 0 || groupOptions.length > 0);
|
|
70
|
+
const sortLabel = $derived(sortOptions.find((o) => o.id === sort)?.label);
|
|
71
|
+
const groupLabel = $derived(groupOptions.find((o) => o.id === group)?.label);
|
|
72
|
+
const menuButtonText = $derived(sortOptions.length ? 'Sort' : 'Group');
|
|
73
|
+
const menuSummary = $derived(
|
|
74
|
+
[sortLabel && `Sort: ${sortLabel}`, groupLabel && `Group: ${groupLabel}`].filter(Boolean).join(', ')
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
function choose(id: string) {
|
|
78
|
+
if (active === id) return;
|
|
79
|
+
active = id;
|
|
80
|
+
onFilterChange?.(id);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function items(): HTMLElement[] {
|
|
84
|
+
return menuEl ? Array.from(menuEl.querySelectorAll<HTMLElement>('[role="menuitemradio"]')) : [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function openMenu(focus: 'first' | 'last' | 'checked' = 'checked') {
|
|
88
|
+
menuOpen = true;
|
|
89
|
+
tick().then(() => {
|
|
90
|
+
const all = items();
|
|
91
|
+
const target =
|
|
92
|
+
focus === 'last'
|
|
93
|
+
? all.at(-1)
|
|
94
|
+
: focus === 'first'
|
|
95
|
+
? all[0]
|
|
96
|
+
: (all.find((el) => el.getAttribute('aria-checked') === 'true') ?? all[0]);
|
|
97
|
+
target?.focus();
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function closeMenu(returnFocus = true) {
|
|
102
|
+
menuOpen = false;
|
|
103
|
+
if (returnFocus) menuButton?.focus();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function pickSort(id: string) {
|
|
107
|
+
if (sort !== id) {
|
|
108
|
+
sort = id;
|
|
109
|
+
onSortChange?.(id);
|
|
110
|
+
}
|
|
111
|
+
closeMenu();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function pickGroup(id: string) {
|
|
115
|
+
if (group !== id) {
|
|
116
|
+
group = id;
|
|
117
|
+
onGroupChange?.(id);
|
|
118
|
+
}
|
|
119
|
+
closeMenu();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function onButtonKeydown(e: KeyboardEvent) {
|
|
123
|
+
if (e.key === 'ArrowDown') {
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
openMenu('checked');
|
|
126
|
+
} else if (e.key === 'ArrowUp') {
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
openMenu('last');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function onMenuKeydown(e: KeyboardEvent) {
|
|
133
|
+
const all = items();
|
|
134
|
+
const i = all.indexOf(document.activeElement as HTMLElement);
|
|
135
|
+
let next: HTMLElement | undefined;
|
|
136
|
+
if (e.key === 'ArrowDown') next = all[(i + 1) % all.length];
|
|
137
|
+
else if (e.key === 'ArrowUp') next = all[(i - 1 + all.length) % all.length];
|
|
138
|
+
else if (e.key === 'Home') next = all[0];
|
|
139
|
+
else if (e.key === 'End') next = all.at(-1);
|
|
140
|
+
else if (e.key === 'Escape') {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
e.stopPropagation();
|
|
143
|
+
closeMenu();
|
|
144
|
+
return;
|
|
145
|
+
} else if (e.key === 'Tab') {
|
|
146
|
+
closeMenu(false);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (next) {
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
next.focus();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Click outside closes the menu (without stealing focus back).
|
|
156
|
+
$effect(() => {
|
|
157
|
+
if (!menuOpen) return;
|
|
158
|
+
const onDown = (e: PointerEvent) => {
|
|
159
|
+
const t = e.target as Node;
|
|
160
|
+
if (!menuEl?.contains(t) && !menuButton?.contains(t)) closeMenu(false);
|
|
161
|
+
};
|
|
162
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
163
|
+
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
164
|
+
});
|
|
165
|
+
</script>
|
|
166
|
+
|
|
167
|
+
<!-- @container: the row stacks or goes single-line by the width of ITS slot
|
|
168
|
+
(a card in a grid), not the viewport. -->
|
|
169
|
+
<div class="review-filter-bar @container min-w-0 {className}" role="group" aria-label={label}>
|
|
170
|
+
<div class="flex min-w-0 flex-col gap-2 @3xl:flex-row @3xl:items-center">
|
|
171
|
+
<div
|
|
172
|
+
class="review-filter-chips -mx-1 flex min-w-0 snap-x gap-1 overflow-x-auto px-1 pb-0.5"
|
|
173
|
+
role="group"
|
|
174
|
+
aria-label="Filter"
|
|
175
|
+
>
|
|
176
|
+
{#each filters as f (f.id)}
|
|
177
|
+
{@const on = active === f.id}
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
class="btn btn-sm review-filter-chip shrink-0 snap-start gap-1.5 font-medium {on
|
|
181
|
+
? 'border-base-content/25 bg-base-content/10 text-base-content'
|
|
182
|
+
: 'text-base-content/75 hover:bg-base-content/5 border-transparent bg-transparent shadow-none'}"
|
|
183
|
+
aria-pressed={on}
|
|
184
|
+
data-filter={f.id}
|
|
185
|
+
onclick={() => choose(f.id)}
|
|
186
|
+
>
|
|
187
|
+
{#if f.tone}
|
|
188
|
+
<span class="size-1.5 shrink-0 rounded-full {TONE_BG[f.tone]}" aria-hidden="true"></span>
|
|
189
|
+
{/if}
|
|
190
|
+
{f.label}
|
|
191
|
+
<span class="text-base-content/70 tabular-nums">{f.count}</span>
|
|
192
|
+
</button>
|
|
193
|
+
{/each}
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
{#if hasMenu || onHelp || trailing}
|
|
197
|
+
<div class="flex shrink-0 items-center gap-2 @3xl:ml-auto">
|
|
198
|
+
{#if trailing}{@render trailing()}{/if}
|
|
199
|
+
|
|
200
|
+
{#if hasMenu}
|
|
201
|
+
<div class="relative">
|
|
202
|
+
<button
|
|
203
|
+
bind:this={menuButton}
|
|
204
|
+
type="button"
|
|
205
|
+
class="btn btn-ghost btn-sm review-filter-chip text-base-content/75 gap-1"
|
|
206
|
+
aria-haspopup="menu"
|
|
207
|
+
aria-expanded={menuOpen}
|
|
208
|
+
aria-controls={menuOpen ? menuId : undefined}
|
|
209
|
+
aria-label={menuSummary ? `${menuButtonText} (${menuSummary})` : menuButtonText}
|
|
210
|
+
onclick={() => (menuOpen ? closeMenu() : openMenu())}
|
|
211
|
+
onkeydown={onButtonKeydown}
|
|
212
|
+
>
|
|
213
|
+
{menuButtonText}
|
|
214
|
+
<svg class="size-3" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
215
|
+
<path d="M3.5 6 8 10.5 12.5 6z" />
|
|
216
|
+
</svg>
|
|
217
|
+
</button>
|
|
218
|
+
|
|
219
|
+
{#if menuOpen}
|
|
220
|
+
<div
|
|
221
|
+
bind:this={menuEl}
|
|
222
|
+
id={menuId}
|
|
223
|
+
class="review-filter-menu bg-base-100 border-base-content/10 absolute left-0 @3xl:left-auto @3xl:right-0 z-20 mt-1 flex min-w-44 flex-col border p-1 text-sm shadow-lg"
|
|
224
|
+
role="menu"
|
|
225
|
+
aria-label="Sort and group"
|
|
226
|
+
tabindex="-1"
|
|
227
|
+
onkeydown={onMenuKeydown}
|
|
228
|
+
>
|
|
229
|
+
{#if sortOptions.length}
|
|
230
|
+
<div role="group" aria-labelledby="{menuId}-sort">
|
|
231
|
+
<div id="{menuId}-sort" class="text-base-content/60 px-2 pt-1 pb-0.5 text-xs font-medium">Sort</div>
|
|
232
|
+
{#each sortOptions as o (o.id)}
|
|
233
|
+
{@render option(o, sort === o.id, () => pickSort(o.id))}
|
|
234
|
+
{/each}
|
|
235
|
+
</div>
|
|
236
|
+
{/if}
|
|
237
|
+
{#if sortOptions.length && groupOptions.length}
|
|
238
|
+
<div class="border-base-content/10 my-1 border-t" role="separator"></div>
|
|
239
|
+
{/if}
|
|
240
|
+
{#if groupOptions.length}
|
|
241
|
+
<div role="group" aria-labelledby="{menuId}-group">
|
|
242
|
+
<div id="{menuId}-group" class="text-base-content/60 px-2 pt-1 pb-0.5 text-xs font-medium">Group</div>
|
|
243
|
+
{#each groupOptions as o (o.id)}
|
|
244
|
+
{@render option(o, group === o.id, () => pickGroup(o.id))}
|
|
245
|
+
{/each}
|
|
246
|
+
</div>
|
|
247
|
+
{/if}
|
|
248
|
+
</div>
|
|
249
|
+
{/if}
|
|
250
|
+
</div>
|
|
251
|
+
{/if}
|
|
252
|
+
|
|
253
|
+
{#if onHelp}
|
|
254
|
+
<button
|
|
255
|
+
type="button"
|
|
256
|
+
class="btn btn-ghost btn-sm review-filter-chip text-base-content/75 gap-1.5"
|
|
257
|
+
aria-haspopup="dialog"
|
|
258
|
+
aria-keyshortcuts="?"
|
|
259
|
+
onclick={onHelp}
|
|
260
|
+
>
|
|
261
|
+
Shortcuts <kbd class="kbd kbd-xs">?</kbd>
|
|
262
|
+
</button>
|
|
263
|
+
{/if}
|
|
264
|
+
</div>
|
|
265
|
+
{/if}
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
{#snippet option(o: MenuOption, checked: boolean, pick: () => void)}
|
|
270
|
+
<button
|
|
271
|
+
type="button"
|
|
272
|
+
class="review-filter-option hover:bg-base-content/5 focus-visible:bg-base-content/10 flex w-full items-center gap-2 px-2 py-1.5 text-left outline-none"
|
|
273
|
+
role="menuitemradio"
|
|
274
|
+
aria-checked={checked}
|
|
275
|
+
tabindex="-1"
|
|
276
|
+
data-option={o.id}
|
|
277
|
+
onclick={pick}
|
|
278
|
+
>
|
|
279
|
+
<svg class="size-3.5 shrink-0" class:invisible={!checked} viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
280
|
+
<path d="M6.2 11.6 2.9 8.3l1.1-1.1 2.2 2.2 5.8-5.8 1.1 1.1z" />
|
|
281
|
+
</svg>
|
|
282
|
+
{o.label}
|
|
283
|
+
</button>
|
|
284
|
+
{/snippet}
|
|
285
|
+
|
|
286
|
+
<style>
|
|
287
|
+
.review-filter-chip {
|
|
288
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
289
|
+
}
|
|
290
|
+
.review-filter-menu {
|
|
291
|
+
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
292
|
+
}
|
|
293
|
+
.review-filter-option {
|
|
294
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
295
|
+
}
|
|
296
|
+
</style>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReviewFilterBar (jatui-xmrod) — the row on top of every review block:
|
|
3
|
+
* filter chips with live counts (All · Needs you · Undecided · per verdict ·
|
|
4
|
+
* Retest), one compact Sort/Group menu button and the Shortcuts button.
|
|
5
|
+
* Ported from MOS artifact-blocks/review/ReviewToolbar.svelte.
|
|
6
|
+
*
|
|
7
|
+
* In flow, NOT sticky (a sticky bar collided with the host page's own
|
|
8
|
+
* sticky header). The chips are one row that scrolls sideways on a phone
|
|
9
|
+
* instead of wrapping. Chips come from the host — `buildReviewFilters()`
|
|
10
|
+
* builds the standard set from verdict counts.
|
|
11
|
+
*
|
|
12
|
+
* Sort and Group choices are passed in; both live in ONE menu button so the
|
|
13
|
+
* row stays a single line. Omit both option lists and the button is hidden;
|
|
14
|
+
* omit `onHelp` and the Shortcuts button is hidden.
|
|
15
|
+
*/
|
|
16
|
+
import { type Snippet } from 'svelte';
|
|
17
|
+
import { type ReviewFilterChip } from './reviewSummary';
|
|
18
|
+
interface MenuOption {
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
}
|
|
22
|
+
interface Props {
|
|
23
|
+
filters: ReviewFilterChip[];
|
|
24
|
+
/** Active filter id (bindable). */
|
|
25
|
+
active?: string;
|
|
26
|
+
onFilterChange?: (id: string) => void;
|
|
27
|
+
sortOptions?: MenuOption[];
|
|
28
|
+
/** Active sort id (bindable). */
|
|
29
|
+
sort?: string;
|
|
30
|
+
onSortChange?: (id: string) => void;
|
|
31
|
+
groupOptions?: MenuOption[];
|
|
32
|
+
/** Active group id (bindable). */
|
|
33
|
+
group?: string;
|
|
34
|
+
onGroupChange?: (id: string) => void;
|
|
35
|
+
/** Opens the shortcuts help. Omit to hide the button. */
|
|
36
|
+
onHelp?: () => void;
|
|
37
|
+
/** Accessible name of the whole row. */
|
|
38
|
+
label?: string;
|
|
39
|
+
/** Extra content at the end of the row (e.g. a compact ReviewProgress). */
|
|
40
|
+
trailing?: Snippet;
|
|
41
|
+
class?: string;
|
|
42
|
+
}
|
|
43
|
+
declare const ReviewFilterBar: import("svelte").Component<Props, {}, "sort" | "active" | "group">;
|
|
44
|
+
type ReviewFilterBar = ReturnType<typeof ReviewFilterBar>;
|
|
45
|
+
export default ReviewFilterBar;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
ReviewNote — one review item's note, bound to the CURRENT round
|
|
3
|
+
(mos-z8vgx "Shared primitives"; ports MOS artifact-blocks/review/noteFlow).
|
|
4
|
+
|
|
5
|
+
- Opens itself and focuses the textarea when the verdict changes to one
|
|
6
|
+
marked `opensNote` (Reject / Needs work / Fail…). It does NOT open on
|
|
7
|
+
mount, on a repeat of the same verdict, or when the round changes.
|
|
8
|
+
- The note is optional: under an EMPTY note on a note-worthy verdict sits
|
|
9
|
+
the soft NOTE_HINT — never a validation block.
|
|
10
|
+
- Auto-grows with its content.
|
|
11
|
+
- Per-note save state from `onSave`: "Saving…" → "Saved ✓", or
|
|
12
|
+
"Not saved" + Retry when it throws/rejects. Saves are debounced while
|
|
13
|
+
typing and flushed on blur.
|
|
14
|
+
- Esc hands focus back to the item's card (`[data-item-id]`, the same hook
|
|
15
|
+
reviewKeys() navigates); the text is already saved, Esc is never a
|
|
16
|
+
discard.
|
|
17
|
+
- `round` keys the note to the current round: a new round resets the save
|
|
18
|
+
state, drops any pending save for the old round, and collapses the note
|
|
19
|
+
if the host gives it an empty value.
|
|
20
|
+
-->
|
|
21
|
+
<script module lang="ts">
|
|
22
|
+
/** The soft inline hint under an EMPTY note field on a note-worthy verdict. */
|
|
23
|
+
export const NOTE_HINT = 'Optional — the verdict is saved. A note is welcome, not required.';
|
|
24
|
+
|
|
25
|
+
export type ReviewNoteSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
|
26
|
+
|
|
27
|
+
let nextId = 0;
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<script lang="ts">
|
|
31
|
+
import { tick, untrack } from 'svelte';
|
|
32
|
+
import type { VerdictOption } from '../../actions/reviewKeys';
|
|
33
|
+
|
|
34
|
+
interface Props {
|
|
35
|
+
/** Note text for the current round. */
|
|
36
|
+
value?: string;
|
|
37
|
+
/** Whether the note field is shown. */
|
|
38
|
+
open?: boolean;
|
|
39
|
+
/** The current-round verdict; a change to an `opensNote` one opens the note. */
|
|
40
|
+
verdict?: string | null;
|
|
41
|
+
/** Same array as VerdictControl's — `opensNote` is read from it. */
|
|
42
|
+
options?: readonly VerdictOption[];
|
|
43
|
+
/** Current round key (version, retestSince…). */
|
|
44
|
+
round?: string | number | null;
|
|
45
|
+
/** Persist the note. Throw / reject to show "Not saved". */
|
|
46
|
+
onSave?: (text: string, ctx: { round: string | number | null }) => void | Promise<void>;
|
|
47
|
+
/** Every keystroke, before the debounce. */
|
|
48
|
+
onInput?: (text: string) => void;
|
|
49
|
+
/** Debounce for saves while typing. Default 600ms. */
|
|
50
|
+
debounceMs?: number;
|
|
51
|
+
/** Where Esc sends focus. Default: the closest `[data-item-id]` ancestor. */
|
|
52
|
+
returnFocusTo?: () => HTMLElement | null | undefined;
|
|
53
|
+
/** Visible label (also the textarea's accessible name). */
|
|
54
|
+
label?: string;
|
|
55
|
+
placeholder?: string;
|
|
56
|
+
/** Text-entry id; pairs with a Note toggle's aria-controls. Auto when omitted. */
|
|
57
|
+
id?: string;
|
|
58
|
+
rows?: number;
|
|
59
|
+
disabled?: boolean;
|
|
60
|
+
class?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let {
|
|
64
|
+
value = $bindable(''),
|
|
65
|
+
open = $bindable(false),
|
|
66
|
+
verdict = null,
|
|
67
|
+
options = [],
|
|
68
|
+
round = null,
|
|
69
|
+
onSave,
|
|
70
|
+
onInput,
|
|
71
|
+
debounceMs = 600,
|
|
72
|
+
returnFocusTo,
|
|
73
|
+
label = 'Note',
|
|
74
|
+
placeholder = 'What should change?',
|
|
75
|
+
id,
|
|
76
|
+
rows = 2,
|
|
77
|
+
disabled = false,
|
|
78
|
+
class: className = ''
|
|
79
|
+
}: Props = $props();
|
|
80
|
+
|
|
81
|
+
const uid = ++nextId;
|
|
82
|
+
const inputId = $derived(id ?? `review-note-${uid}`);
|
|
83
|
+
const statusId = $derived(`${inputId}-status`);
|
|
84
|
+
|
|
85
|
+
let root: HTMLDivElement | undefined = $state();
|
|
86
|
+
let textarea: HTMLTextAreaElement | undefined = $state();
|
|
87
|
+
let saveState = $state<ReviewNoteSaveState>('idle');
|
|
88
|
+
|
|
89
|
+
const noteWorthy = $derived(!!verdict && options.some((o) => o.id === verdict && o.opensNote));
|
|
90
|
+
|
|
91
|
+
// Open + focus when the verdict CHANGES to a note-worthy one. The first run
|
|
92
|
+
// only records the starting verdict, so a saved state never pops open.
|
|
93
|
+
let lastVerdict: string | null | undefined = undefined;
|
|
94
|
+
$effect(() => {
|
|
95
|
+
const v = verdict;
|
|
96
|
+
const prev = lastVerdict;
|
|
97
|
+
lastVerdict = v;
|
|
98
|
+
if (prev === undefined || v === prev || !v) return;
|
|
99
|
+
if (!untrack(() => options.some((o) => o.id === v && o.opensNote))) return;
|
|
100
|
+
open = true;
|
|
101
|
+
tick().then(() => textarea?.focus());
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// A new round: forget the old round's save state and pending save.
|
|
105
|
+
let lastRound: string | number | null | undefined = undefined;
|
|
106
|
+
$effect(() => {
|
|
107
|
+
const r = round;
|
|
108
|
+
const prev = lastRound;
|
|
109
|
+
lastRound = r;
|
|
110
|
+
if (prev === undefined || r === prev) return;
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
timer = undefined;
|
|
113
|
+
pendingText = null;
|
|
114
|
+
saveState = 'idle';
|
|
115
|
+
if (!untrack(() => value)) open = false;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Auto-grow: reset, then fit the content.
|
|
119
|
+
$effect(() => {
|
|
120
|
+
void value;
|
|
121
|
+
void open;
|
|
122
|
+
const el = textarea;
|
|
123
|
+
if (!el) return;
|
|
124
|
+
el.style.height = 'auto';
|
|
125
|
+
if (el.scrollHeight > 0) el.style.height = `${el.scrollHeight}px`;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
129
|
+
let pendingText: string | null = null;
|
|
130
|
+
let saveSeq = 0;
|
|
131
|
+
|
|
132
|
+
async function save(text: string) {
|
|
133
|
+
if (!onSave) return;
|
|
134
|
+
const seq = ++saveSeq;
|
|
135
|
+
const forRound = round;
|
|
136
|
+
saveState = 'saving';
|
|
137
|
+
try {
|
|
138
|
+
await onSave(text, { round: forRound });
|
|
139
|
+
if (seq === saveSeq && forRound === round) saveState = 'saved';
|
|
140
|
+
} catch {
|
|
141
|
+
if (seq === saveSeq && forRound === round) saveState = 'error';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function flush() {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
timer = undefined;
|
|
148
|
+
if (pendingText === null) return;
|
|
149
|
+
const text = pendingText;
|
|
150
|
+
pendingText = null;
|
|
151
|
+
void save(text);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function onTextInput(e: Event) {
|
|
155
|
+
const text = (e.currentTarget as HTMLTextAreaElement).value;
|
|
156
|
+
value = text;
|
|
157
|
+
onInput?.(text);
|
|
158
|
+
if (!onSave) return;
|
|
159
|
+
pendingText = text;
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
timer = setTimeout(flush, debounceMs);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function retry() {
|
|
165
|
+
pendingText = null;
|
|
166
|
+
void save(value);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function onKeydown(e: KeyboardEvent) {
|
|
170
|
+
if (e.key !== 'Escape') return;
|
|
171
|
+
e.preventDefault();
|
|
172
|
+
e.stopPropagation();
|
|
173
|
+
flush();
|
|
174
|
+
const target = returnFocusTo ? returnFocusTo() : root?.closest<HTMLElement>('[data-item-id]');
|
|
175
|
+
target?.focus();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
$effect(() => () => clearTimeout(timer));
|
|
179
|
+
</script>
|
|
180
|
+
|
|
181
|
+
{#if open}
|
|
182
|
+
<div bind:this={root} class="flex flex-col gap-1 {className}">
|
|
183
|
+
<div class="flex items-baseline justify-between gap-2">
|
|
184
|
+
<label for={inputId} class="text-xs font-medium text-base-content/75">{label}</label>
|
|
185
|
+
<span id={statusId} class="text-xs" role="status" aria-live="polite">
|
|
186
|
+
{#if saveState === 'saving'}
|
|
187
|
+
<span class="text-base-content/60">Saving…</span>
|
|
188
|
+
{:else if saveState === 'saved'}
|
|
189
|
+
<span class="text-success">Saved ✓</span>
|
|
190
|
+
{:else if saveState === 'error'}
|
|
191
|
+
<span class="text-error">Not saved</span>
|
|
192
|
+
<button type="button" class="link link-error ml-1" onclick={retry}>Retry</button>
|
|
193
|
+
{/if}
|
|
194
|
+
</span>
|
|
195
|
+
</div>
|
|
196
|
+
<textarea
|
|
197
|
+
bind:this={textarea}
|
|
198
|
+
id={inputId}
|
|
199
|
+
class="textarea textarea-sm w-full resize-none overflow-hidden leading-snug"
|
|
200
|
+
{rows}
|
|
201
|
+
{placeholder}
|
|
202
|
+
{disabled}
|
|
203
|
+
{value}
|
|
204
|
+
aria-describedby={statusId}
|
|
205
|
+
oninput={onTextInput}
|
|
206
|
+
onblur={flush}
|
|
207
|
+
onkeydown={onKeydown}
|
|
208
|
+
></textarea>
|
|
209
|
+
{#if noteWorthy && !value.trim()}
|
|
210
|
+
<p class="text-xs text-base-content/60">{NOTE_HINT}</p>
|
|
211
|
+
{/if}
|
|
212
|
+
</div>
|
|
213
|
+
{/if}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** The soft inline hint under an EMPTY note field on a note-worthy verdict. */
|
|
2
|
+
export declare const NOTE_HINT = "Optional \u2014 the verdict is saved. A note is welcome, not required.";
|
|
3
|
+
export type ReviewNoteSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
|
4
|
+
import type { VerdictOption } from '../../actions/reviewKeys';
|
|
5
|
+
interface Props {
|
|
6
|
+
/** Note text for the current round. */
|
|
7
|
+
value?: string;
|
|
8
|
+
/** Whether the note field is shown. */
|
|
9
|
+
open?: boolean;
|
|
10
|
+
/** The current-round verdict; a change to an `opensNote` one opens the note. */
|
|
11
|
+
verdict?: string | null;
|
|
12
|
+
/** Same array as VerdictControl's — `opensNote` is read from it. */
|
|
13
|
+
options?: readonly VerdictOption[];
|
|
14
|
+
/** Current round key (version, retestSince…). */
|
|
15
|
+
round?: string | number | null;
|
|
16
|
+
/** Persist the note. Throw / reject to show "Not saved". */
|
|
17
|
+
onSave?: (text: string, ctx: {
|
|
18
|
+
round: string | number | null;
|
|
19
|
+
}) => void | Promise<void>;
|
|
20
|
+
/** Every keystroke, before the debounce. */
|
|
21
|
+
onInput?: (text: string) => void;
|
|
22
|
+
/** Debounce for saves while typing. Default 600ms. */
|
|
23
|
+
debounceMs?: number;
|
|
24
|
+
/** Where Esc sends focus. Default: the closest `[data-item-id]` ancestor. */
|
|
25
|
+
returnFocusTo?: () => HTMLElement | null | undefined;
|
|
26
|
+
/** Visible label (also the textarea's accessible name). */
|
|
27
|
+
label?: string;
|
|
28
|
+
placeholder?: string;
|
|
29
|
+
/** Text-entry id; pairs with a Note toggle's aria-controls. Auto when omitted. */
|
|
30
|
+
id?: string;
|
|
31
|
+
rows?: number;
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
class?: string;
|
|
34
|
+
}
|
|
35
|
+
declare const ReviewNote: import("svelte").Component<Props, {}, "value" | "open">;
|
|
36
|
+
type ReviewNote = ReturnType<typeof ReviewNote>;
|
|
37
|
+
export default ReviewNote;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ReviewProgress (jatui-xmrod) — one horizontal tone bar plus a sentence:
|
|
4
|
+
* "12 of 19 decided · 9 pass · 2 fail · 1 blocked · 4 to retest".
|
|
5
|
+
*
|
|
6
|
+
* Generic over verdicts: pass the block's `{id, label, tone}` list and a
|
|
7
|
+
* count per verdict id. The same component serves one block AND the page
|
|
8
|
+
* Finish bar — for the page, sum the blocks with `mergeReviewCounts()` and
|
|
9
|
+
* pass the summed item total. Purely presentational; the host counts.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
reviewSentence,
|
|
13
|
+
summarizeReview,
|
|
14
|
+
TONE_BG,
|
|
15
|
+
type ReviewCounts,
|
|
16
|
+
type ReviewVerdictDef
|
|
17
|
+
} from './reviewSummary';
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
verdicts: ReviewVerdictDef[];
|
|
21
|
+
/** Decided items per verdict id. */
|
|
22
|
+
counts: ReviewCounts;
|
|
23
|
+
/** Items under review (decided + undecided). */
|
|
24
|
+
total: number;
|
|
25
|
+
/** Items in an open retest round — appended as "N to retest". */
|
|
26
|
+
retest?: number;
|
|
27
|
+
/** Word after "N of M" (default "decided"). */
|
|
28
|
+
decidedWord?: string;
|
|
29
|
+
/** Accessible name of the bar. */
|
|
30
|
+
label?: string;
|
|
31
|
+
size?: 'xs' | 'sm' | 'md';
|
|
32
|
+
/** Hide the sentence (bar only; the sentence stays the bar's aria-valuetext). */
|
|
33
|
+
hideSentence?: boolean;
|
|
34
|
+
/** Announce sentence changes politely to screen readers. Default true. */
|
|
35
|
+
live?: boolean;
|
|
36
|
+
class?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let {
|
|
40
|
+
verdicts,
|
|
41
|
+
counts,
|
|
42
|
+
total,
|
|
43
|
+
retest = 0,
|
|
44
|
+
decidedWord = 'decided',
|
|
45
|
+
label = 'Review progress',
|
|
46
|
+
size = 'sm',
|
|
47
|
+
hideSentence = false,
|
|
48
|
+
live = true,
|
|
49
|
+
class: className = ''
|
|
50
|
+
}: Props = $props();
|
|
51
|
+
|
|
52
|
+
const summary = $derived(summarizeReview(verdicts, counts, total, retest));
|
|
53
|
+
const sentence = $derived(reviewSentence(summary, decidedWord));
|
|
54
|
+
const pct = (n: number) => (summary.total > 0 ? (n / summary.total) * 100 : 0);
|
|
55
|
+
const heights = { xs: 'h-1', sm: 'h-1.5', md: 'h-2.5' };
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<div class="review-progress flex min-w-0 flex-col gap-1.5 {className}">
|
|
59
|
+
<div
|
|
60
|
+
class="review-progress-bar bg-base-content/10 flex w-full overflow-hidden {heights[size]}"
|
|
61
|
+
role="progressbar"
|
|
62
|
+
aria-label={label}
|
|
63
|
+
aria-valuemin={0}
|
|
64
|
+
aria-valuemax={summary.total}
|
|
65
|
+
aria-valuenow={summary.decided}
|
|
66
|
+
aria-valuetext={sentence}
|
|
67
|
+
>
|
|
68
|
+
{#each summary.segments as seg (seg.id)}
|
|
69
|
+
<div
|
|
70
|
+
class="review-progress-seg h-full {TONE_BG[seg.tone]}"
|
|
71
|
+
style:width="{pct(seg.count)}%"
|
|
72
|
+
data-verdict={seg.id}
|
|
73
|
+
></div>
|
|
74
|
+
{/each}
|
|
75
|
+
</div>
|
|
76
|
+
{#if !hideSentence}
|
|
77
|
+
<p class="text-base-content/70 text-xs tabular-nums" aria-live={live ? 'polite' : undefined}>
|
|
78
|
+
{sentence}
|
|
79
|
+
</p>
|
|
80
|
+
{/if}
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<style>
|
|
84
|
+
.review-progress-bar {
|
|
85
|
+
border-radius: var(--jatui-radius-selector, 0.375rem);
|
|
86
|
+
}
|
|
87
|
+
.review-progress-seg {
|
|
88
|
+
transition: width var(--motion-move, 240ms) var(--motion-glide, ease-out);
|
|
89
|
+
}
|
|
90
|
+
.review-progress-seg + .review-progress-seg {
|
|
91
|
+
/* hairline between tones so adjacent segments read as separate counts */
|
|
92
|
+
box-shadow: inset var(--jatui-border-w, 1px) 0 0 color-mix(in oklab, var(--color-base-100) 60%, transparent);
|
|
93
|
+
}
|
|
94
|
+
@media (prefers-reduced-motion: reduce) {
|
|
95
|
+
.review-progress-seg {
|
|
96
|
+
transition: none;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
</style>
|