@enonic/ui-kit 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +78 -8
  2. package/dist/dialog/action-dialog-context.d.ts +22 -0
  3. package/dist/dialog/action-dialog-context.js +21 -0
  4. package/dist/dialog/action-dialog-context.js.map +1 -0
  5. package/dist/dialog/action-dialog.d.ts +135 -0
  6. package/dist/dialog/action-dialog.js +213 -0
  7. package/dist/dialog/action-dialog.js.map +1 -0
  8. package/dist/dialog/delete-confirm.d.ts +33 -0
  9. package/dist/dialog/delete-confirm.js +80 -0
  10. package/dist/dialog/delete-confirm.js.map +1 -0
  11. package/dist/dialog/delete-expectation.d.ts +17 -0
  12. package/dist/dialog/delete-expectation.js +13 -0
  13. package/dist/dialog/delete-expectation.js.map +1 -0
  14. package/dist/dialog/dialog-preset.d.ts +34 -0
  15. package/dist/dialog/dialog-preset.js +38 -0
  16. package/dist/dialog/dialog-preset.js.map +1 -0
  17. package/dist/dialog/gate-match.d.ts +6 -0
  18. package/dist/dialog/gate-match.js +11 -0
  19. package/dist/dialog/gate-match.js.map +1 -0
  20. package/dist/dialog/gate.d.ts +31 -0
  21. package/dist/dialog/gate.js +72 -0
  22. package/dist/dialog/gate.js.map +1 -0
  23. package/dist/dialog/use-close-guard.d.ts +34 -0
  24. package/dist/dialog/use-close-guard.js +40 -0
  25. package/dist/dialog/use-close-guard.js.map +1 -0
  26. package/dist/filter/filter-fields.d.ts +33 -0
  27. package/dist/filter/filter-fields.js +72 -0
  28. package/dist/filter/filter-fields.js.map +1 -0
  29. package/dist/filter/filter-input.d.ts +35 -0
  30. package/dist/filter/filter-input.js +441 -0
  31. package/dist/filter/filter-input.js.map +1 -0
  32. package/dist/filter/filter-input.phrases.d.ts +26 -0
  33. package/dist/filter/filter-input.phrases.js +26 -0
  34. package/dist/filter/filter-input.phrases.js.map +1 -0
  35. package/dist/filter/filter-query.d.ts +40 -0
  36. package/dist/filter/filter-query.js +61 -0
  37. package/dist/filter/filter-query.js.map +1 -0
  38. package/dist/i18n/fill-phrase.d.ts +10 -0
  39. package/dist/i18n/fill-phrase.js +25 -0
  40. package/dist/i18n/fill-phrase.js.map +1 -0
  41. package/dist/i18n/phrases.d.ts +14 -0
  42. package/dist/i18n/phrases.js +23 -0
  43. package/dist/i18n/phrases.js.map +1 -0
  44. package/dist/i18n/use-phrases.d.ts +9 -0
  45. package/dist/i18n/use-phrases.js +11 -0
  46. package/dist/i18n/use-phrases.js.map +1 -0
  47. package/dist/index.d.ts +16 -1
  48. package/dist/index.js +15 -0
  49. package/package.json +34 -6
  50. package/preset.css +6 -0
