@marktiderman/genesis-forms-web 0.1.1 → 0.2.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/dist/index.js CHANGED
@@ -1,17 +1,19 @@
1
1
  "use client";
2
- import { createContext, useRef, useContext, useState, useEffect, useLayoutEffect, useId } from 'react';
2
+ import { createContext, useRef, useState, useId, useEffect, useContext, useLayoutEffect } from 'react';
3
3
  import { Button } from '@marktiderman/genesis-ui/button';
4
4
  import { Progress } from '@marktiderman/genesis-ui/progress';
5
5
  import { cn } from '@marktiderman/genesis-ui/utils';
6
6
  import { createRendererRegistry, useForm, FormRuntime, useFormContext, useFormSelector, FieldRenderer } from '@marktiderman/genesis-forms-react';
7
7
  import { Checkbox } from '@marktiderman/genesis-ui/checkbox';
8
- import { FormDescription, FormError, FormLabel } from '@marktiderman/genesis-ui/form-field';
9
8
  import { Input } from '@marktiderman/genesis-ui/input';
10
9
  import { Label } from '@marktiderman/genesis-ui/label';
11
10
  import { RadioGroup, RadioGroupItem } from '@marktiderman/genesis-ui/radio-group';
12
11
  import { Textarea } from '@marktiderman/genesis-ui/textarea';
13
- import { choiceOptions, getFieldType } from '@marktiderman/genesis-forms';
12
+ import { choiceOptions, listLimits, tableColumns, tableLimits, parseTimestamp, formatTimestamp, getFieldType } from '@marktiderman/genesis-forms';
13
+ import { FormDescription, FormError, FormLabel } from '@marktiderman/genesis-ui/form-field';
14
14
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
15
+ import { Stack } from '@marktiderman/genesis-ui/layout';
16
+ import { SortableList } from '@marktiderman/genesis-ui/sortable-list';
15
17
 
16
18
  // src/Form.tsx
17
19
  var FormPresentationContext = createContext({ presentation: "embedded", layout: "classic" });
@@ -118,6 +120,635 @@ function describedBy(props) {
118
120
  ].filter(Boolean);
119
121
  return ids.length ? ids.join(" ") : void 0;
120
122
  }
