formlift 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/CHANGELOG.md CHANGED
@@ -1,9 +1,18 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to this project are documented here. The format follows
4
- [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/).
3
+ Written automatically by [release-please](https://github.com/googleapis/release-please) from the
4
+ commit messages. The project uses [Semantic Versioning](https://semver.org/).
5
5
 
6
- ## [Unreleased]
6
+ ## [0.2.0](https://github.com/harendra-singh-negi/formlift/compare/v0.1.1...v0.2.0) (2026-11-12)
7
+
8
+
9
+ ### ⚠ BREAKING CHANGES
10
+
11
+ * matches, NO_FILTER and QuestionFilter are removed. Filter with filterQuestions and filter objects instead.
12
+
13
+ ### Features
14
+
15
+ * customisable UI, headless hooks and a filter registry ([50a2571](https://github.com/harendra-singh-negi/formlift/commit/50a2571964a09d17acbc82fd65d83bac1ab1b70a))
7
16
 
8
17
  ## [0.1.1] - 2026-11-10
9
18
 
package/README.md CHANGED
@@ -12,7 +12,7 @@ Auto-detect fields · Drag-and-drop builder · Conditional logic · Validation
12
12
  [![license](https://img.shields.io/npm/l/formlift?color=0f766e)](./LICENSE)
13
13
  [![CI](https://github.com/harendra-singh-negi/formlift/actions/workflows/ci.yml/badge.svg)](https://github.com/harendra-singh-negi/formlift/actions/workflows/ci.yml)
14
14
 
15
- **[▶ Live demo](https://harendra-singh-negi.github.io/formlift/)** · [Quick start](#quick-start) · [API](#api-reference) · [Server](#server-side) · [Theming](#theming) · [FAQ](#faq)
15
+ **[▶ Live demo](https://harendra-singh-negi.github.io/formlift/)** · [Quick start](#quick-start) · [API](#api-reference) · [Server](#server-side) · [Theming](#theming) · [Your own UI](#your-own-ui) · [FAQ](#faq)
16
16
 
17
17
  <img src="./docs/builder.png" alt="formlift builder: suggested fields over a PDF page" width="860" />
18
18
 
@@ -242,6 +242,13 @@ uploads are embedded as PDF attachments.
242
242
  | `onError` | `(err: unknown) => void` | `console.error` | Error messages |
243
243
  | `className` | `string` | `'h-full'` | Classes on the root element |
244
244
  | `ref` | `Ref<FormEditorHandle>` | – | `{ save(), dirty, value }`, for example to save before a workflow step |
245
+ | `hide` | `EditorPart[]` | `[]` | Parts to leave out: `back`, `title`, `status`, `undo`, `snap`, `zoom`, `editText`, `autoDetect`, `preview`, `save`, `tools`, `fieldTools`, `layoutTools`, `panel`, `propertiesTab`, `fieldsTab`, `pagesTab`, `problems` |
246
+ | `fieldTypes` | `FieldType[]` | all | Field types that can be added |
247
+ | `elementTools` | `ElementTool[]` | all | Layout tools that can be added (`[]` = none) |
248
+ | `components` | `FormliftComponents` | – | Replacement UI components and answer controls ([Your own UI](#your-own-ui)) |
249
+ | `keyboard` | `boolean` | `true` | Keyboard shortcuts |
250
+ | `warnUnsaved` | `boolean` | `true` | Browser warning when leaving with unsaved changes |
251
+ | `onDetected` | `(found: FieldDef[]) => void` | – | Called with the fields auto-detect suggested |
245
252
 
246
253
  **Keyboard:** <kbd>Del</kbd> delete · <kbd>Ctrl</kbd>+<kbd>Z</kbd> / <kbd>Ctrl</kbd>+<kbd>Y</kbd> undo/redo ·
247
254
  <kbd>Ctrl</kbd>+<kbd>D</kbd> duplicate · arrow keys nudge (hold <kbd>Shift</kbd> for larger steps) ·
@@ -262,8 +269,25 @@ uploads are embedded as PDF attachments.
262
269
  | `loadFile` | `(id) => Promise<Blob>` | – | Loads stored signatures and opens attachments |
263
270
  | `files` | `AnswerFileInfo[]` | `[]` | Files already attached to the draft |
264
271
  | `readOnly` | `boolean` | `false` | View a submitted form |
272
+ | `hide` | `FillerPart[]` | `[]` | Parts to leave out: `back`, `title`, `views`, `zoom`, `saveStatus`, `saveDraft`, `submit`, `attachments`, `errors`, `requiredMark` |
273
+ | `views` | `('document' \| 'list')[]` | both | Views offered; the first is shown first |
274
+ | `confirmSubmit` | `boolean` | `true` | Ask "Submit this form?" first |
275
+ | `listFilters` | `(FilterSource \| string)[]` | `defaultFilters` | The list view's filters ([Filters](#4-filters-you-choose-formlift-computes)) |
276
+ | `listSearch` | `boolean` | `true` | Search box over the list |
277
+ | `hideUnavailableFilters` | `boolean` | `true` | Leave out filters this form has nothing for |
278
+ | `renderFilters` | `(fs: QuestionFilterState) => ReactNode` | – | Your own search / filter bar instead of the built-in one |
279
+ | `toQuestions` | `(fields, textOf) => Question[]` | `toQuestions` | How fields become list questions |
280
+ | `components` | `FormliftComponents` | – | Replacement UI components and answer controls |
265
281
  | `title`, `onBack`, `toolbar`, `notify`, `onError`, `className` | | | Same as `FormEditor` |
266
282
 
283
+ ### Hooks (`formlift/react`)
284
+
285
+ | Hook | Gives |
286
+ | --- | --- |
287
+ | `useFormEditor(options)` | Everything `FormEditor` does, without UI: document, undo/redo, selection, drag/resize, pages, detect, save |
288
+ | `useFormFiller(options)` | Everything `FormFiller` does, without UI: answers, conditions, validation, autosave, uploads, questions |
289
+ | `useQuestionFilter(questions, answers, options)` | Search and filters as data: `groups`, `inUse`, `list` |
290
+
267
291
  ### Core functions (`formlift`)
268
292
 
269
293
  | Function | Returns | Description |
@@ -278,7 +302,8 @@ uploads are embedded as PDF attachments.
278
302
  | `defaultAnswers(fields)` | `Answers` | The answers a new submission starts with |
279
303
  | `defaultLayout(pageSizes)` | `Layout` | The layout of an untouched document |
280
304
  | `detectDocument(pdf, OPS, pages, existing, onProgress?, signal?)` | `Promise<FieldDef[]>` | Suggests fields for a whole document |
281
- | `toQuestions(fields, textOf?)` | `Question[]` | Fields grouped as questions, used by the list view |
305
+ | `toQuestions(fields, textOf?, { categoryCode? })` | `Question[]` | Fields grouped as questions, used by the list view |
306
+ | `filterQuestions(questions, answers, filters, values, search?)` | `Question[]` | The questions passing a search and picked filter values (see [Filters](#4-filters-you-choose-formlift-computes)) |
282
307
 
283
308
  ### Server (`formlift/server`)
284
309
 
@@ -433,6 +458,185 @@ The styles live inside `.fl-root` and have no global reset. Override any variabl
433
458
 
434
459
  The full list of variables is in [`src/style.css`](./src/style.css).
435
460
 
461
+ ## Your own UI
462
+
463
+ formlift's logic is separate from its look. Pick how much you replace:
464
+
465
+ | Level | What you change | How |
466
+ |---|---|---|
467
+ | 1. Theme | colours, font | `--fl-*` CSS variables (above) |
468
+ | 2. Parts and features | what shows, which field types / views exist | `hide`, `views`, `fieldTypes`, `listFilters`… |
469
+ | 3. Components | any button, input, modal, chip…, the answer control, or one field type | `components` prop / `<FormliftProvider>` |
470
+ | 4. Filters | which filters exist, your own filters, your own filter UI | `listFilters`, filter objects, `renderFilters` / `useQuestionFilter` |
471
+ | 5. Headless | the whole screen | `useFormEditor` / `useFormFiller` + your own markup |
472
+
473
+ ### 2. Choose what shows
474
+
475
+ ```tsx
476
+ <FormEditor
477
+ pdf={url}
478
+ onSave={save}
479
+ fieldTypes={['text', 'date', 'checkbox', 'signature']} // only these in the palette
480
+ elementTools={['text']} // layout tools offered ([] = none)
481
+ hide={['autoDetect', 'editText', 'pagesTab', 'snap']} // EditorPart: leave these out
482
+ autoDetect={false}
483
+ />
484
+
485
+ <FormFiller
486
+ pdf={url}
487
+ fields={fields}
488
+ views={['list']} // only the question list ('document', 'list'; first = default)
489
+ listFilters={['answered', 'category']} // only these filters (ids, or filter objects: see 4.)
490
+ hide={['saveDraft', 'attachments']} // FillerPart: leave these out
491
+ confirmSubmit={false}
492
+ />
493
+ ```
494
+
495
+ Everything hidden still works through the hooks and `ref` (hide `save` and call `ref.current.save()` from your
496
+ own button). `EditorPart` and `FillerPart` list every part that can be hidden.
497
+
498
+ ### 3. Swap components
499
+
500
+ Every building block formlift draws (`Button`, `IconButton`, `TextInput`, `Select`, `TextArea`, `Checkbox`,
501
+ `FormField`, `Modal`, `ModalHeader`, `ModalBody`, `ModalFooter`, `ConfirmModal`, `SegmentedTabs`, `PanelHeading`,
502
+ `EmptyText`, `Chip`, `ChipGroup`, `RemovableChip`, `Popover`, `Tag`, `SignaturePad`) can be replaced, inside the
503
+ editor's panels too. A replacement gets the same props as the built-in one:
504
+
505
+ ```tsx
506
+ import { FormliftProvider, FormFiller, type UIComponents } from 'formlift/react'
507
+ import type { ComponentProps } from 'react'
508
+
509
+ function MyButton({ variant, size, icon: Icon, children, ...rest }: ComponentProps<UIComponents['Button']>) {
510
+ return <button className={`my-btn my-btn--${variant ?? 'primary'}`} {...rest}>{Icon && <Icon size={14} />}{children}</button>
511
+ }
512
+
513
+ // One screen…
514
+ <FormFiller pdf={url} fields={fields} components={{ Button: MyButton, FieldControl: MyAnswerControl }} />
515
+
516
+ // …or the whole app.
517
+ <FormliftProvider components={{ Button: MyButton, Select: MySelect }}>
518
+ <App />
519
+ </FormliftProvider>
520
+ ```
521
+
522
+ The answer controls are swappable too, all at once (`FieldControl`) or per field type (`fields`, which wins). Each
523
+ gets `FieldControlProps` (`field`, `value`, `onChange`, `readOnly`, `error`…):
524
+
525
+ ```tsx
526
+ <FormFiller
527
+ pdf={url}
528
+ fields={fields}
529
+ components={{ fields: { date: MyDatePicker, signature: MySignaturePad } }} // other types stay built-in
530
+ />
531
+ ```
532
+
533
+ ### 4. Filters: you choose, formlift computes
534
+
535
+ formlift decides nothing about which filters a form gets. A filter is a plain object (`id`, `title`, `options()`,
536
+ `test()`); the built-ins are presets you pick from, and you can write your own. For each filter you pass,
537
+ formlift returns its options **for this form** with counts and an `available` flag (does this form have anything
538
+ to filter on?). Whether to show it is your call.
539
+
540
+ | Built-in (`formlift`) | id | Offers |
541
+ |---|---|---|
542
+ | `answeredFilter()` | `answered` | Answered / Not answered |
543
+ | `partFilter({ id, title, match })` | yours | Written / Empty for parts whose name matches `match` (presets: `remarks` = `/remark/i`, `comments` = `/comment/i`) |
544
+ | `choiceFilter()` / `pickedFilter()` | `choices` / `picked` | answer choices offered / ticked ("Yes", "NA"…) |
545
+ | `categoryFilter()` | `category` | category codes read from the form (`Question.category`) |
546
+ | `tagFilters()` / `tagFilter(key)` | `tag:<key>` | "Risk: H" style tags read from the form |
547
+ | `typeFilter()` | `type` | answer types |
548
+ | `customFilter(id, title, valuesOf)` | yours | anything you compute from a question |
549
+
550
+ ```tsx
551
+ import { categoryFilter, customFilter, partFilter } from 'formlift'
552
+
553
+ const hasCategories = form.kind === 'inspection' // your rule, not formlift's
554
+
555
+ <FormFiller
556
+ pdf={url}
557
+ fields={fields}
558
+ listFilters={[
559
+ 'answered', // a built-in, by id
560
+ partFilter({ id: 'finding', title: 'Finding', match: /finding|observation/i }),
561
+ ...(hasCategories ? [categoryFilter({ title: 'Chapter' })] : []),
562
+ customFilter('deck', 'Deck', (q) => [q.page <= 2 ? 'Upper' : 'Lower']),
563
+ ]}
564
+ listSearch={true}
565
+ hideUnavailableFilters={false} // show listed filters even when this form has nothing for them
566
+ />
567
+ ```
568
+
569
+ What counts as a category is yours too: `toQuestions(fields, text, { categoryCode: /^(HW|PR|HF)$/ })`, or
570
+ `categoryCode: false` and set `Question.category` yourself through `useFormFiller`'s `toQuestions` option.
571
+
572
+ **Your own filter UI.** `renderFilters` replaces the built-in bar; `fs.groups` has every filter you passed, each
573
+ with `options` (label + count), `available`, `value`, `set`, and a `section` hint:
574
+
575
+ ```tsx
576
+ <FormFiller
577
+ pdf={url}
578
+ fields={fields}
579
+ renderFilters={(fs) => (
580
+ <MyToolbar>
581
+ <MySearch value={fs.search ?? ''} onChange={fs.setSearch} />
582
+ {fs.groups
583
+ .filter((g) => g.available)
584
+ .map((g) => <MySelect key={g.id} label={g.title} options={g.options} value={g.value} onChange={g.set} />)}
585
+ {fs.inUse.map((c) => <MyChip key={c.id} label={c.label} onRemove={c.clear} />)}
586
+ <span>{fs.list.length} of {fs.total}</span>
587
+ </MyToolbar>
588
+ )}
589
+ />
590
+ ```
591
+
592
+ Headless: `useQuestionFilter(f.questions, f.answers, { filters, search, initial, predicate, onChange })` gives the
593
+ same object; render `fs.list` yourself or pass it to `<QuestionList questions={…} filter={fs} toolbar={false} />`.
594
+ On the server (no React): `filterQuestions(questions, answers, resolveFilters(filters, questions), values, search)`.
595
+
596
+ ### 5. Headless: hooks only
597
+
598
+ `useFormFiller` and `useFormEditor` hold everything the built-in screens do (answers, conditions, validation, queued
599
+ saves and autosave, uploads; the document with undo / redo, selection, drag / resize, pages, auto-detect, save). Draw
600
+ whatever you like around them. `PdfPages` renders the pages and gives you an overlay per page.
601
+
602
+ ```tsx
603
+ import { boxStyle } from 'formlift'
604
+ import { PdfPages, useFormFiller } from 'formlift/react'
605
+
606
+ function MyFiller({ pdfUrl, fields }) {
607
+ const f = useFormFiller({ fields, onSave: (answers, { submit }) => api.save(answers, submit) })
608
+ return (
609
+ <>
610
+ <header>
611
+ {f.saveState} <button onClick={() => f.submit()}>Send</button>
612
+ </header>
613
+ <PdfPages
614
+ load={pdfUrl}
615
+ zoom={1}
616
+ onLoaded={f.onPdfLoaded}
617
+ overlay={(page) =>
618
+ fields
619
+ .filter((x) => x.position.page === page && f.states[x.key].visible)
620
+ .map((x) => (
621
+ <input
622
+ key={x.id}
623
+ style={{ position: 'absolute', ...boxStyle(x.position) }}
624
+ value={String(f.answers[x.key] ?? '')}
625
+ onChange={(e) => f.change(x, e.target.value)}
626
+ />
627
+ ))
628
+ }
629
+ />
630
+ </>
631
+ )
632
+ }
633
+ ```
634
+
635
+ Or skip the PDF and show `f.questions` (fields grouped into questions, tables and option groups) as a plain list.
636
+ For the editor, `useFormEditor` gives `fields`, `layout`, `selection`, `placeNew`, `startDrag` / `onDrag` /
637
+ `endDrag`, `updateField`, `undo` / `redo`, `detect`, `save` and the rest. Hover the hook in your editor for the
638
+ full list.
639
+
436
640
  ## Security
437
641
 
438
642
  - Run `validateFields` / `validateLayout` when a form is saved, and `cleanAnswers` + `validateAnswers` when
@@ -997,7 +997,7 @@ function headerRow(lines, first) {
997
997
  });
998
998
  return names.every(Boolean) ? names : null;
999
999
  }
1000
- function toQuestions(fields, textOf = () => void 0) {
1000
+ function toQuestions(fields, textOf = () => void 0, { categoryCode = CODE } = {}) {
1001
1001
  const sorted = [...fields].sort(
1002
1002
  (a, b) => a.position.page - b.position.page || a.position.y - b.position.y || a.position.x - b.position.x
1003
1003
  );
@@ -1165,7 +1165,7 @@ function toQuestions(fields, textOf = () => void 0) {
1165
1165
  });
1166
1166
  const at = (q) => top(q.parts.map((p) => p.field).filter((f) => f.position.page === q.page));
1167
1167
  out.sort((a, b) => a.page - b.page || at(a) - at(b));
1168
- setCategories(out, sorted, textOf);
1168
+ if (categoryCode) setCategories(out, sorted, textOf, categoryCode);
1169
1169
  setSections(out, sorted, textOf);
1170
1170
  setScales(out, textOf);
1171
1171
  return mergeBlocks(out);
@@ -1270,10 +1270,10 @@ function mergeBlocks(out) {
1270
1270
  return res;
1271
1271
  }
1272
1272
  var CODE = /^[A-Z]{2,4}$/;
1273
- function setCategories(out, fields, textOf) {
1273
+ function setCategories(out, fields, textOf, code) {
1274
1274
  const last = Math.max(0, ...fields.map((f) => f.position.page));
1275
1275
  const found = [];
1276
- const isCode = (t) => CODE.test(t.trim()) && !COLUMN.test(t.trim());
1276
+ const isCode = (t) => code.test(t.trim()) && !COLUMN.test(t.trim());
1277
1277
  for (let page = 1; page <= last; page++) {
1278
1278
  const lines = _nullishCoalesce(textOf(page), () => ( []));
1279
1279
  for (const l of lines) {
@@ -1431,25 +1431,7 @@ function tagsOf(text) {
1431
1431
  if (pairs.some((m) => !m || m[1].trim().split(/\s+/).length > 3)) return null;
1432
1432
  return Object.fromEntries(pairs.map((m) => [m[1].trim(), m[2].trim()]));
1433
1433
  }
1434
- var NO_FILTER = {
1435
- search: "",
1436
- answered: "all",
1437
- remarks: "all",
1438
- comments: "all",
1439
- type: "all",
1440
- category: "all",
1441
- tags: {},
1442
- choices: "all",
1443
- picked: "all"
1444
- };
1445
1434
  var isFilled = (v) => v != null && v !== "" && v !== false;
1446
- var partsLike = (q, re) => q.parts.filter((p) => re.test(p.name) || re.test(p.field.label));
1447
- function check(parts, answers, want) {
1448
- if (want === "all") return true;
1449
- if (!parts.length) return false;
1450
- const any = parts.some((p) => isFilled(answers[p.field.key]));
1451
- return want === "filled" ? any : !any;
1452
- }
1453
1435
  function choiceParts(q) {
1454
1436
  if (q.columns) return [];
1455
1437
  const size = _nullishCoalesce(_optionalChain([q, 'access', _30 => _30.rows, 'optionalAccess', _31 => _31.reduce, 'call', _32 => _32((a, b) => a + b, 0)]), () => ( 0));
@@ -1470,17 +1452,157 @@ function pickedOf(q, answers) {
1470
1452
  const v = radio && answers[radio.key];
1471
1453
  return typeof v === "string" && v ? [v] : [];
1472
1454
  }
1473
- function matches(q, answers, f) {
1474
- const text = f.search.trim().toLowerCase();
1475
- if (text && !`${_nullishCoalesce(q.section, () => ( ""))} ${_nullishCoalesce(q.intro, () => ( ""))} ${q.title} ${q.parts.map((p) => p.field.label).join(" ")}`.toLowerCase().includes(text))
1476
- return false;
1477
- if (f.type !== "all" && !q.parts.some((p) => p.field.type === f.type)) return false;
1478
- if (f.category !== "all" && q.category !== f.category) return false;
1479
- if (Object.entries(f.tags).some(([k, v]) => _optionalChain([q, 'access', _42 => _42.tags, 'optionalAccess', _43 => _43[k]]) !== v)) return false;
1480
- if (f.choices !== "all" && !choicesOf(q).includes(f.choices)) return false;
1481
- if (f.picked !== "all" && !pickedOf(q, answers).includes(f.picked)) return false;
1482
- return check(q.parts, answers, f.answered) && check(partsLike(q, /remark/i), answers, f.remarks) && check(partsLike(q, /comment/i), answers, f.comments);
1455
+
1456
+ // src/core/filters.ts
1457
+ var counted = (questions, values, label = (v) => v) => {
1458
+ const m = /* @__PURE__ */ new Map();
1459
+ for (const q of questions) for (const v of new Set(values(q))) m.set(v, (_nullishCoalesce(m.get(v), () => ( 0))) + 1);
1460
+ return [...m].map(([value, count]) => ({ value, label: label(value), count }));
1461
+ };
1462
+ var filledOptions = (yes, no) => [
1463
+ { value: "filled", label: yes },
1464
+ { value: "empty", label: no }
1465
+ ];
1466
+ function filledTest(parts, value, answers) {
1467
+ if (!parts.length) return false;
1468
+ const any = parts.some((p) => isFilled(answers[p.field.key]));
1469
+ return value === "filled" ? any : !any;
1470
+ }
1471
+ var answeredFilter = ({
1472
+ id = "answered",
1473
+ title = "Answered",
1474
+ yes = "Answered",
1475
+ no = "Not answered",
1476
+ section = "Progress"
1477
+ } = {}) => ({
1478
+ id,
1479
+ title,
1480
+ section,
1481
+ options: (qs) => qs.length ? filledOptions(yes, no) : [],
1482
+ test: (q, v, a) => filledTest(q.parts, v, a)
1483
+ });
1484
+ function partFilter({
1485
+ id,
1486
+ title,
1487
+ match,
1488
+ yes = "Written",
1489
+ no = "Empty",
1490
+ section = "Progress"
1491
+ }) {
1492
+ const parts = (q) => q.parts.filter((p) => match.test(p.name) || match.test(p.field.label));
1493
+ return {
1494
+ id,
1495
+ title,
1496
+ section,
1497
+ options: (qs) => qs.some((q) => parts(q).length) ? filledOptions(yes, no) : [],
1498
+ test: (q, v, a) => filledTest(parts(q), v, a)
1499
+ };
1483
1500
  }
1501
+ var choiceFilter = ({
1502
+ id = "choices",
1503
+ title = "Has option",
1504
+ section = "Answers",
1505
+ min = 2,
1506
+ max = 40
1507
+ } = {}) => ({
1508
+ id,
1509
+ title,
1510
+ section,
1511
+ options: (qs) => counted(qs, choicesOf).filter((o) => o.count >= min).slice(0, max),
1512
+ test: (q, v) => choicesOf(q).includes(v)
1513
+ });
1514
+ var pickedFilter = ({
1515
+ id = "picked",
1516
+ title = "Answered with",
1517
+ section = "Answers",
1518
+ min = 2,
1519
+ max = 40
1520
+ } = {}) => ({
1521
+ id,
1522
+ title,
1523
+ section,
1524
+ options: (qs, answers) => {
1525
+ const offered = new Set(
1526
+ choiceFilter({ min, max }).options(qs, answers).map((o) => o.value)
1527
+ );
1528
+ const picked = new Map(counted(qs, (q) => pickedOf(q, answers)).map((o) => [o.value, o.count]));
1529
+ return [...offered].map((c) => ({ value: c, label: c, count: _nullishCoalesce(picked.get(c), () => ( 0)) }));
1530
+ },
1531
+ test: (q, v, a) => pickedOf(q, a).includes(v)
1532
+ });
1533
+ var categoryFilter = ({ id = "category", title = "Category", section = "bar" } = {}) => ({
1534
+ id,
1535
+ title,
1536
+ section,
1537
+ options: (qs) => counted(qs, (q) => q.category ? [q.category] : []),
1538
+ test: (q, v) => q.category === v
1539
+ });
1540
+ var typeFilter = ({
1541
+ id = "type",
1542
+ title = "Answer type",
1543
+ section = "Answer type",
1544
+ label = (t) => t
1545
+ } = {}) => ({
1546
+ id,
1547
+ title,
1548
+ section,
1549
+ options: (qs) => {
1550
+ const opts = counted(
1551
+ qs,
1552
+ (q) => q.parts.map((p) => p.field.type),
1553
+ (t) => label(t)
1554
+ );
1555
+ return opts.length > 1 ? opts : [];
1556
+ },
1557
+ test: (q, v) => q.parts.some((p) => p.field.type === v)
1558
+ });
1559
+ var tagFilter = (key, { title = key, section = "Question details" } = {}) => ({
1560
+ id: `tag:${key}`,
1561
+ title,
1562
+ section,
1563
+ options: (qs) => counted(qs, (q) => _optionalChain([q, 'access', _42 => _42.tags, 'optionalAccess', _43 => _43[key]]) ? [q.tags[key]] : []).sort(
1564
+ (a, b) => a.value.localeCompare(b.value, void 0, { numeric: true })
1565
+ ),
1566
+ test: (q, v) => _optionalChain([q, 'access', _44 => _44.tags, 'optionalAccess', _45 => _45[key]]) === v
1567
+ });
1568
+ var tagFilters = ({ section = "Question details" } = {}) => (qs) => [...new Set(qs.flatMap((q) => Object.keys(_nullishCoalesce(q.tags, () => ( {})))))].map((k) => tagFilter(k, { section }));
1569
+ var customFilter = (id, title, valuesOf, section) => ({
1570
+ id,
1571
+ title,
1572
+ section,
1573
+ options: (qs, answers) => counted(qs, (q) => valuesOf(q, answers)),
1574
+ test: (q, v, a) => valuesOf(q, a).includes(v)
1575
+ });
1576
+ var builtinFilters = (labels = {}) => [
1577
+ answeredFilter(),
1578
+ partFilter({ id: "remarks", title: "Remarks", match: /remark/i }),
1579
+ partFilter({ id: "comments", title: "Comments", match: /comment/i }),
1580
+ choiceFilter(),
1581
+ pickedFilter(),
1582
+ tagFilters(),
1583
+ typeFilter({ label: labels.type }),
1584
+ categoryFilter()
1585
+ ];
1586
+ var resolveFilters = (sources, questions) => sources.flatMap((s) => typeof s === "function" ? s(questions) : [s]);
1587
+ var searchText = (q) => `${_nullishCoalesce(q.section, () => ( ""))} ${_nullishCoalesce(q.intro, () => ( ""))} ${q.title} ${q.parts.map((p) => p.field.label).join(" ")}`;
1588
+ function filterQuestions(questions, answers, filters, values, search = "") {
1589
+ const text = search.trim().toLowerCase();
1590
+ const active = filters.filter((f) => values[f.id] != null);
1591
+ return questions.filter(
1592
+ (q) => (!text || searchText(q).toLowerCase().includes(text)) && active.every((f) => f.test(q, values[f.id], answers))
1593
+ );
1594
+ }
1595
+
1596
+
1597
+
1598
+
1599
+
1600
+
1601
+
1602
+
1603
+
1604
+
1605
+
1484
1606
 
1485
1607
 
1486
1608
 
@@ -1513,5 +1635,5 @@ function matches(q, answers, f) {
1513
1635
 
1514
1636
 
1515
1637
 
1516
- exports.snap = snap; exports.toPage = toPage; exports.fitInPage = fitInPage; exports.moveBy = moveBy; exports.resizeBy = resizeBy; exports.boxStyle = boxStyle; exports.uniqueKey = uniqueKey; exports.keyFor = keyFor; exports.withField = withField; exports.newField = newField; exports.duplicate = duplicate; exports.reorder = reorder; exports.movePage = movePage; exports.deletePage = deletePage; exports.insertBlankPage = insertBlankPage; exports.ELEMENT_TOOLS = ELEMENT_TOOLS; exports.newElement = newElement; exports.replaceLine = replaceLine; exports.fitTextWidth = fitTextWidth; exports.duplicateElement = duplicateElement; exports.toLines = toLines; exports.extractShapes = extractShapes; exports.detectFields = detectFields; exports.detectDocument = detectDocument; exports.toQuestions = toQuestions; exports.NO_FILTER = NO_FILTER; exports.isFilled = isFilled; exports.choicesOf = choicesOf; exports.pickedOf = pickedOf; exports.matches = matches;
1517
- //# sourceMappingURL=chunk-5VHPTLLV.cjs.map
1638
+ exports.snap = snap; exports.toPage = toPage; exports.fitInPage = fitInPage; exports.moveBy = moveBy; exports.resizeBy = resizeBy; exports.boxStyle = boxStyle; exports.uniqueKey = uniqueKey; exports.keyFor = keyFor; exports.withField = withField; exports.newField = newField; exports.duplicate = duplicate; exports.reorder = reorder; exports.movePage = movePage; exports.deletePage = deletePage; exports.insertBlankPage = insertBlankPage; exports.ELEMENT_TOOLS = ELEMENT_TOOLS; exports.newElement = newElement; exports.replaceLine = replaceLine; exports.fitTextWidth = fitTextWidth; exports.duplicateElement = duplicateElement; exports.toLines = toLines; exports.extractShapes = extractShapes; exports.detectFields = detectFields; exports.detectDocument = detectDocument; exports.toQuestions = toQuestions; exports.isFilled = isFilled; exports.choicesOf = choicesOf; exports.pickedOf = pickedOf; exports.answeredFilter = answeredFilter; exports.partFilter = partFilter; exports.choiceFilter = choiceFilter; exports.pickedFilter = pickedFilter; exports.categoryFilter = categoryFilter; exports.typeFilter = typeFilter; exports.tagFilter = tagFilter; exports.tagFilters = tagFilters; exports.customFilter = customFilter; exports.builtinFilters = builtinFilters; exports.resolveFilters = resolveFilters; exports.searchText = searchText; exports.filterQuestions = filterQuestions;
1639
+ //# sourceMappingURL=chunk-33XWO4KB.cjs.map