@@ -0,0 +1,80 @@
1
+ import { useActionDialog } from "./action-dialog-context.js";
2
+ import { useUiKitPhrases } from "../i18n/use-phrases.js";
3
+ import { ActionDialog } from "./action-dialog.js";
4
+ import { deleteExpectation } from "./delete-expectation.js";
5
+ import { Gate } from "./gate.js";
6
+ import { useEffect, useRef } from "react";
7
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
8
+ //#region src/dialog/delete-confirm.tsx
9
+ const DELETE_CONFIRM_NAME = "DeleteConfirm";
10
+ /**
11
+ * The delete view: the question, the targets, the gate, and a red button that enables once the
12
+ * name or the count is typed back. A `Content` — the first view of a dialog opened from a
13
+ * screen, or the view after a list.
14
+ */
15
+ const DeleteConfirm = ({ title, question, targets, expected = deleteExpectation(targets), confirmLabel, size = "medium", onConfirm, onCancel, closeOnConfirm = true, closeOnCancel = true, "data-component": componentName = DELETE_CONFIRM_NAME }) => {
16
+ const gateInputRef = useRef(null);
17
+ return /* @__PURE__ */ jsx(ActionDialog.Content, {
18
+ "data-component": componentName,
19
+ size,
20
+ defaultConfirmEnabled: expected === null,
21
+ onOpenAutoFocus: (event) => {
22
+ if (expected !== null) {
23
+ event.preventDefault();
24
+ gateInputRef.current?.focus();
25
+ }
26
+ },
27
+ children: /* @__PURE__ */ jsx(DeleteConfirmParts, {
28
+ title,
29
+ question,
30
+ targets,
31
+ expected,
32
+ confirmLabel,
33
+ gateInputRef,
34
+ onConfirm,
35
+ onCancel,
36
+ closeOnConfirm,
37
+ closeOnCancel
38
+ })
39
+ });
40
+ };
41
+ DeleteConfirm.displayName = DELETE_CONFIRM_NAME;
42
+ const DeleteConfirmParts = ({ title, question, targets, expected, confirmLabel, gateInputRef, onConfirm, onCancel, closeOnConfirm, closeOnCancel }) => {
43
+ const t = useUiKitPhrases();
44
+ const { confirmEnabled, setConfirmEnabled } = useActionDialog();
45
+ const confirmRef = useRef(null);
46
+ useEffect(() => {
47
+ if (expected === null) setConfirmEnabled(true);
48
+ }, [expected, setConfirmEnabled]);
49
+ useEffect(() => {
50
+ if (expected !== null && confirmEnabled) confirmRef.current?.focus();
51
+ }, [expected, confirmEnabled]);
52
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
53
+ /* @__PURE__ */ jsx(ActionDialog.DefaultHeader, { title }),
54
+ /* @__PURE__ */ jsxs(ActionDialog.Body, { children: [/* @__PURE__ */ jsxs("div", {
55
+ className: "flex flex-col gap-2.5",
56
+ children: [/* @__PURE__ */ jsx("div", { children: question }), targets.length > 0 && /* @__PURE__ */ jsx("ul", {
57
+ className: "flex flex-col gap-2.5 py-1.5",
58
+ children: targets.map(({ key, label }) => /* @__PURE__ */ jsx("li", { children: label }, key))
59
+ })]
60
+ }), expected !== null && /* @__PURE__ */ jsxs(Gate, { children: [/* @__PURE__ */ jsx(Gate.Hint, { value: expected }), /* @__PURE__ */ jsx(Gate.Input, {
61
+ ref: gateInputRef,
62
+ expected
63
+ })] })] }),
64
+ /* @__PURE__ */ jsx(ActionDialog.Footer, {
65
+ intent: "danger",
66
+ cancelVariant: "outline",
67
+ confirmLabel: confirmLabel ?? t("enonic.uiKit.dialog.delete"),
68
+ confirmRef,
69
+ onConfirm,
70
+ onCancel,
71
+ closeOnConfirm,
72
+ closeOnCancel
73
+ })
74
+ ] });
75
+ };
76
+ DeleteConfirmParts.displayName = "DeleteConfirm.Parts";
77
+ //#endregion
78
+ export { DeleteConfirm };
79
+
80
+ //# sourceMappingURL=delete-confirm.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delete-confirm.js","names":[],"sources":["../../src/dialog/delete-confirm.tsx"],"sourcesContent":["import { type ReactElement, type ReactNode, type RefObject, useEffect, useRef } from 'react';\n\nimport { useUiKitPhrases } from '../i18n/use-phrases';\nimport { ActionDialog, type DialogSize } from './action-dialog';\nimport { useActionDialog } from './action-dialog-context';\nimport { deleteExpectation, type DeleteTarget } from './delete-expectation';\nimport { Gate } from './gate';\n\nexport type DeleteConfirmProps = {\n title: string;\n /** The question, already worded for one target or many; the targets are listed under it. */\n question: ReactNode;\n targets: readonly DeleteTarget[];\n /**\n * What is typed back before the button enables: `deleteExpectation(targets)` by default, the\n * one name or the count. `null` asks for nothing, and the button is enabled from the start.\n */\n expected?: string | number | null;\n /** Defaults to the kit's \"Delete\". */\n confirmLabel?: string;\n size?: DialogSize;\n onConfirm: () => void;\n onCancel?: () => void;\n /** Off for a caller that moves on to a progress view instead of closing. */\n closeOnConfirm?: boolean;\n /** Off for a caller whose Cancel goes back to the view before — a list the delete was picked from. */\n closeOnCancel?: boolean;\n 'data-component'?: string;\n};\n\nconst DELETE_CONFIRM_NAME = 'DeleteConfirm';\n\n/**\n * The delete view: the question, the targets, the gate, and a red button that enables once the\n * name or the count is typed back. A `Content` — the first view of a dialog opened from a\n * screen, or the view after a list.\n */\nconst DeleteConfirm = ({\n title,\n question,\n targets,\n expected = deleteExpectation(targets),\n confirmLabel,\n size = 'medium',\n onConfirm,\n onCancel,\n closeOnConfirm = true,\n closeOnCancel = true,\n 'data-component': componentName = DELETE_CONFIRM_NAME,\n}: DeleteConfirmProps): ReactElement => {\n const gateInputRef = useRef<HTMLInputElement>(null);\n\n return (\n <ActionDialog.Content\n data-component={componentName}\n size={size}\n defaultConfirmEnabled={expected === null}\n onOpenAutoFocus={(event) => {\n if (expected !== null) {\n event.preventDefault();\n gateInputRef.current?.focus();\n }\n }}\n >\n <DeleteConfirmParts\n title={title}\n question={question}\n targets={targets}\n expected={expected}\n confirmLabel={confirmLabel}\n gateInputRef={gateInputRef}\n onConfirm={onConfirm}\n onCancel={onCancel}\n closeOnConfirm={closeOnConfirm}\n closeOnCancel={closeOnCancel}\n />\n </ActionDialog.Content>\n );\n};\nDeleteConfirm.displayName = DELETE_CONFIRM_NAME;\n\ntype DeleteConfirmPartsProps = Required<\n Pick<DeleteConfirmProps, 'expected' | 'closeOnConfirm' | 'closeOnCancel'>\n> &\n Pick<\n DeleteConfirmProps,\n 'title' | 'question' | 'targets' | 'confirmLabel' | 'onConfirm' | 'onCancel'\n > & {\n gateInputRef: RefObject<HTMLInputElement>;\n };\n\n// Inside `Content`, where the confirm state is: a match moves the focus onto the button, so Enter confirms.\nconst DeleteConfirmParts = ({\n title,\n question,\n targets,\n expected,\n confirmLabel,\n gateInputRef,\n onConfirm,\n onCancel,\n closeOnConfirm,\n closeOnCancel,\n}: DeleteConfirmPartsProps): ReactElement => {\n const t = useUiKitPhrases();\n const { confirmEnabled, setConfirmEnabled } = useActionDialog();\n const confirmRef = useRef<HTMLButtonElement>(null);\n\n // With no gate asked for, nothing else enables the button — including after a gate went away.\n useEffect(() => {\n if (expected === null) {\n setConfirmEnabled(true);\n }\n }, [expected, setConfirmEnabled]);\n\n useEffect(() => {\n if (expected !== null && confirmEnabled) {\n confirmRef.current?.focus();\n }\n }, [expected, confirmEnabled]);\n\n return (\n <>\n <ActionDialog.DefaultHeader title={title} />\n <ActionDialog.Body>\n <div className=\"flex flex-col gap-2.5\">\n <div>{question}</div>\n {targets.length > 0 && (\n <ul className=\"flex flex-col gap-2.5 py-1.5\">\n {targets.map(({ key, label }) => (\n <li key={key}>{label}</li>\n ))}\n </ul>\n )}\n </div>\n {expected !== null && (\n <Gate>\n <Gate.Hint value={expected} />\n <Gate.Input ref={gateInputRef} expected={expected} />\n </Gate>\n )}\n </ActionDialog.Body>\n <ActionDialog.Footer\n intent=\"danger\"\n cancelVariant=\"outline\"\n confirmLabel={confirmLabel ?? t('enonic.uiKit.dialog.delete')}\n confirmRef={confirmRef}\n onConfirm={onConfirm}\n onCancel={onCancel}\n closeOnConfirm={closeOnConfirm}\n closeOnCancel={closeOnCancel}\n />\n </>\n );\n};\nDeleteConfirmParts.displayName = 'DeleteConfirm.Parts';\n\nexport { DeleteConfirm };\n"],"mappings":";;;;;;;;AA8BA,MAAM,sBAAsB;;;;;;AAO5B,MAAM,iBAAiB,EACrB,OACA,UACA,SACA,WAAW,kBAAkB,OAAO,GACpC,cACA,OAAO,UACP,WACA,UACA,iBAAiB,MACjB,gBAAgB,MAChB,kBAAkB,gBAAgB,0BACI;CACtC,MAAM,eAAe,OAAyB,IAAI;CAElD,OACE,oBAAC,aAAa,SAAd;EACE,kBAAgB;EACV;EACN,uBAAuB,aAAa;EACpC,kBAAkB,UAAU;GAC1B,IAAI,aAAa,MAAM;IACrB,MAAM,eAAe;IACrB,aAAa,SAAS,MAAM;GAC9B;EACF;EAEA,UAAA,oBAAC,oBAAD;GACS;GACG;GACD;GACC;GACI;GACA;GACH;GACD;GACM;GACD;EAChB,CAAA;CACmB,CAAA;AAE1B;AACA,cAAc,cAAc;AAa5B,MAAM,sBAAsB,EAC1B,OACA,UACA,SACA,UACA,cACA,cACA,WACA,UACA,gBACA,oBAC2C;CAC3C,MAAM,IAAI,gBAAgB;CAC1B,MAAM,EAAE,gBAAgB,sBAAsB,gBAAgB;CAC9D,MAAM,aAAa,OAA0B,IAAI;CAGjD,gBAAgB;EACd,IAAI,aAAa,MACf,kBAAkB,IAAI;CAE1B,GAAG,CAAC,UAAU,iBAAiB,CAAC;CAEhC,gBAAgB;EACd,IAAI,aAAa,QAAQ,gBACvB,WAAW,SAAS,MAAM;CAE9B,GAAG,CAAC,UAAU,cAAc,CAAC;CAE7B,OACE,qBAAA,UAAA,EAAA,UAAA;EACE,oBAAC,aAAa,eAAd,EAAmC,MAAQ,CAAA;EAC3C,qBAAC,aAAa,MAAd,EAAA,UAAA,CACE,qBAAC,OAAD;GAAK,WAAU;GAAf,UAAA,CACE,oBAAC,OAAD,EAAA,UAAM,SAAc,CAAA,GACnB,QAAQ,SAAS,KAChB,oBAAC,MAAD;IAAI,WAAU;IACX,UAAA,QAAQ,KAAK,EAAE,KAAK,YACnB,oBAAC,MAAD,EAAA,UAAe,MAAU,GAAhB,GAAgB,CAC1B;GACC,CAAA,CAEH;EACJ,CAAA,GAAA,aAAa,QACZ,qBAAC,MAAD,EAAA,UAAA,CACE,oBAAC,KAAK,MAAN,EAAW,OAAO,SAAW,CAAA,GAC7B,oBAAC,KAAK,OAAN;GAAY,KAAK;GAAwB;EAAW,CAAA,CAChD,EAAA,CAAA,CAES,EAAA,CAAA;EACnB,oBAAC,aAAa,QAAd;GACE,QAAO;GACP,eAAc;GACd,cAAc,gBAAgB,EAAE,4BAA4B;GAChD;GACD;GACD;GACM;GACD;EAChB,CAAA;CACD,EAAA,CAAA;AAEN;AACA,mBAAmB,cAAc"}
@@ -0,0 +1,17 @@
1
+ import { ReactNode } from "react";
2
+ //#region src/dialog/delete-expectation.d.ts
3
+ type DeleteTarget = {
4
+ key: string;
5
+ /** What has to be typed back to delete this one. */
6
+ name: string;
7
+ /** How the item reads elsewhere in the application; the caller renders it, so the dialog knows no domain. */
8
+ label: ReactNode;
9
+ };
10
+ /**
11
+ * What to type back: the one item's name, or the count of a batch nobody would retype name by name
12
+ * — and of an item with no name to type.
13
+ */
14
+ declare function deleteExpectation(targets: readonly DeleteTarget[]): string | number;
15
+ //#endregion
16
+ export { DeleteTarget, deleteExpectation };
17
+ //# sourceMappingURL=delete-expectation.d.ts.map
@@ -0,0 +1,13 @@
1
+ //#region src/dialog/delete-expectation.ts
2
+ /**
3
+ * What to type back: the one item's name, or the count of a batch nobody would retype name by name
4
+ * — and of an item with no name to type.
5
+ */
6
+ function deleteExpectation(targets) {
7
+ const [only] = targets;
8
+ return targets.length === 1 && only !== void 0 && only.name.trim() !== "" ? only.name : targets.length;
9
+ }
10
+ //#endregion
11
+ export { deleteExpectation };
12
+
13
+ //# sourceMappingURL=delete-expectation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delete-expectation.js","names":[],"sources":["../../src/dialog/delete-expectation.ts"],"sourcesContent":["import type { ReactNode } from 'react';\n\nexport type DeleteTarget = {\n key: string;\n /** What has to be typed back to delete this one. */\n name: string;\n /** How the item reads elsewhere in the application; the caller renders it, so the dialog knows no domain. */\n label: ReactNode;\n};\n\n/**\n * What to type back: the one item's name, or the count of a batch nobody would retype name by name\n * — and of an item with no name to type.\n */\nexport function deleteExpectation(targets: readonly DeleteTarget[]): string | number {\n const [only] = targets;\n\n return targets.length === 1 && only !== undefined && only.name.trim() !== ''\n ? only.name\n : targets.length;\n}\n"],"mappings":";;;;;AAcA,SAAgB,kBAAkB,SAAmD;CACnF,MAAM,CAAC,QAAQ;CAEf,OAAO,QAAQ,WAAW,KAAK,SAAS,KAAA,KAAa,KAAK,KAAK,KAAK,MAAM,KACtE,KAAK,OACL,QAAQ;AACd"}
@@ -0,0 +1,34 @@
1
+ import { DialogIntent, DialogSize } from "./action-dialog.js";
2
+ import { ReactElement, ReactNode } from "react";
3
+ //#region src/dialog/dialog-preset.d.ts
4
+ type DialogPresetConfirmProps = {
5
+ open: boolean;
6
+ onOpenChange: (open: boolean) => void;
7
+ title: string;
8
+ description?: ReactNode;
9
+ question?: ReactNode;
10
+ intent?: DialogIntent;
11
+ size?: DialogSize;
12
+ confirmLabel?: string;
13
+ cancelLabel?: string;
14
+ onConfirm: () => void;
15
+ /** Cancel, `Escape`, the mask and the close button alike. */
16
+ onCancel?: () => void;
17
+ /** Why the dialog is still open after a confirm that did not close it. */
18
+ error?: string;
19
+ /** Off for a caller that closes after its own work, or reports an `error` instead. */
20
+ closeOnConfirm?: boolean;
21
+ children?: ReactNode;
22
+ 'data-component'?: string;
23
+ };
24
+ /** The plain question: a title, the question, and the two answers with equal weight. */
25
+ declare const DialogPresetConfirm: {
26
+ ({ open, onOpenChange, title, description, question, intent, size, confirmLabel, cancelLabel, onConfirm, onCancel, error, closeOnConfirm, children, 'data-component': componentName }: DialogPresetConfirmProps): ReactElement;
27
+ displayName: string;
28
+ };
29
+ declare const DialogPreset: {
30
+ Confirm: typeof DialogPresetConfirm;
31
+ };
32
+ //#endregion
33
+ export { DialogPreset, DialogPresetConfirmProps };
34
+ //# sourceMappingURL=dialog-preset.d.ts.map
@@ -0,0 +1,38 @@
1
+ import { ActionDialog } from "./action-dialog.js";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/dialog/dialog-preset.tsx
4
+ const CONFIRM_NAME = "DialogPreset.Confirm";
5
+ /** The plain question: a title, the question, and the two answers with equal weight. */
6
+ const DialogPresetConfirm = ({ open, onOpenChange, title, description, question, intent = "default", size = "default", confirmLabel, cancelLabel, onConfirm, onCancel, error, closeOnConfirm = true, children, "data-component": componentName = CONFIRM_NAME }) => /* @__PURE__ */ jsx(ActionDialog.Root, {
7
+ open,
8
+ onOpenChange: (next, details) => {
9
+ if (!next && details === void 0) onCancel?.();
10
+ onOpenChange(next);
11
+ },
12
+ children: /* @__PURE__ */ jsxs(ActionDialog.Portal, { children: [/* @__PURE__ */ jsx(ActionDialog.Overlay, {}), /* @__PURE__ */ jsxs(ActionDialog.Content, {
13
+ "data-component": componentName,
14
+ size,
15
+ children: [
16
+ /* @__PURE__ */ jsx(ActionDialog.DefaultHeader, {
17
+ title,
18
+ description
19
+ }),
20
+ (question !== void 0 || children !== void 0) && /* @__PURE__ */ jsxs(ActionDialog.Body, { children: [question !== void 0 && /* @__PURE__ */ jsx("div", { children: question }), children] }),
21
+ /* @__PURE__ */ jsx(ActionDialog.Footer, {
22
+ intent,
23
+ cancelVariant: "outline",
24
+ confirmLabel,
25
+ cancelLabel,
26
+ onConfirm,
27
+ error,
28
+ closeOnConfirm
29
+ })
30
+ ]
31
+ })] })
32
+ });
33
+ DialogPresetConfirm.displayName = CONFIRM_NAME;
34
+ const DialogPreset = { Confirm: DialogPresetConfirm };
35
+ //#endregion
36
+ export { DialogPreset };
37
+
38
+ //# sourceMappingURL=dialog-preset.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dialog-preset.js","names":[],"sources":["../../src/dialog/dialog-preset.tsx"],"sourcesContent":["import type { ReactElement, ReactNode } from 'react';\n\nimport { ActionDialog, type DialogIntent, type DialogSize } from './action-dialog';\n\nexport type DialogPresetConfirmProps = {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n title: string;\n description?: ReactNode;\n question?: ReactNode;\n intent?: DialogIntent;\n size?: DialogSize;\n confirmLabel?: string;\n cancelLabel?: string;\n onConfirm: () => void;\n /** Cancel, `Escape`, the mask and the close button alike. */\n onCancel?: () => void;\n /** Why the dialog is still open after a confirm that did not close it. */\n error?: string;\n /** Off for a caller that closes after its own work, or reports an `error` instead. */\n closeOnConfirm?: boolean;\n children?: ReactNode;\n 'data-component'?: string;\n};\n\nconst CONFIRM_NAME = 'DialogPreset.Confirm';\n\n/** The plain question: a title, the question, and the two answers with equal weight. */\nconst DialogPresetConfirm = ({\n open,\n onOpenChange,\n title,\n description,\n question,\n intent = 'default',\n size = 'default',\n confirmLabel,\n cancelLabel,\n onConfirm,\n onCancel,\n error,\n closeOnConfirm = true,\n children,\n 'data-component': componentName = CONFIRM_NAME,\n}: DialogPresetConfirmProps): ReactElement => (\n <ActionDialog.Root\n open={open}\n onOpenChange={(next, details) => {\n if (!next && details === undefined) {\n onCancel?.();\n }\n onOpenChange(next);\n }}\n >\n <ActionDialog.Portal>\n <ActionDialog.Overlay />\n <ActionDialog.Content data-component={componentName} size={size}>\n <ActionDialog.DefaultHeader title={title} description={description} />\n {(question !== undefined || children !== undefined) && (\n <ActionDialog.Body>\n {question !== undefined && <div>{question}</div>}\n {children}\n </ActionDialog.Body>\n )}\n <ActionDialog.Footer\n intent={intent}\n cancelVariant=\"outline\"\n confirmLabel={confirmLabel}\n cancelLabel={cancelLabel}\n onConfirm={onConfirm}\n error={error}\n closeOnConfirm={closeOnConfirm}\n />\n </ActionDialog.Content>\n </ActionDialog.Portal>\n </ActionDialog.Root>\n);\nDialogPresetConfirm.displayName = CONFIRM_NAME;\n\nexport const DialogPreset = {\n Confirm: DialogPresetConfirm,\n};\n"],"mappings":";;;AAyBA,MAAM,eAAe;;AAGrB,MAAM,uBAAuB,EAC3B,MACA,cACA,OACA,aACA,UACA,SAAS,WACT,OAAO,WACP,cACA,aACA,WACA,UACA,OACA,iBAAiB,MACjB,UACA,kBAAkB,gBAAgB,mBAElC,oBAAC,aAAa,MAAd;CACQ;CACN,eAAe,MAAM,YAAY;EAC/B,IAAI,CAAC,QAAQ,YAAY,KAAA,GACvB,WAAW;EAEb,aAAa,IAAI;CACnB;CAEA,UAAA,qBAAC,aAAa,QAAd,EAAA,UAAA,CACE,oBAAC,aAAa,SAAd,CAAuB,CAAA,GACvB,qBAAC,aAAa,SAAd;EAAsB,kBAAgB;EAAqB;EAA3D,UAAA;GACE,oBAAC,aAAa,eAAd;IAAmC;IAAoB;GAAc,CAAA;IACnE,aAAa,KAAA,KAAa,aAAa,KAAA,MACvC,qBAAC,aAAa,MAAd,EAAA,UAAA,CACG,aAAa,KAAA,KAAa,oBAAC,OAAD,EAAA,UAAM,SAAc,CAAA,GAC9C,QACgB,EAAA,CAAA;GAErB,oBAAC,aAAa,QAAd;IACU;IACR,eAAc;IACA;IACD;IACF;IACJ;IACS;GACjB,CAAA;EACmB;CACH,CAAA,CAAA,EAAA,CAAA;AACJ,CAAA;AAErB,oBAAoB,cAAc;AAElC,MAAa,eAAe,EAC1B,SAAS,oBACX"}
@@ -0,0 +1,6 @@
1
+ //#region src/dialog/gate-match.d.ts
2
+ /** Whether what was typed is the name or the count asked for; spaces around it do not count, nothing never matches. */
3
+ declare function matchesExpected(typed: string, expected: string | number, normalize?: (typed: string) => string): boolean;
4
+ //#endregion
5
+ export { matchesExpected };
6
+ //# sourceMappingURL=gate-match.d.ts.map
@@ -0,0 +1,11 @@
1
+ //#region src/dialog/gate-match.ts
2
+ const normalizeTyped = (typed) => typed.trim();
3
+ /** Whether what was typed is the name or the count asked for; spaces around it do not count, nothing never matches. */
4
+ function matchesExpected(typed, expected, normalize = normalizeTyped) {
5
+ const entered = normalize(typed);
6
+ return entered !== "" && entered === String(expected);
7
+ }
8
+ //#endregion
9
+ export { matchesExpected, normalizeTyped };
10
+
11
+ //# sourceMappingURL=gate-match.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gate-match.js","names":[],"sources":["../../src/dialog/gate-match.ts"],"sourcesContent":["export const normalizeTyped = (typed: string): string => typed.trim();\n\n/** Whether what was typed is the name or the count asked for; spaces around it do not count, nothing never matches. */\nexport function matchesExpected(\n typed: string,\n expected: string | number,\n normalize: (typed: string) => string = normalizeTyped,\n): boolean {\n const entered = normalize(typed);\n return entered !== '' && entered === String(expected);\n}\n"],"mappings":";AAAA,MAAa,kBAAkB,UAA0B,MAAM,KAAK;;AAGpE,SAAgB,gBACd,OACA,UACA,YAAuC,gBAC9B;CACT,MAAM,UAAU,UAAU,KAAK;CAC/B,OAAO,YAAY,MAAM,YAAY,OAAO,QAAQ;AACtD"}
@@ -0,0 +1,31 @@
1
+ import { ComponentPropsWithoutRef } from "react";
2
+ import { InputProps } from "@enonic/ui";
3
+ //#region src/dialog/gate.d.ts
4
+ type GateHintProps = {
5
+ /** What has to be typed back, shown in bold inside the sentence. */
6
+ value: string | number;
7
+ } & ComponentPropsWithoutRef<'p'>;
8
+ type GateInputProps = {
9
+ expected: string | number;
10
+ /** In place of the comparison with `expected`. */
11
+ validate?: (typed: string) => boolean;
12
+ /** Applied to what was typed before the comparison; trims by default. */
13
+ normalize?: (typed: string) => string;
14
+ } & Omit<InputProps, 'value' | 'onChange' | 'error' | 'readOnly'>;
15
+ /** The typed-back confirmation: a hint naming what to type, and the field it is typed into. */
16
+ declare const Gate: import("preact").FunctionalComponent<import("preact/compat").PropsWithoutRef<ComponentPropsWithoutRef<"div">> & {
17
+ ref?: import("preact").Ref<HTMLDivElement> | undefined;
18
+ }> & {
19
+ Root: import("preact").FunctionalComponent<import("preact/compat").PropsWithoutRef<ComponentPropsWithoutRef<"div">> & {
20
+ ref?: import("preact").Ref<HTMLDivElement> | undefined;
21
+ }>;
22
+ Hint: import("preact").FunctionalComponent<import("preact/compat").PropsWithoutRef<GateHintProps> & {
23
+ ref?: import("preact").Ref<HTMLParagraphElement> | undefined;
24
+ }>;
25
+ Input: import("preact").FunctionalComponent<import("preact/compat").PropsWithoutRef<GateInputProps> & {
26
+ ref?: import("preact").Ref<HTMLInputElement> | undefined;
27
+ }>;
28
+ };
29
+ //#endregion
30
+ export { Gate, GateHintProps, GateInputProps };
31
+ //# sourceMappingURL=gate.d.ts.map
@@ -0,0 +1,72 @@
1
+ import { useActionDialog } from "./action-dialog-context.js";
2
+ import { useUiKitPhrases } from "../i18n/use-phrases.js";
3
+ import { fillPhrase } from "../i18n/fill-phrase.js";
4
+ import { matchesExpected, normalizeTyped } from "./gate-match.js";
5
+ import { forwardRef, useEffect, useLayoutEffect, useState } from "react";
6
+ import { jsx } from "react/jsx-runtime";
7
+ import { Input, cn } from "@enonic/ui";
8
+ //#region src/dialog/gate.tsx
9
+ const ERROR_DELAY_MS = 500;
10
+ const VALUE = Symbol("value");
11
+ const ROOT_NAME = "Gate.Root";
12
+ const GateRoot = forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx("div", {
13
+ ref,
14
+ "data-component": ROOT_NAME,
15
+ className: cn("bg-surface-primary flex flex-col gap-2.5 rounded-lg p-7.5", className),
16
+ ...props
17
+ }));
18
+ GateRoot.displayName = ROOT_NAME;
19
+ const HINT_NAME = "Gate.Hint";
20
+ const GateHint = forwardRef(({ value, className, ...props }, ref) => {
21
+ const t = useUiKitPhrases();
22
+ return /* @__PURE__ */ jsx("p", {
23
+ ref,
24
+ "data-component": HINT_NAME,
25
+ className: cn("text-xl", className),
26
+ ...props,
27
+ children: fillPhrase(t("enonic.uiKit.gate.hint"), [VALUE]).map((part, index) => part === VALUE ? /* @__PURE__ */ jsx("strong", { children: value }, index) : part)
28
+ });
29
+ });
30
+ GateHint.displayName = HINT_NAME;
31
+ const INPUT_NAME = "Gate.Input";
32
+ /** The field to type the name or the count into; the dialog's confirm button follows it. */
33
+ const GateInput = forwardRef(({ expected, validate, normalize = normalizeTyped, className, ...props }, ref) => {
34
+ const t = useUiKitPhrases();
35
+ const { setConfirmEnabled } = useActionDialog();
36
+ const [typed, setTyped] = useState("");
37
+ const [showError, setShowError] = useState(false);
38
+ const entered = normalize(typed);
39
+ const matched = validate ? validate(entered) : matchesExpected(typed, expected, normalize);
40
+ useLayoutEffect(() => {
41
+ setConfirmEnabled(matched);
42
+ }, [matched, setConfirmEnabled]);
43
+ useEffect(() => {
44
+ setShowError(false);
45
+ if (entered === "" || matched) return;
46
+ const timer = setTimeout(() => setShowError(true), ERROR_DELAY_MS);
47
+ return () => clearTimeout(timer);
48
+ }, [entered, matched]);
49
+ return /* @__PURE__ */ jsx(Input, {
50
+ ref,
51
+ "data-component": INPUT_NAME,
52
+ value: typed,
53
+ inputMode: typeof expected === "number" ? "numeric" : void 0,
54
+ "aria-label": t("enonic.uiKit.gate.hint", expected),
55
+ error: showError ? t("enonic.uiKit.gate.mismatch", expected, entered) : void 0,
56
+ readOnly: matched,
57
+ className: cn("w-3/5 max-w-sm", className),
58
+ onChange: (event) => setTyped(event.currentTarget.value),
59
+ ...props
60
+ });
61
+ });
62
+ GateInput.displayName = INPUT_NAME;
63
+ /** The typed-back confirmation: a hint naming what to type, and the field it is typed into. */
64
+ const Gate = Object.assign(GateRoot, {
65
+ Root: GateRoot,
66
+ Hint: GateHint,
67
+ Input: GateInput
68
+ });
69
+ //#endregion
70
+ export { Gate };
71
+
72
+ //# sourceMappingURL=gate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gate.js","names":[],"sources":["../../src/dialog/gate.tsx"],"sourcesContent":["import { cn, Input, type InputProps } from '@enonic/ui';\nimport {\n type ComponentPropsWithoutRef,\n forwardRef,\n type ReactElement,\n useEffect,\n useLayoutEffect,\n useState,\n} from 'react';\n\nimport { fillPhrase } from '../i18n/fill-phrase';\nimport { useUiKitPhrases } from '../i18n/use-phrases';\nimport { useActionDialog } from './action-dialog-context';\nimport { matchesExpected, normalizeTyped } from './gate-match';\n\n// Held back: a wrong entry is what a right one looks like halfway through.\nconst ERROR_DELAY_MS = 500;\n\n// A translation may repeat `{0}`; each occurrence renders its own keyed node.\nconst VALUE = Symbol('value');\n\n//\n// * Root\n//\n\nconst ROOT_NAME = 'Gate.Root';\n\nconst GateRoot = forwardRef<HTMLDivElement, ComponentPropsWithoutRef<'div'>>(\n ({ className, ...props }, ref): ReactElement => (\n <div\n ref={ref}\n data-component={ROOT_NAME}\n className={cn('bg-surface-primary flex flex-col gap-2.5 rounded-lg p-7.5', className)}\n {...props}\n />\n ),\n);\nGateRoot.displayName = ROOT_NAME;\n\n//\n// * Hint\n//\n\nexport type GateHintProps = {\n /** What has to be typed back, shown in bold inside the sentence. */\n value: string | number;\n} & ComponentPropsWithoutRef<'p'>;\n\nconst HINT_NAME = 'Gate.Hint';\n\nconst GateHint = forwardRef<HTMLParagraphElement, GateHintProps>(\n ({ value, className, ...props }, ref): ReactElement => {\n const t = useUiKitPhrases();\n\n return (\n <p ref={ref} data-component={HINT_NAME} className={cn('text-xl', className)} {...props}>\n {fillPhrase(t('enonic.uiKit.gate.hint'), [VALUE]).map((part, index) =>\n part === VALUE ? <strong key={index}>{value}</strong> : part,\n )}\n </p>\n );\n },\n);\nGateHint.displayName = HINT_NAME;\n\n//\n// * Input\n//\n\nexport type GateInputProps = {\n expected: string | number;\n /** In place of the comparison with `expected`. */\n validate?: (typed: string) => boolean;\n /** Applied to what was typed before the comparison; trims by default. */\n normalize?: (typed: string) => string;\n} & Omit<InputProps, 'value' | 'onChange' | 'error' | 'readOnly'>;\n\nconst INPUT_NAME = 'Gate.Input';\n\n/** The field to type the name or the count into; the dialog's confirm button follows it. */\nconst GateInput = forwardRef<HTMLInputElement, GateInputProps>(\n ({ expected, validate, normalize = normalizeTyped, className, ...props }, ref): ReactElement => {\n const t = useUiKitPhrases();\n const { setConfirmEnabled } = useActionDialog();\n const [typed, setTyped] = useState('');\n const [showError, setShowError] = useState(false);\n\n const entered = normalize(typed);\n const matched = validate ? validate(entered) : matchesExpected(typed, expected, normalize);\n\n // Layout effect: the button follows the field in the same frame the field appears or changes.\n useLayoutEffect(() => {\n setConfirmEnabled(matched);\n }, [matched, setConfirmEnabled]);\n\n useEffect(() => {\n setShowError(false);\n if (entered === '' || matched) {\n return;\n }\n const timer = setTimeout(() => setShowError(true), ERROR_DELAY_MS);\n return () => clearTimeout(timer);\n }, [entered, matched]);\n\n return (\n <Input\n ref={ref}\n data-component={INPUT_NAME}\n value={typed}\n inputMode={typeof expected === 'number' ? 'numeric' : undefined}\n aria-label={t('enonic.uiKit.gate.hint', expected)}\n error={showError ? t('enonic.uiKit.gate.mismatch', expected, entered) : undefined}\n // Matched, the field locks: what was typed cannot drift from what the button now acts on.\n readOnly={matched}\n className={cn('w-3/5 max-w-sm', className)}\n onChange={(event) => setTyped(event.currentTarget.value)}\n {...props}\n />\n );\n },\n);\nGateInput.displayName = INPUT_NAME;\n\n/** The typed-back confirmation: a hint naming what to type, and the field it is typed into. */\nexport const Gate = Object.assign(GateRoot, {\n Root: GateRoot,\n Hint: GateHint,\n Input: GateInput,\n});\n"],"mappings":";;;;;;;;AAgBA,MAAM,iBAAiB;AAGvB,MAAM,QAAQ,OAAO,OAAO;AAM5B,MAAM,YAAY;AAElB,MAAM,WAAW,YACd,EAAE,WAAW,GAAG,SAAS,QACxB,oBAAC,OAAD;CACO;CACL,kBAAgB;CAChB,WAAW,GAAG,6DAA6D,SAAS;CACpF,GAAI;AACL,CAAA,CAEL;AACA,SAAS,cAAc;AAWvB,MAAM,YAAY;AAElB,MAAM,WAAW,YACd,EAAE,OAAO,WAAW,GAAG,SAAS,QAAsB;CACrD,MAAM,IAAI,gBAAgB;CAE1B,OACE,oBAAC,KAAD;EAAQ;EAAK,kBAAgB;EAAW,WAAW,GAAG,WAAW,SAAS;EAAG,GAAI;EAC9E,UAAA,WAAW,EAAE,wBAAwB,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,MAAM,UAC3D,SAAS,QAAQ,oBAAC,UAAD,EAAA,UAAqB,MAAc,GAAtB,KAAsB,IAAI,IAC1D;CACC,CAAA;AAEP,CACF;AACA,SAAS,cAAc;AAcvB,MAAM,aAAa;;AAGnB,MAAM,YAAY,YACf,EAAE,UAAU,UAAU,YAAY,gBAAgB,WAAW,GAAG,SAAS,QAAsB;CAC9F,MAAM,IAAI,gBAAgB;CAC1B,MAAM,EAAE,sBAAsB,gBAAgB;CAC9C,MAAM,CAAC,OAAO,YAAY,SAAS,EAAE;CACrC,MAAM,CAAC,WAAW,gBAAgB,SAAS,KAAK;CAEhD,MAAM,UAAU,UAAU,KAAK;CAC/B,MAAM,UAAU,WAAW,SAAS,OAAO,IAAI,gBAAgB,OAAO,UAAU,SAAS;CAGzF,sBAAsB;EACpB,kBAAkB,OAAO;CAC3B,GAAG,CAAC,SAAS,iBAAiB,CAAC;CAE/B,gBAAgB;EACd,aAAa,KAAK;EAClB,IAAI,YAAY,MAAM,SACpB;EAEF,MAAM,QAAQ,iBAAiB,aAAa,IAAI,GAAG,cAAc;EACjE,aAAa,aAAa,KAAK;CACjC,GAAG,CAAC,SAAS,OAAO,CAAC;CAErB,OACE,oBAAC,OAAD;EACO;EACL,kBAAgB;EAChB,OAAO;EACP,WAAW,OAAO,aAAa,WAAW,YAAY,KAAA;EACtD,cAAY,EAAE,0BAA0B,QAAQ;EAChD,OAAO,YAAY,EAAE,8BAA8B,UAAU,OAAO,IAAI,KAAA;EAExE,UAAU;EACV,WAAW,GAAG,kBAAkB,SAAS;EACzC,WAAW,UAAU,SAAS,MAAM,cAAc,KAAK;EACvD,GAAI;CACL,CAAA;AAEL,CACF;AACA,UAAU,cAAc;;AAGxB,MAAa,OAAO,OAAO,OAAO,UAAU;CAC1C,MAAM;CACN,MAAM;CACN,OAAO;AACT,CAAC"}
@@ -0,0 +1,34 @@
1
+ import { ActionDialogOpenChangeDetails } from "./action-dialog-context.js";
2
+ //#region src/dialog/use-close-guard.d.ts
3
+ type CloseGuardOptions = {
4
+ /** Whether closing would lose something. */
5
+ dirty: boolean;
6
+ open: boolean;
7
+ onOpenChange: (open: boolean) => void;
8
+ };
9
+ type CloseGuard = {
10
+ /** Whether the question is being asked. */
11
+ asking: boolean;
12
+ /**
13
+ * For `ActionDialog.Root`: lets an open through, and a close an action took; asks before any
14
+ * other close that would lose something.
15
+ */
16
+ onOpenChange: (open: boolean, details?: ActionDialogOpenChangeDetails) => void;
17
+ /** Asks, or closes when there is nothing to lose — a Cancel button. */
18
+ requestClose: () => void;
19
+ /** The way back: the question goes, and the footer returns the focus to the control it left. */
20
+ keep: () => void;
21
+ /** Closes, losing what there was. */
22
+ discard: () => void;
23
+ };
24
+ /**
25
+ * The dirty-close question of a dialog: `Escape`, the mask, the close button and Cancel all come
26
+ * through `onOpenChange(false)`, and a dialog with unsaved edits asks before it closes. A close
27
+ * from the footer's Confirm or an `Action` is the outcome itself and goes through unasked — its
28
+ * click runs before the render that would clear `dirty`. The footer renders the question; this
29
+ * decides when it is asked.
30
+ */
31
+ declare function useCloseGuard({ dirty, open, onOpenChange }: CloseGuardOptions): CloseGuard;
32
+ //#endregion
33
+ export { CloseGuard, CloseGuardOptions, useCloseGuard };
34
+ //# sourceMappingURL=use-close-guard.d.ts.map
@@ -0,0 +1,40 @@
1
+ import { useEffect, useState } from "react";
2
+ //#region src/dialog/use-close-guard.ts
3
+ /**
4
+ * The dirty-close question of a dialog: `Escape`, the mask, the close button and Cancel all come
5
+ * through `onOpenChange(false)`, and a dialog with unsaved edits asks before it closes. A close
6
+ * from the footer's Confirm or an `Action` is the outcome itself and goes through unasked — its
7
+ * click runs before the render that would clear `dirty`. The footer renders the question; this
8
+ * decides when it is asked.
9
+ */
10
+ function useCloseGuard({ dirty, open, onOpenChange }) {
11
+ const [asking, setAsking] = useState(false);
12
+ useEffect(() => {
13
+ if (!open) setAsking(false);
14
+ }, [open]);
15
+ const requestClose = () => {
16
+ if (dirty) setAsking(true);
17
+ else onOpenChange(false);
18
+ };
19
+ const keep = () => setAsking(false);
20
+ const discard = () => {
21
+ setAsking(false);
22
+ onOpenChange(false);
23
+ };
24
+ return {
25
+ asking,
26
+ requestClose,
27
+ keep,
28
+ discard,
29
+ onOpenChange: (next, details) => {
30
+ if (next) onOpenChange(true);
31
+ else if (details?.reason === "action") discard();
32
+ else if (asking) keep();
33
+ else requestClose();
34
+ }
35
+ };
36
+ }
37
+ //#endregion
38
+ export { useCloseGuard };
39
+
40
+ //# sourceMappingURL=use-close-guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-close-guard.js","names":[],"sources":["../../src/dialog/use-close-guard.ts"],"sourcesContent":["import { useEffect, useState } from 'react';\n\nimport type { ActionDialogOpenChangeDetails } from './action-dialog-context';\n\nexport type CloseGuardOptions = {\n /** Whether closing would lose something. */\n dirty: boolean;\n open: boolean;\n onOpenChange: (open: boolean) => void;\n};\n\nexport type CloseGuard = {\n /** Whether the question is being asked. */\n asking: boolean;\n /**\n * For `ActionDialog.Root`: lets an open through, and a close an action took; asks before any\n * other close that would lose something.\n */\n onOpenChange: (open: boolean, details?: ActionDialogOpenChangeDetails) => void;\n /** Asks, or closes when there is nothing to lose — a Cancel button. */\n requestClose: () => void;\n /** The way back: the question goes, and the footer returns the focus to the control it left. */\n keep: () => void;\n /** Closes, losing what there was. */\n discard: () => void;\n};\n\n/**\n * The dirty-close question of a dialog: `Escape`, the mask, the close button and Cancel all come\n * through `onOpenChange(false)`, and a dialog with unsaved edits asks before it closes. A close\n * from the footer's Confirm or an `Action` is the outcome itself and goes through unasked — its\n * click runs before the render that would clear `dirty`. The footer renders the question; this\n * decides when it is asked.\n */\nexport function useCloseGuard({ dirty, open, onOpenChange }: CloseGuardOptions): CloseGuard {\n const [asking, setAsking] = useState(false);\n\n useEffect(() => {\n if (!open) {\n setAsking(false);\n }\n }, [open]);\n\n const requestClose = (): void => {\n if (dirty) {\n setAsking(true);\n } else {\n onOpenChange(false);\n }\n };\n const keep = (): void => setAsking(false);\n const discard = (): void => {\n setAsking(false);\n onOpenChange(false);\n };\n\n return {\n asking,\n requestClose,\n keep,\n discard,\n onOpenChange: (next, details) => {\n if (next) {\n onOpenChange(true);\n } else if (details?.reason === 'action') {\n discard();\n } else if (asking) {\n keep();\n } else {\n requestClose();\n }\n },\n };\n}\n"],"mappings":";;;;;;;;;AAkCA,SAAgB,cAAc,EAAE,OAAO,MAAM,gBAA+C;CAC1F,MAAM,CAAC,QAAQ,aAAa,SAAS,KAAK;CAE1C,gBAAgB;EACd,IAAI,CAAC,MACH,UAAU,KAAK;CAEnB,GAAG,CAAC,IAAI,CAAC;CAET,MAAM,qBAA2B;EAC/B,IAAI,OACF,UAAU,IAAI;OAEd,aAAa,KAAK;CAEtB;CACA,MAAM,aAAmB,UAAU,KAAK;CACxC,MAAM,gBAAsB;EAC1B,UAAU,KAAK;EACf,aAAa,KAAK;CACpB;CAEA,OAAO;EACL;EACA;EACA;EACA;EACA,eAAe,MAAM,YAAY;GAC/B,IAAI,MACF,aAAa,IAAI;QACZ,IAAI,SAAS,WAAW,UAC7B,QAAQ;QACH,IAAI,QACT,KAAK;QAEL,aAAa;EAEjB;CACF;AACF"}
@@ -0,0 +1,33 @@
1
+ import { LucideIcon } from "lucide-react";
2
+ //#region src/filter/filter-fields.d.ts
3
+ type FilterValue = {
4
+ id: string;
5
+ label: string;
6
+ /**
7
+ * How many rows fall under this value, where that is knowable.
8
+ *
9
+ * ! Absent, not zero, where nothing can count: a value without a count is always offered — there is
10
+ * ! nothing to tell it apart from an empty one.
11
+ */
12
+ count?: number;
13
+ /** Values sharing a group are listed together under its label, after the ungrouped ones. */
14
+ group?: string;
15
+ /** Withheld for a reason other than an empty count; the caller says why, if anywhere. */
16
+ disabled?: boolean;
17
+ };
18
+ /** A field the filter offers, with the values it can take. */
19
+ type FilterField = {
20
+ id: string;
21
+ label: string;
22
+ icon?: LucideIcon;
23
+ values: readonly FilterValue[];
24
+ /** Whether several values can be picked at once, ORed. Off, a pick replaces the field's value. */
25
+ multiple?: boolean;
26
+ /** Shown above the values: why the list may be short or stale, such as a count request that failed. */
27
+ notice?: string;
28
+ /** The values are still being fetched; the dropdown says so instead of "no matches". */
29
+ loading?: boolean;
30
+ };
31
+ //#endregion
32
+ export { FilterField, FilterValue };
33
+ //# sourceMappingURL=filter-fields.d.ts.map
@@ -0,0 +1,72 @@
1
+ import { isTextTerm } from "./filter-query.js";
2
+ //#region src/filter/filter-fields.ts
3
+ /** Whether a value can be picked: one with a count of zero would narrow the list to nothing. */
4
+ function isOffered({ count, disabled }) {
5
+ return disabled !== true && (count === void 0 || count > 0);
6
+ }
7
+ /**
8
+ * The values by hits, most first, the ones with none last. A value without a count comes first: it is
9
+ * offered whatever the rows, so it must not sink below the empty ones. Ties keep the field's own order.
10
+ */
11
+ function orderedValues(values) {
12
+ return [...values].sort((a, b) => rank(b) - rank(a));
13
+ }
14
+ function rank({ count }) {
15
+ return count ?? Number.POSITIVE_INFINITY;
16
+ }
17
+ /**
18
+ * The values as the dropdown lists them: the ungrouped ones first, then every group in the order it
19
+ * first appears, each run ordered by hits on its own — a group's empty values sink to its end, not
20
+ * to the end of the list, or the group would lose them to the one below.
21
+ */
22
+ function groupedValues(values) {
23
+ const runs = /* @__PURE__ */ new Map([[void 0, []]]);
24
+ for (const value of values) {
25
+ const run = runs.get(value.group);
26
+ if (run === void 0) runs.set(value.group, [value]);
27
+ else run.push(value);
28
+ }
29
+ return [...runs].filter(([, run]) => run.length > 0).map(([label, run]) => ({
30
+ label,
31
+ values: orderedValues(run)
32
+ }));
33
+ }
34
+ /** The fields whose label contains what was typed, case-insensitive; every field for a blank. */
35
+ function matchingFields(fields, typed) {
36
+ const needle = typed.trim().toLowerCase();
37
+ return needle.length === 0 ? [...fields] : fields.filter(({ label }) => label.toLowerCase().includes(needle));
38
+ }
39
+ function matchingValues(values, typed) {
40
+ const needle = typed.trim().toLowerCase();
41
+ return needle.length === 0 ? [...values] : values.filter(({ label }) => label.toLowerCase().includes(needle));
42
+ }
43
+ const ASCII_PREFIXES = (label) => [`${label}:`];
44
+ /**
45
+ * The field a typed prefix names, case-insensitive, with the text after it: what was typed up to the
46
+ * colon, or a whole `Label: value` pasted in. The longest prefix wins where one is a prefix of another.
47
+ */
48
+ function fieldTyped(fields, typed, prefixesOf = ASCII_PREFIXES) {
49
+ const text = typed.trimStart();
50
+ const needle = text.toLowerCase();
51
+ const longest = fields.flatMap((field) => prefixesOf(field.label).filter((prefix) => prefix.length > 0 && needle.startsWith(prefix.toLowerCase())).map((prefix) => ({
52
+ field,
53
+ prefix
54
+ }))).sort((a, b) => b.prefix.length - a.prefix.length)[0];
55
+ return longest === void 0 ? void 0 : {
56
+ field: longest.field,
57
+ text: text.slice(longest.prefix.length).trim()
58
+ };
59
+ }
60
+ function termLabel(term, fields) {
61
+ if (isTextTerm(term)) return { value: term.text };
62
+ const field = fields.find(({ id }) => id === term.field);
63
+ const value = field?.values.find(({ id }) => id === term.value);
64
+ return {
65
+ field: field?.label ?? term.field,
66
+ value: value?.label ?? term.value
67
+ };
68
+ }
69
+ //#endregion
70
+ export { fieldTyped, groupedValues, isOffered, matchingFields, matchingValues, orderedValues, termLabel };
71
+
72
+ //# sourceMappingURL=filter-fields.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"filter-fields.js","names":[],"sources":["../../src/filter/filter-fields.ts"],"sourcesContent":["import type { LucideIcon } from 'lucide-react';\n\nimport { isTextTerm, type FilterTerm } from './filter-query';\n\nexport type FilterValue = {\n id: string;\n label: string;\n /**\n * How many rows fall under this value, where that is knowable.\n *\n * ! Absent, not zero, where nothing can count: a value without a count is always offered — there is\n * ! nothing to tell it apart from an empty one.\n */\n count?: number;\n /** Values sharing a group are listed together under its label, after the ungrouped ones. */\n group?: string;\n /** Withheld for a reason other than an empty count; the caller says why, if anywhere. */\n disabled?: boolean;\n};\n\n/** A run of values the dropdown lists together: the ungrouped ones first and unlabelled, then each group. */\nexport type FilterValueGroup = {\n label?: string;\n values: readonly FilterValue[];\n};\n\n/** A field the filter offers, with the values it can take. */\nexport type FilterField = {\n id: string;\n label: string;\n icon?: LucideIcon;\n values: readonly FilterValue[];\n /** Whether several values can be picked at once, ORed. Off, a pick replaces the field's value. */\n multiple?: boolean;\n /** Shown above the values: why the list may be short or stale, such as a count request that failed. */\n notice?: string;\n /** The values are still being fetched; the dropdown says so instead of \"no matches\". */\n loading?: boolean;\n};\n\n/** A field named by what was typed, and whatever followed its label — `State: Started` pasted whole. */\nexport type TypedField = {\n field: FilterField;\n text: string;\n};\n\n/** What a tag shows for a term: the labels, falling back to the ids for a field or value no longer offered. */\nexport type TermLabel = {\n field?: string;\n value: string;\n};\n\n/** Whether a value can be picked: one with a count of zero would narrow the list to nothing. */\nexport function isOffered({ count, disabled }: FilterValue): boolean {\n return disabled !== true && (count === undefined || count > 0);\n}\n\n/**\n * The values by hits, most first, the ones with none last. A value without a count comes first: it is\n * offered whatever the rows, so it must not sink below the empty ones. Ties keep the field's own order.\n */\nexport function orderedValues(values: readonly FilterValue[]): FilterValue[] {\n return [...values].sort((a, b) => rank(b) - rank(a));\n}\n\nfunction rank({ count }: FilterValue): number {\n return count ?? Number.POSITIVE_INFINITY;\n}\n\n/**\n * The values as the dropdown lists them: the ungrouped ones first, then every group in the order it\n * first appears, each run ordered by hits on its own — a group's empty values sink to its end, not\n * to the end of the list, or the group would lose them to the one below.\n */\nexport function groupedValues(values: readonly FilterValue[]): FilterValueGroup[] {\n const runs = new Map<string | undefined, FilterValue[]>([[undefined, []]]);\n for (const value of values) {\n const run = runs.get(value.group);\n if (run === undefined) {\n runs.set(value.group, [value]);\n } else {\n run.push(value);\n }\n }\n\n return [...runs]\n .filter(([, run]) => run.length > 0)\n .map(([label, run]) => ({ label, values: orderedValues(run) }));\n}\n\n/** The fields whose label contains what was typed, case-insensitive; every field for a blank. */\nexport function matchingFields(fields: readonly FilterField[], typed: string): FilterField[] {\n const needle = typed.trim().toLowerCase();\n return needle.length === 0\n ? [...fields]\n : fields.filter(({ label }) => label.toLowerCase().includes(needle));\n}\n\nexport function matchingValues(values: readonly FilterValue[], typed: string): FilterValue[] {\n const needle = typed.trim().toLowerCase();\n return needle.length === 0\n ? [...values]\n : values.filter(({ label }) => label.toLowerCase().includes(needle));\n}\n\n/** The forms a field's label is typed in to enter it: `Label:` always, plus what the tags show, as a locale punctuates it. */\nexport type FieldPrefixes = (label: string) => readonly string[];\n\nconst ASCII_PREFIXES: FieldPrefixes = (label) => [`${label}:`];\n\n/**\n * The field a typed prefix names, case-insensitive, with the text after it: what was typed up to the\n * colon, or a whole `Label: value` pasted in. The longest prefix wins where one is a prefix of another.\n */\nexport function fieldTyped(\n fields: readonly FilterField[],\n typed: string,\n prefixesOf: FieldPrefixes = ASCII_PREFIXES,\n): TypedField | undefined {\n const text = typed.trimStart();\n const needle = text.toLowerCase();\n const matches = fields.flatMap((field) =>\n prefixesOf(field.label)\n .filter((prefix) => prefix.length > 0 && needle.startsWith(prefix.toLowerCase()))\n .map((prefix) => ({ field, prefix })),\n );\n const longest = matches.sort((a, b) => b.prefix.length - a.prefix.length)[0];\n\n return longest === undefined\n ? undefined\n : { field: longest.field, text: text.slice(longest.prefix.length).trim() };\n}\n\nexport function termLabel(term: FilterTerm, fields: readonly FilterField[]): TermLabel {\n if (isTextTerm(term)) {\n return { value: term.text };\n }\n\n const field = fields.find(({ id }) => id === term.field);\n const value = field?.values.find(({ id }) => id === term.value);\n\n return { field: field?.label ?? term.field, value: value?.label ?? term.value };\n}\n"],"mappings":";;;AAqDA,SAAgB,UAAU,EAAE,OAAO,YAAkC;CACnE,OAAO,aAAa,SAAS,UAAU,KAAA,KAAa,QAAQ;AAC9D;;;;;AAMA,SAAgB,cAAc,QAA+C;CAC3E,OAAO,CAAC,GAAG,MAAM,CAAC,CAAC,MAAM,GAAG,MAAM,KAAK,CAAC,IAAI,KAAK,CAAC,CAAC;AACrD;AAEA,SAAS,KAAK,EAAE,SAA8B;CAC5C,OAAO,SAAS,OAAO;AACzB;;;;;;AAOA,SAAgB,cAAc,QAAoD;CAChF,MAAM,uBAAO,IAAI,IAAuC,CAAC,CAAC,KAAA,GAAW,CAAC,CAAC,CAAC,CAAC;CACzE,KAAK,MAAM,SAAS,QAAQ;EAC1B,MAAM,MAAM,KAAK,IAAI,MAAM,KAAK;EAChC,IAAI,QAAQ,KAAA,GACV,KAAK,IAAI,MAAM,OAAO,CAAC,KAAK,CAAC;OAE7B,IAAI,KAAK,KAAK;CAElB;CAEA,OAAO,CAAC,GAAG,IAAI,CAAC,CACb,QAAQ,GAAG,SAAS,IAAI,SAAS,CAAC,CAAC,CACnC,KAAK,CAAC,OAAO,UAAU;EAAE;EAAO,QAAQ,cAAc,GAAG;CAAE,EAAE;AAClE;;AAGA,SAAgB,eAAe,QAAgC,OAA8B;CAC3F,MAAM,SAAS,MAAM,KAAK,CAAC,CAAC,YAAY;CACxC,OAAO,OAAO,WAAW,IACrB,CAAC,GAAG,MAAM,IACV,OAAO,QAAQ,EAAE,YAAY,MAAM,YAAY,CAAC,CAAC,SAAS,MAAM,CAAC;AACvE;AAEA,SAAgB,eAAe,QAAgC,OAA8B;CAC3F,MAAM,SAAS,MAAM,KAAK,CAAC,CAAC,YAAY;CACxC,OAAO,OAAO,WAAW,IACrB,CAAC,GAAG,MAAM,IACV,OAAO,QAAQ,EAAE,YAAY,MAAM,YAAY,CAAC,CAAC,SAAS,MAAM,CAAC;AACvE;AAKA,MAAM,kBAAiC,UAAU,CAAC,GAAG,MAAM,EAAE;;;;;AAM7D,SAAgB,WACd,QACA,OACA,aAA4B,gBACJ;CACxB,MAAM,OAAO,MAAM,UAAU;CAC7B,MAAM,SAAS,KAAK,YAAY;CAMhC,MAAM,UALU,OAAO,SAAS,UAC9B,WAAW,MAAM,KAAK,CAAC,CACpB,QAAQ,WAAW,OAAO,SAAS,KAAK,OAAO,WAAW,OAAO,YAAY,CAAC,CAAC,CAAC,CAChF,KAAK,YAAY;EAAE;EAAO;CAAO,EAAE,CAElB,CAAC,CAAC,MAAM,GAAG,MAAM,EAAE,OAAO,SAAS,EAAE,OAAO,MAAM,CAAC,CAAC;CAE1E,OAAO,YAAY,KAAA,IACf,KAAA,IACA;EAAE,OAAO,QAAQ;EAAO,MAAM,KAAK,MAAM,QAAQ,OAAO,MAAM,CAAC,CAAC,KAAK;CAAE;AAC7E;AAEA,SAAgB,UAAU,MAAkB,QAA2C;CACrF,IAAI,WAAW,IAAI,GACjB,OAAO,EAAE,OAAO,KAAK,KAAK;CAG5B,MAAM,QAAQ,OAAO,MAAM,EAAE,SAAS,OAAO,KAAK,KAAK;CACvD,MAAM,QAAQ,OAAO,OAAO,MAAM,EAAE,SAAS,OAAO,KAAK,KAAK;CAE9D,OAAO;EAAE,OAAO,OAAO,SAAS,KAAK;EAAO,OAAO,OAAO,SAAS,KAAK;CAAM;AAChF"}
@@ -0,0 +1,35 @@
1
+ import { FilterQuery } from "./filter-query.js";
2
+ import { FilterField } from "./filter-fields.js";
3
+ import { Ref } from "react";
4
+ //#region src/filter/filter-input.d.ts
5
+ type FilterInputProps = {
6
+ /** What the dropdown offers. None leaves the input a free-text search. */
7
+ fields: readonly FilterField[];
8
+ /** The query, controlled; leave it out and the input keeps its own, starting from `defaultValue`. */
9
+ value?: FilterQuery;
10
+ defaultValue?: FilterQuery;
11
+ onChange?: (query: FilterQuery) => void;
12
+ /** What the empty input invites, named for the list: `Search users`. Defaults to the kit's "Search". */
13
+ placeholder?: string;
14
+ disabled?: boolean;
15
+ /** The tags are the whole content: no input, no crosses, no clearing. */
16
+ readOnly?: boolean;
17
+ /** The box reads as invalid; what is wrong with the query is the caller's to say beside it. */
18
+ error?: boolean;
19
+ className?: string;
20
+ 'data-component'?: string;
21
+ };
22
+ /**
23
+ * The filter of a list: values of predefined fields combined with free text, every term a
24
+ * tag. The dropdown opens on a click, a keystroke or an arrow — not on focus, so the focus the
25
+ * library returns to the input on Escape does not reopen it. The fields come first; picking one, or
26
+ * typing its label and a colon, opens its values with their hit counts, and the values stay open
27
+ * across picks so several can be ticked. Enter picks the highlighted option, or adds typed text as
28
+ * a tag when no field is entered. The ref is the text input's.
29
+ */
30
+ declare const FilterInput: import("preact").FunctionalComponent<import("preact/compat").PropsWithoutRef<FilterInputProps> & {
31
+ ref?: Ref<HTMLInputElement> | undefined;
32
+ }>;
33
+ //#endregion
34
+ export { FilterInput, FilterInputProps };
35
+ //# sourceMappingURL=filter-input.d.ts.map