123
+ var MediaTimeContext = createContext(null);
124
+ function useMediaTime() {
125
+ return useContext(MediaTimeContext);
126
+ }
127
+ var SourceTextContext = createContext(null);
128
+ function useSourceText() {
129
+ return useContext(SourceTextContext);
130
+ }
131
+ function PlusIcon() {
132
+ return /* @__PURE__ */ jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M12 5v14M5 12h14" }) });
133
+ }
134
+ function XIcon() {
135
+ return /* @__PURE__ */ jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" }) });
136
+ }
137
+ function move(list, from, to) {
138
+ const next = list.slice();
139
+ const [item] = next.splice(from, 1);
140
+ next.splice(to, 0, item);
141
+ return next;
142
+ }
143
+ function useEntryKeys(count) {
144
+ const keys = useRef([]);
145
+ const next = useRef(0);
146
+ const make = () => `e${next.current++}`;
147
+ while (keys.current.length < count) keys.current.push(make());
148
+ if (keys.current.length > count) keys.current = keys.current.slice(0, count);
149
+ return {
150
+ keys: keys.current,
151
+ /** Add a key at `at` and return it. */
152
+ insert(at) {
153
+ const key = make();
154
+ keys.current = [...keys.current.slice(0, at), key, ...keys.current.slice(at)];
155
+ return key;
156
+ },
157
+ remove(at) {
158
+ keys.current = keys.current.filter((_, i) => i !== at);
159
+ },
160
+ move(from, to) {
161
+ keys.current = move(keys.current, from, to);
162
+ }
163
+ };
164
+ }
165
+ function useFocusLater() {
166
+ const elements = useRef(/* @__PURE__ */ new Map());
167
+ const [target, setTarget] = useState(null);
168
+ useEffect(() => {
169
+ if (target === null) return;
170
+ elements.current.get(target)?.focus();
171
+ setTarget(null);
172
+ }, [target]);
173
+ return {
174
+ ref: (key) => (el) => {
175
+ if (el) elements.current.set(key, el);
176
+ else elements.current.delete(key);
177
+ },
178
+ focus: setTarget
179
+ };
180
+ }
181
+ var invalid = (props) => props.binding.error ? true : void 0;
182
+ function RemoveButton({ label, onClick, disabled }) {
183
+ return /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "icon", "aria-label": label, disabled, onClick, className: "shrink-0", children: /* @__PURE__ */ jsx(XIcon, {}) });
184
+ }
185
+ function AddButton({ children, onClick, disabled }) {
186
+ return /* @__PURE__ */ jsxs(Button, { type: "button", variant: "outline", size: "sm", disabled, onClick, className: "self-start", children: [
187
+ /* @__PURE__ */ jsx(PlusIcon, {}),
188
+ " ",
189
+ children
190
+ ] });
191
+ }
192
+ function excerpt(text, max = 40) {
193
+ const t = text.replace(/\s+/g, " ").trim();
194
+ return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
195
+ }
196
+ function ListField(props) {
197
+ const { binding, inputId } = props;
198
+ const field = binding.field;
199
+ const { max, itemMaxLength } = listLimits(field);
200
+ const stored = Array.isArray(binding.value) ? binding.value.map((v) => typeof v === "string" ? v : "") : [];
201
+ const items = stored.length ? stored : [""];
202
+ const entries = useEntryKeys(items.length);
203
+ const focusLater = useFocusLater();
204
+ const addRef = useRef(null);
205
+ const placeholder = typeof field.props?.placeholder === "string" ? field.props.placeholder : field.placeholder ?? void 0;
206
+ const save = (next) => binding.setValue(next.length ? next : void 0);
207
+ const add = () => {
208
+ if (items.length >= max) return;
209
+ const key = entries.insert(items.length);
210
+ save([...items, ""]);
211
+ focusLater.focus(key);
212
+ };
213
+ const remove = (i) => {
214
+ entries.remove(i);
215
+ const next = items.filter((_, j) => j !== i);
216
+ save(next);
217
+ const neighbour = entries.keys[Math.min(i, next.length - 1)];
218
+ if (neighbour !== void 0 && next.length) focusLater.focus(neighbour);
219
+ else addRef.current?.focus();
220
+ };
221
+ const onKeyDown = (i) => (e) => {
222
+ if (e.key !== "Enter" || e.nativeEvent.isComposing || e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return;
223
+ if (i < items.length - 1) {
224
+ e.preventDefault();
225
+ focusLater.focus(entries.keys[i + 1]);
226
+ } else if (items.length < max) {
227
+ e.preventDefault();
228
+ add();
229
+ }
230
+ };
231
+ const rows = items.map((text, index) => ({ key: entries.keys[index], text, index }));
232
+ return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsxs(Stack, { gap: "sm", children: [
233
+ /* @__PURE__ */ jsx(
234
+ SortableList,
235
+ {
236
+ items: rows,
237
+ getKey: (r) => r.key,
238
+ getItemLabel: (r) => r.text.trim() ? excerpt(r.text, 30) : `Item ${r.index + 1}`,
239
+ label: `${binding.label}: items`,
240
+ disabled: items.length < 2,
241
+ onReorder: (from, to) => {
242
+ entries.move(from, to);
243
+ save(move(items, from, to));
244
+ },
245
+ renderItem: (r, { handle, index }) => /* @__PURE__ */ jsxs(Stack, { direction: "row", align: "center", gap: "sm", className: "w-full min-w-0", children: [
246
+ handle,
247
+ /* @__PURE__ */ jsx(
248
+ Input,
249
+ {
250
+ ref: focusLater.ref(r.key),
251
+ id: index === 0 ? `${inputId}-0` : void 0,
252
+ "data-gf-focus": index === 0 ? "" : void 0,
253
+ "aria-label": `Item ${index + 1}`,
254
+ "aria-invalid": invalid(props),
255
+ maxLength: itemMaxLength,
256
+ placeholder,
257
+ value: r.text,
258
+ onChange: (e) => save(items.map((t, j) => j === index ? e.target.value : t)),
259
+ onKeyDown: onKeyDown(index)
260
+ }
261
+ ),
262
+ /* @__PURE__ */ jsx(
263
+ RemoveButton,
264
+ {
265
+ label: `Remove item ${index + 1}`,
266
+ disabled: items.length === 1 && r.text === "",
267
+ onClick: () => remove(index)
268
+ }
269
+ )
270
+ ] })
271
+ }
272
+ ),
273
+ /* @__PURE__ */ jsxs(
274
+ Button,
275
+ {
276
+ ref: addRef,
277
+ type: "button",
278
+ variant: "outline",
279
+ size: "sm",
280
+ className: "self-start",
281
+ disabled: items.length >= max,
282
+ onClick: add,
283
+ children: [
284
+ /* @__PURE__ */ jsx(PlusIcon, {}),
285
+ " Add item"
286
+ ]
287
+ }
288
+ )
289
+ ] }) });
290
+ }
291
+ function toRow(value) {
292
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
293
+ return Object.fromEntries(Object.entries(value).filter((e) => typeof e[1] === "string"));
294
+ }
295
+ function TableField(props) {
296
+ const { binding, inputId } = props;
297
+ const columns = tableColumns(binding.field);
298
+ const { minRows, maxRows } = tableLimits(binding.field);
299
+ const stored = Array.isArray(binding.value) ? binding.value.map(toRow) : [];
300
+ const shown = Math.min(Math.max(stored.length, minRows, 1), Math.max(maxRows, 1));
301
+ const rows = Array.from({ length: shown }, (_, i) => stored[i] ?? {});
302
+ const entries = useEntryKeys(rows.length);
303
+ const focusLater = useFocusLater();
304
+ const addRef = useRef(null);
305
+ const save = (next) => binding.setValue(next.length ? next : void 0);
306
+ const firstKey = columns[0]?.key;
307
+ const add = () => {
308
+ if (rows.length >= maxRows) return;
309
+ const key = entries.insert(rows.length);
310
+ save([...rows, {}]);
311
+ focusLater.focus(key);
312
+ };
313
+ const remove = (i) => {
314
+ entries.remove(i);
315
+ const next = rows.filter((_, j) => j !== i);
316
+ save(next);
317
+ const neighbour = entries.keys[Math.min(i, next.length - 1)];
318
+ if (neighbour !== void 0 && next.length) focusLater.focus(neighbour);
319
+ else addRef.current?.focus();
320
+ };
321
+ if (!columns.length) {
322
+ return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "This question has no columns yet." }) });
323
+ }
324
+ const items = rows.map((row, index) => ({ key: entries.keys[index], row, index }));
325
+ return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsxs(Stack, { gap: "md", children: [
326
+ /* @__PURE__ */ jsx(
327
+ SortableList,
328
+ {
329
+ items,
330
+ getKey: (r) => r.key,
331
+ getItemLabel: (r) => `Row ${r.index + 1}`,
332
+ label: `${binding.label}: rows`,
333
+ disabled: rows.length < 2,
334
+ onReorder: (from, to) => {
335
+ entries.move(from, to);
336
+ save(move(rows, from, to));
337
+ },
338
+ renderItem: (r, { handle, index }) => /* @__PURE__ */ jsxs(Stack, { direction: "row", align: "start", gap: "sm", className: "w-full min-w-0", children: [
339
+ handle ? /* @__PURE__ */ jsx("div", { className: "pt-7", children: handle }) : null,
340
+ /* @__PURE__ */ jsxs("fieldset", { className: "m-0 min-w-0 flex-1 rounded-md border border-input p-3", children: [
341
+ /* @__PURE__ */ jsx("legend", { className: "px-1 text-sm font-medium", children: `Row ${index + 1}` }),
342
+ /* @__PURE__ */ jsx("div", { className: "grid gap-3 sm:grid-cols-2", children: columns.map((column, c) => {
343
+ const id = `${inputId}-${r.key}-${column.key}`;
344
+ const common = {
345
+ id,
346
+ // The first cell of the first row is what an error summary link and the focus layout reach.
347
+ "data-gf-focus": index === 0 && c === 0 ? "" : void 0,
348
+ "aria-invalid": invalid(props),
349
+ value: r.row[column.key] ?? "",
350
+ maxLength: 2e3
351
+ };
352
+ const set = (text) => save(rows.map((row, j) => j === index ? { ...row, [column.key]: text } : row));
353
+ return /* @__PURE__ */ jsxs("div", { className: cn("space-y-1.5", column.long && "sm:col-span-2"), children: [
354
+ /* @__PURE__ */ jsx(Label, { htmlFor: id, children: column.label }),
355
+ column.long ? /* @__PURE__ */ jsx(
356
+ Textarea,
357
+ {
358
+ ...common,
359
+ ref: column.key === firstKey ? focusLater.ref(r.key) : void 0,
360
+ rows: 2,
361
+ onChange: (e) => set(e.target.value)
362
+ }
363
+ ) : /* @__PURE__ */ jsx(
364
+ Input,
365
+ {
366
+ ...common,
367
+ ref: column.key === firstKey ? focusLater.ref(r.key) : void 0,
368
+ onChange: (e) => set(e.target.value)
369
+ }
370
+ )
371
+ ] }, column.key);
372
+ }) })
373
+ ] }),
374
+ /* @__PURE__ */ jsx("div", { className: "pt-7", children: /* @__PURE__ */ jsx(
375
+ RemoveButton,
376
+ {
377
+ label: `Remove row ${index + 1}`,
378
+ disabled: rows.length <= Math.max(minRows, 1),
379
+ onClick: () => remove(index)
380
+ }
381
+ ) })
382
+ ] })
383
+ }
384
+ ),
385
+ /* @__PURE__ */ jsxs(
386
+ Button,
387
+ {
388
+ ref: addRef,
389
+ type: "button",
390
+ variant: "outline",
391
+ size: "sm",
392
+ className: "self-start",
393
+ disabled: rows.length >= maxRows,
394
+ onClick: add,
395
+ children: [
396
+ /* @__PURE__ */ jsx(PlusIcon, {}),
397
+ " Add row"
398
+ ]
399
+ }
400
+ )
401
+ ] }) });
402
+ }
403
+ function toNote(value) {
404
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { at: 0, text: "" };
405
+ return { at: typeof value.at === "number" ? value.at : 0, text: typeof value.text === "string" ? value.text : "" };
406
+ }
407
+ function MediaNotesField(props) {
408
+ const { binding, inputId } = props;
409
+ const media = useMediaTime();
410
+ const maxNotes = typeof binding.field.props?.maxNotes === "number" ? binding.field.props.maxNotes : 20;
411
+ const textMaxLength = typeof binding.field.props?.textMaxLength === "number" ? binding.field.props.textMaxLength : 1e3;
412
+ const notes = Array.isArray(binding.value) ? binding.value.map(toNote) : [];
413
+ const entries = useEntryKeys(notes.length);
414
+ const now = () => {
415
+ const t = media?.currentTime?.();
416
+ return typeof t === "number" && Number.isFinite(t) && t >= 0 ? formatTimestamp(t) : "";
417
+ };
418
+ const [time, setTime] = useState(now);
419
+ const [timeTouched, setTimeTouched] = useState(false);
420
+ const [text, setText] = useState("");
421
+ const [timeError, setTimeError] = useState(null);
422
+ const textRef = useRef(null);
423
+ const save = (next) => binding.setValue(next.length ? next : void 0);
424
+ const full = notes.length >= maxNotes;
425
+ const follow = () => {
426
+ if (timeTouched || !media?.currentTime) return;
427
+ const t = now();
428
+ if (t) setTime(t);
429
+ };
430
+ const parsed = parseTimestamp(time);
431
+ const add = () => {
432
+ if (full || !text.trim()) return;
433
+ const at = time.trim() === "" && !media?.currentTime ? 0 : parsed;
434
+ if (at === null) {
435
+ setTimeError("Enter a time like 1:23 or 1:02:03.");
436
+ return;
437
+ }
438
+ const at_ = notes.findIndex((n) => n.at > at);
439
+ const index = at_ === -1 ? notes.length : at_;
440
+ entries.insert(index);
441
+ save([...notes.slice(0, index), { at, text: text.trim() }, ...notes.slice(index)]);
442
+ setText("");
443
+ setTimeError(null);
444
+ setTimeTouched(false);
445
+ setTime(now());
446
+ textRef.current?.focus();
447
+ };
448
+ const onEnter = (e) => {
449
+ if (e.key !== "Enter" || e.nativeEvent.isComposing) return;
450
+ e.preventDefault();
451
+ add();
452
+ };
453
+ const addLabel = media?.currentTime && parsed !== null ? `Add note at ${formatTimestamp(parsed)}` : "Add note";
454
+ const timeId = `${inputId}-time`;
455
+ const textId = `${inputId}-text`;
456
+ return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsxs(Stack, { gap: "md", children: [
457
+ notes.length ? /* @__PURE__ */ jsx("ol", { "aria-label": `${binding.label}: notes`, className: "m-0 list-none space-y-2 p-0", children: notes.map((note, index) => {
458
+ const at = formatTimestamp(note.at);
459
+ return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(Stack, { direction: "row", align: "center", gap: "sm", children: [
460
+ media?.seek ? /* @__PURE__ */ jsx(
461
+ Button,
462
+ {
463
+ type: "button",
464
+ variant: "link",
465
+ size: "sm",
466
+ className: "shrink-0 px-0 tabular-nums",
467
+ "aria-label": `Play from ${at}`,
468
+ onClick: () => media.seek?.(note.at),
469
+ children: at
470
+ }
471
+ ) : /* @__PURE__ */ jsx("span", { className: "shrink-0 text-sm tabular-nums text-muted-foreground", children: at }),
472
+ /* @__PURE__ */ jsx(
473
+ Input,
474
+ {
475
+ "aria-label": `Note at ${at}`,
476
+ "aria-invalid": invalid(props),
477
+ maxLength: textMaxLength,
478
+ value: note.text,
479
+ onChange: (e) => save(notes.map((n, j) => j === index ? { ...n, text: e.target.value } : n)),
480
+ onKeyDown: (e) => {
481
+ if (e.key === "Enter") e.preventDefault();
482
+ }
483
+ }
484
+ ),
485
+ /* @__PURE__ */ jsx(
486
+ RemoveButton,
487
+ {
488
+ label: `Remove note at ${at}`,
489
+ onClick: () => {
490
+ entries.remove(index);
491
+ save(notes.filter((_, j) => j !== index));
492
+ textRef.current?.focus();
493
+ }
494
+ }
495
+ )
496
+ ] }) }, entries.keys[index]);
497
+ }) }) : null,
498
+ full ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: `You've added the most notes this question takes (${maxNotes}).` }) : /* @__PURE__ */ jsxs("div", { className: "grid gap-2 sm:grid-cols-[7rem_1fr_auto] sm:items-end", children: [
499
+ /* @__PURE__ */ jsxs("div", { className: "space-y-1.5", children: [
500
+ /* @__PURE__ */ jsx(Label, { htmlFor: timeId, children: "Time" }),
501
+ /* @__PURE__ */ jsx(
502
+ Input,
503
+ {
504
+ id: timeId,
505
+ inputMode: "numeric",
506
+ placeholder: "m:ss",
507
+ value: time,
508
+ "aria-invalid": timeError ? true : void 0,
509
+ "aria-describedby": timeError ? `${timeId}-error` : void 0,
510
+ onChange: (e) => {
511
+ setTime(e.target.value);
512
+ setTimeTouched(true);
513
+ setTimeError(null);
514
+ },
515
+ onKeyDown: onEnter
516
+ }
517
+ )
518
+ ] }),
519
+ /* @__PURE__ */ jsxs("div", { className: "space-y-1.5", children: [
520
+ /* @__PURE__ */ jsx(Label, { htmlFor: textId, children: "Note" }),
521
+ /* @__PURE__ */ jsx(
522
+ Input,
523
+ {
524
+ ref: textRef,
525
+ id: textId,
526
+ "data-gf-focus": "",
527
+ maxLength: textMaxLength,
528
+ value: text,
529
+ onFocus: follow,
530
+ onChange: (e) => {
531
+ if (!text) follow();
532
+ setText(e.target.value);
533
+ },
534
+ onKeyDown: onEnter
535
+ }
536
+ )
537
+ ] }),
538
+ /* @__PURE__ */ jsx(AddButton, { onClick: add, disabled: !text.trim(), children: addLabel }),
539
+ timeError ? /* @__PURE__ */ jsx("p", { id: `${timeId}-error`, className: "text-sm text-destructive sm:col-span-3", children: timeError }) : null
540
+ ] })
541
+ ] }) });
542
+ }
543
+ function toHighlight(value) {
544
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
545
+ const { start, end, quote, note } = value;
546
+ if (typeof start !== "number" || typeof end !== "number" || typeof quote !== "string") return null;
547
+ return { start, end, quote, note: typeof note === "string" ? note : "" };
548
+ }
549
+ function segments(text, highlights) {
550
+ const cuts = /* @__PURE__ */ new Set([0, text.length]);
551
+ for (const h of highlights) {
552
+ if (h.start >= 0 && h.end <= text.length && h.start < h.end) {
553
+ cuts.add(h.start);
554
+ cuts.add(h.end);
555
+ }
556
+ }
557
+ const points = [...cuts].sort((a, b) => a - b);
558
+ const out = [];
559
+ for (let i = 0; i < points.length - 1; i++) {
560
+ const start = points[i];
561
+ const end = points[i + 1];
562
+ if (end <= start) continue;
563
+ out.push({ start, text: text.slice(start, end), marked: highlights.some((h) => h.start <= start && h.end >= end) });
564
+ }
565
+ return out;
566
+ }
567
+ function textOffset(root, node, offset) {
568
+ const range = document.createRange();
569
+ range.selectNodeContents(root);
570
+ range.setEnd(node, offset);
571
+ return range.toString().length;
572
+ }
573
+ function TextHighlightsField(props) {
574
+ const { binding, inputId } = props;
575
+ const hosted = useSourceText();
576
+ const own = typeof binding.field.props?.text === "string" ? binding.field.props.text : "";
577
+ const text = hosted ?? own;
578
+ const maxHighlights = typeof binding.field.props?.maxHighlights === "number" ? binding.field.props.maxHighlights : 20;
579
+ const highlights = (Array.isArray(binding.value) ? binding.value : []).flatMap((v) => {
580
+ const h = toHighlight(v);
581
+ return h ? [h] : [];
582
+ });
583
+ const entries = useEntryKeys(highlights.length);
584
+ const textRef = useRef(null);
585
+ const noteRef = useRef(null);
586
+ const toggleRef = useRef(null);
587
+ const [selection, setSelection] = useState(null);
588
+ const [typing, setTyping] = useState(false);
589
+ const [passage, setPassage] = useState("");
590
+ const [note, setNote] = useState("");
591
+ const full = highlights.length >= maxHighlights;
592
+ const save = (next) => binding.setValue(next.length ? next : void 0);
593
+ const hintId = useId();
594
+ const typed = passage.trim();
595
+ const found = typing && typed ? text.indexOf(typed) : -1;
596
+ const draft = typing ? found >= 0 ? { start: found, end: found + typed.length } : null : selection;
597
+ const draftKey = draft ? `${draft.start}:${draft.end}` : "";
598
+ const wasSelection = useRef(false);
599
+ useEffect(() => {
600
+ if (draftKey && wasSelection.current) noteRef.current?.focus();
601
+ }, [draftKey]);
602
+ const readSelection = () => {
603
+ const root = textRef.current;
604
+ const sel = typeof window === "undefined" ? null : window.getSelection();
605
+ if (!root || !sel || sel.rangeCount === 0 || sel.isCollapsed || full) return;
606
+ const range = sel.getRangeAt(0);
607
+ if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return;
608
+ let start = textOffset(root, range.startContainer, range.startOffset);
609
+ let end = textOffset(root, range.endContainer, range.endOffset);
610
+ if (start > end) [start, end] = [end, start];
611
+ while (start < end && /\s/.test(text[start])) start++;
612
+ while (end > start && /\s/.test(text[end - 1])) end--;
613
+ if (end <= start) return;
614
+ wasSelection.current = true;
615
+ setTyping(false);
616
+ setSelection({ start, end });
617
+ };
618
+ const reset = () => {
619
+ setSelection(null);
620
+ setPassage("");
621
+ setNote("");
622
+ setTyping(false);
623
+ wasSelection.current = false;
624
+ };
625
+ const commit = () => {
626
+ if (!draft || !note.trim() || full) return;
627
+ const h = { start: draft.start, end: draft.end, quote: text.slice(draft.start, draft.end), note: note.trim() };
628
+ const at = highlights.findIndex((x) => x.start > h.start);
629
+ const index = at === -1 ? highlights.length : at;
630
+ entries.insert(index);
631
+ save([...highlights.slice(0, index), h, ...highlights.slice(index)]);
632
+ reset();
633
+ window.getSelection?.()?.removeAllRanges();
634
+ toggleRef.current?.focus();
635
+ };
636
+ if (!text) {
637
+ return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "There is no passage to highlight." }) });
638
+ }
639
+ return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsxs(Stack, { gap: "md", children: [
640
+ /* @__PURE__ */ jsx(
641
+ "p",
642
+ {
643
+ ref: textRef,
644
+ id: `${inputId}-text`,
645
+ "aria-describedby": hintId,
646
+ className: "m-0 whitespace-pre-wrap rounded-md border border-input bg-muted/30 p-3 text-base leading-relaxed",
647
+ onMouseUp: readSelection,
648
+ onTouchEnd: readSelection,
649
+ onKeyUp: readSelection,
650
+ children: segments(text, highlights).map(
651
+ (s) => s.marked ? /* @__PURE__ */ jsx("mark", { className: "rounded-sm bg-primary/20 text-foreground", children: s.text }, s.start) : /* @__PURE__ */ jsx("span", { children: s.text }, s.start)
652
+ )
653
+ }
654
+ ),
655
+ /* @__PURE__ */ jsx("p", { id: hintId, className: "m-0 text-sm text-muted-foreground", children: full ? `You've added the most highlights this question takes (${maxHighlights}).` : "Select words in the passage to highlight them, or use Highlight a passage to type them." }),
656
+ !full ? /* @__PURE__ */ jsx(
657
+ Button,
658
+ {
659
+ ref: toggleRef,
660
+ type: "button",
661
+ variant: "outline",
662
+ size: "sm",
663
+ className: "self-start",
664
+ "aria-expanded": typing,
665
+ onClick: () => {
666
+ setSelection(null);
667
+ wasSelection.current = false;
668
+ setTyping((t) => !t);
669
+ },
670
+ children: "Highlight a passage"
671
+ }
672
+ ) : null,
673
+ typing && !full ? /* @__PURE__ */ jsxs("div", { className: "space-y-1.5", children: [
674
+ /* @__PURE__ */ jsx(Label, { htmlFor: `${inputId}-passage`, children: "Passage" }),
675
+ /* @__PURE__ */ jsx(
676
+ Input,
677
+ {
678
+ id: `${inputId}-passage`,
679
+ value: passage,
680
+ "aria-describedby": `${inputId}-passage-hint`,
681
+ "aria-invalid": typed && found < 0 ? true : void 0,
682
+ onChange: (e) => setPassage(e.target.value),
683
+ onKeyDown: (e) => {
684
+ if (e.key === "Enter") {
685
+ e.preventDefault();
686
+ if (found >= 0) noteRef.current?.focus();
687
+ }
688
+ }
689
+ }
690
+ ),
691
+ /* @__PURE__ */ jsx("p", { id: `${inputId}-passage-hint`, className: "m-0 text-sm text-muted-foreground", children: typed && found < 0 ? "Not found in the passage. Type the words exactly as they appear." : "Type the words exactly as they appear." })
692
+ ] }) : null,
693
+ draft && !full ? /* @__PURE__ */ jsxs("div", { role: "group", "aria-label": "New highlight", className: "space-y-2 rounded-md border border-input p-3", children: [
694
+ /* @__PURE__ */ jsx("blockquote", { className: "m-0 border-l-2 border-primary pl-3 text-sm", children: /* @__PURE__ */ jsx("mark", { className: "bg-primary/20 text-foreground", children: text.slice(draft.start, draft.end) }) }),
695
+ /* @__PURE__ */ jsx(Label, { htmlFor: `${inputId}-note`, children: "Your note" }),
696
+ /* @__PURE__ */ jsx(
697
+ Textarea,
698
+ {
699
+ ref: noteRef,
700
+ id: `${inputId}-note`,
701
+ rows: 2,
702
+ maxLength: 1e3,
703
+ value: note,
704
+ onChange: (e) => setNote(e.target.value)
705
+ }
706
+ ),
707
+ /* @__PURE__ */ jsxs(Stack, { direction: "row", gap: "sm", children: [
708
+ /* @__PURE__ */ jsx(Button, { type: "button", size: "sm", disabled: !note.trim(), onClick: commit, children: "Highlight" }),
709
+ /* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: "ghost", onClick: reset, children: "Cancel" })
710
+ ] })
711
+ ] }) : null,
712
+ highlights.length ? /* @__PURE__ */ jsx("ol", { "aria-label": `${binding.label}: highlights`, className: "m-0 list-none space-y-3 p-0", children: highlights.map((h, index) => {
713
+ const name = `\u201C${excerpt(h.quote)}\u201D`;
714
+ return /* @__PURE__ */ jsxs("li", { className: "space-y-1.5", children: [
715
+ /* @__PURE__ */ jsx("blockquote", { className: "m-0 border-l-2 border-primary pl-3 text-sm", children: h.quote }),
716
+ /* @__PURE__ */ jsxs(Stack, { direction: "row", align: "center", gap: "sm", children: [
717
+ /* @__PURE__ */ jsx(
718
+ Input,
719
+ {
720
+ "aria-label": `Note on ${name}`,
721
+ "aria-invalid": invalid(props) || (h.note.trim() ? void 0 : true),
722
+ maxLength: 1e3,
723
+ value: h.note,
724
+ onChange: (e) => save(highlights.map((x, j) => j === index ? { ...x, note: e.target.value } : x)),
725
+ onKeyDown: (e) => {
726
+ if (e.key === "Enter") e.preventDefault();
727
+ }
728
+ }
729
+ ),
730
+ /* @__PURE__ */ jsx(
731
+ RemoveButton,
732
+ {
733
+ label: `Remove highlight ${name}`,
734
+ onClick: () => {
735
+ entries.remove(index);
736
+ save(highlights.filter((_, j) => j !== index));
737
+ toggleRef.current?.focus();
738
+ }
739
+ }
740
+ )
741
+ ] })
742
+ ] }, entries.keys[index]);
743
+ }) }) : null
744
+ ] }) });
745
+ }
746
+ var structuredRenderers = [
747
+ { type: "list", Component: ListField },
748
+ { type: "table", Component: TableField },
749
+ { type: "media-notes", Component: MediaNotesField },
750
+ { type: "text-highlights", Component: TextHighlightsField }
751
+ ];
121
752
  function groupAria(props) {
122
753
  return {
123
754
  "aria-labelledby": `${props.inputId}-label`,
@@ -429,7 +1060,8 @@ var builtInRenderers = [
429
1060
  { type: "nps", Component: ScaleField },
430
1061
  { type: "single-choice", Component: SingleChoiceField },
431
1062
  { type: "multi-choice", Component: MultiChoiceField },
432
- { type: "yes-no", Component: YesNoField }
1063
+ { type: "yes-no", Component: YesNoField },
1064
+ ...structuredRenderers
433
1065
  ];
434
1066
  var defaultRegistry = createRendererRegistry(builtInRenderers);
435
1067
  function renderSlot(Default, override, props, extra) {
@@ -485,6 +1117,7 @@ function DefaultBrand({ name, logo, className }) {
485
1117
  function DefaultProgress({ step, total, className }) {
486
1118
  const { presentation } = useFormPresentation();
487
1119
  const page = presentation === "page";
1120
+ if (!total) return null;
488
1121
  const percent = total ? Math.round((page ? step - 1 : step) / total * 100) : 0;
489
1122
  const bar = /* @__PURE__ */ jsx(
490
1123
  Progress,
@@ -926,7 +1559,10 @@ function FormBody({
926
1559
  },
927
1560
  slotProps?.errorSummary
928
1561
  ) : null;
929
- const progress = layout === "focus" ? renderSlot(DefaultProgress, slots?.progress, { step, total: path.length }, slotProps?.progress) : null;
1562
+ const progress = (
1563
+ // No questions on the path: no "Question 1 of 0".
1564
+ layout === "focus" && path.length > 0 ? renderSlot(DefaultProgress, slots?.progress, { step, total: path.length }, slotProps?.progress) : null
1565
+ );
930
1566
  const fields = (
931
1567
  // Disabled once the response is completed (sending, or after a failed send): read-only, not dead.
932
1568
  /* @__PURE__ */ jsx("fieldset", { disabled: locked, className: "min-w-0 border-0 p-0", children: layout === "focus" ? /* @__PURE__ */ jsx(
@@ -1019,6 +1655,6 @@ function FormBody({
1019
1655
  }
1020
1656
  var classicSection = "rounded-xl border bg-card px-5 py-6 text-card-foreground sm:px-8 has-[[aria-invalid=true]]:border-destructive";
1021
1657
 
1022
- export { FieldChrome, Form, builtInRenderers, defaultRegistry, describedBy, useFormPresentation };
1658
+ export { FieldChrome, Form, MediaTimeContext, SourceTextContext, builtInRenderers, defaultRegistry, describedBy, useFormPresentation, useMediaTime, useSourceText };
1023
1659
  //# sourceMappingURL=index.js.map
1024
1660
  //# sourceMappingURL=index.js.map