@marktiderman/genesis-forms-web 0.1.0 → 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/README.md +25 -5
- package/dist/index.d.ts +50 -21
- package/dist/index.js +646 -10
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
package/dist/index.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { createContext, useRef,
|
|
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,
|
|
@@ -715,6 +1348,7 @@ function FormBody({
|
|
|
715
1348
|
const errorsKey = useFormSelector(engine, (s) => JSON.stringify(s.errors));
|
|
716
1349
|
const ending = useFormSelector(engine, (s) => s.ending);
|
|
717
1350
|
const historyLength = useFormSelector(engine, (s) => s.history.length);
|
|
1351
|
+
const description = useFormSelector(engine, () => def.description ? engine.text(def.description) : void 0);
|
|
718
1352
|
const path = JSON.parse(pathKey);
|
|
719
1353
|
const errors = JSON.parse(errorsKey);
|
|
720
1354
|
const formRef = useRef(null);
|
|
@@ -899,8 +1533,7 @@ function FormBody({
|
|
|
899
1533
|
const field = /* @__PURE__ */ jsx(FieldRenderer, { id, onAdvance: focus ? scheduleAdvance : void 0 });
|
|
900
1534
|
return /* @__PURE__ */ jsx("div", { "data-block": id, className: pageClassic ? classicSection : void 0, children: field }, id);
|
|
901
1535
|
};
|
|
902
|
-
const
|
|
903
|
-
const step = current ? Math.max(1, visiblePath.indexOf(current) + 1) : 1;
|
|
1536
|
+
const step = current ? Math.max(1, path.indexOf(current) + 1) : 1;
|
|
904
1537
|
const isLast = layout === "classic" || current === path[path.length - 1];
|
|
905
1538
|
const errorItems = path.filter((id) => errors[id]).map((id) => ({
|
|
906
1539
|
id,
|
|
@@ -913,7 +1546,7 @@ function FormBody({
|
|
|
913
1546
|
const header = renderSlot(
|
|
914
1547
|
DefaultHeader,
|
|
915
1548
|
slots?.header,
|
|
916
|
-
{ title: def.title, description
|
|
1549
|
+
{ title: def.title, description },
|
|
917
1550
|
slotProps?.header
|
|
918
1551
|
);
|
|
919
1552
|
const errorSummary = errorItems.length > 0 && layout === "classic" ? renderSlot(
|
|
@@ -926,7 +1559,10 @@ function FormBody({
|
|
|
926
1559
|
},
|
|
927
1560
|
slotProps?.errorSummary
|
|
928
1561
|
) : null;
|
|
929
|
-
const progress =
|
|
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(
|
|
@@ -942,7 +1578,7 @@ function FormBody({
|
|
|
942
1578
|
children: current ? renderBlock(current, true) : null
|
|
943
1579
|
},
|
|
944
1580
|
pageFocus ? current ?? "" : void 0
|
|
945
|
-
) : /* @__PURE__ */ jsx("div", { className: pageClassic ? "space-y-4" : "space-y-8", children:
|
|
1581
|
+
) : /* @__PURE__ */ jsx("div", { className: pageClassic ? "space-y-4" : "space-y-8", children: path.map((id) => renderBlock(id, false)) }) })
|
|
946
1582
|
);
|
|
947
1583
|
const failure = phase === "failed" ? /* @__PURE__ */ jsxs("p", { role: "alert", className: cn("text-sm font-medium text-destructive", page && "text-base"), children: [
|
|
948
1584
|
"We couldn't send your answers. They're kept here: press ",
|
|
@@ -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
|