@eifi1/ui-kit 0.5.1 → 0.6.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.
Files changed (128) hide show
  1. package/README.md +50 -1
  2. package/dist/components/amount-input.d.ts +7 -0
  3. package/dist/components/autocomplete.d.ts +101 -0
  4. package/dist/components/autocomplete.js +260 -0
  5. package/dist/components/autocomplete.js.map +1 -0
  6. package/dist/components/calculator.d.ts +7 -0
  7. package/dist/components/chip.d.ts +3 -2
  8. package/dist/components/chip.js +14 -1
  9. package/dist/components/chip.js.map +1 -1
  10. package/dist/components/choice-card.d.ts +100 -0
  11. package/dist/components/choice-card.js +170 -0
  12. package/dist/components/choice-card.js.map +1 -0
  13. package/dist/components/combobox-core.d.ts +76 -6
  14. package/dist/components/combobox-core.js +119 -49
  15. package/dist/components/combobox-core.js.map +1 -1
  16. package/dist/components/combobox.d.ts +12 -2
  17. package/dist/components/combobox.js +42 -17
  18. package/dist/components/combobox.js.map +1 -1
  19. package/dist/components/danger-confirm.d.ts +91 -0
  20. package/dist/components/danger-confirm.js +181 -0
  21. package/dist/components/danger-confirm.js.map +1 -0
  22. package/dist/components/dialog-frame.d.ts +84 -0
  23. package/dist/components/dialog-frame.js +86 -0
  24. package/dist/components/dialog-frame.js.map +1 -0
  25. package/dist/components/disclosure.d.ts +108 -0
  26. package/dist/components/disclosure.js +127 -0
  27. package/dist/components/disclosure.js.map +1 -0
  28. package/dist/components/entity-combobox.d.ts +17 -3
  29. package/dist/components/entity-combobox.js +25 -5
  30. package/dist/components/entity-combobox.js.map +1 -1
  31. package/dist/components/file-button.d.ts +161 -0
  32. package/dist/components/file-button.js +225 -0
  33. package/dist/components/file-button.js.map +1 -0
  34. package/dist/components/file-dropzone.d.ts +72 -23
  35. package/dist/components/file-dropzone.js +219 -94
  36. package/dist/components/file-dropzone.js.map +1 -1
  37. package/dist/components/icon-picker.d.ts +72 -0
  38. package/dist/components/icon-picker.js +104 -0
  39. package/dist/components/icon-picker.js.map +1 -0
  40. package/dist/components/mini-calendar.d.ts +3 -0
  41. package/dist/components/mini-calendar.js +4 -3
  42. package/dist/components/mini-calendar.js.map +1 -1
  43. package/dist/components/modal.d.ts +8 -1
  44. package/dist/components/modal.js +4 -2
  45. package/dist/components/modal.js.map +1 -1
  46. package/dist/components/multi-entity-combobox.d.ts +16 -3
  47. package/dist/components/multi-entity-combobox.js +25 -5
  48. package/dist/components/multi-entity-combobox.js.map +1 -1
  49. package/dist/components/number-field.d.ts +41 -1
  50. package/dist/components/number-field.js +42 -10
  51. package/dist/components/number-field.js.map +1 -1
  52. package/dist/components/number-input.d.ts +35 -2
  53. package/dist/components/number-input.js +35 -4
  54. package/dist/components/number-input.js.map +1 -1
  55. package/dist/components/numpad-sheet.d.ts +7 -0
  56. package/dist/components/search-field.d.ts +16 -0
  57. package/dist/components/search-field.js +29 -7
  58. package/dist/components/search-field.js.map +1 -1
  59. package/dist/components/signature-pad.d.ts +43 -1
  60. package/dist/components/signature-pad.js +74 -2
  61. package/dist/components/signature-pad.js.map +1 -1
  62. package/dist/components/swatch-picker.d.ts +69 -0
  63. package/dist/components/swatch-picker.js +75 -0
  64. package/dist/components/swatch-picker.js.map +1 -0
  65. package/dist/components/tile-radio.d.ts +50 -0
  66. package/dist/components/tile-radio.js +140 -0
  67. package/dist/components/tile-radio.js.map +1 -0
  68. package/dist/components/toggle-group.d.ts +27 -5
  69. package/dist/components/toggle-group.js +22 -15
  70. package/dist/components/toggle-group.js.map +1 -1
  71. package/dist/components/ui.d.ts +150 -12
  72. package/dist/components/ui.js +196 -22
  73. package/dist/components/ui.js.map +1 -1
  74. package/dist/i18n/defaults.d.ts +7 -0
  75. package/dist/i18n/defaults.js +13 -2
  76. package/dist/i18n/defaults.js.map +1 -1
  77. package/dist/i18n/kit-labels.d.ts +38 -5
  78. package/dist/i18n/kit-labels.js +12 -4
  79. package/dist/i18n/kit-labels.js.map +1 -1
  80. package/dist/index.d.ts +16 -7
  81. package/dist/index.js +12 -0
  82. package/dist/index.js.map +1 -1
  83. package/dist/lib/table-text.d.ts +127 -0
  84. package/dist/lib/table-text.js +82 -0
  85. package/dist/lib/table-text.js.map +1 -0
  86. package/dist/rhf/form.d.ts +79 -0
  87. package/dist/rhf/form.js +143 -0
  88. package/dist/rhf/form.js.map +1 -0
  89. package/dist/rhf.d.ts +4 -0
  90. package/dist/rhf.js +3 -0
  91. package/dist/rhf.js.map +1 -0
  92. package/dist/shell/app-shell.js +3 -1
  93. package/dist/shell/app-shell.js.map +1 -1
  94. package/dist/table-text.d.ts +1 -0
  95. package/dist/table-text.js +3 -0
  96. package/dist/table-text.js.map +1 -0
  97. package/package.json +14 -1
  98. package/src/components/autocomplete.tsx +425 -0
  99. package/src/components/chip.tsx +24 -2
  100. package/src/components/choice-card.tsx +305 -0
  101. package/src/components/combobox-core.tsx +228 -58
  102. package/src/components/combobox.tsx +58 -21
  103. package/src/components/danger-confirm.tsx +286 -0
  104. package/src/components/dialog-frame.tsx +179 -0
  105. package/src/components/disclosure.tsx +259 -0
  106. package/src/components/entity-combobox.tsx +41 -6
  107. package/src/components/file-button.tsx +431 -0
  108. package/src/components/file-dropzone.tsx +323 -117
  109. package/src/components/icon-picker.tsx +181 -0
  110. package/src/components/mini-calendar.tsx +7 -3
  111. package/src/components/modal.tsx +10 -2
  112. package/src/components/multi-entity-combobox.tsx +40 -6
  113. package/src/components/number-field.tsx +86 -10
  114. package/src/components/number-input.tsx +79 -2
  115. package/src/components/search-field.tsx +49 -6
  116. package/src/components/signature-pad.tsx +112 -0
  117. package/src/components/swatch-picker.tsx +141 -0
  118. package/src/components/tile-radio.tsx +228 -0
  119. package/src/components/toggle-group.tsx +54 -18
  120. package/src/components/ui.tsx +400 -24
  121. package/src/i18n/defaults.ts +12 -1
  122. package/src/i18n/kit-labels.tsx +45 -5
  123. package/src/index.ts +19 -0
  124. package/src/lib/table-text.ts +265 -0
  125. package/src/rhf/form.tsx +300 -0
  126. package/src/rhf.ts +9 -0
  127. package/src/shell/app-shell.tsx +3 -1
  128. package/src/table-text.ts +8 -0
@@ -0,0 +1,84 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import { ModalProps } from './modal.js';
4
+
5
+ interface DialogFrameLabels {
6
+ /** Accessible name of the header's X, when {@link DialogFrameProps.closeButton} shows it. */
7
+ close: string;
8
+ }
9
+ declare const DEFAULT_DIALOG_FRAME_LABELS: DialogFrameLabels;
10
+ /**
11
+ * `ModalProps` minus the three this component owns: the NAME (`labelledBy` — the
12
+ * heading's id is generated inside and never reaches the caller), the content
13
+ * (`children` is the body here, not the whole panel) and `title`, which is the dialog's
14
+ * HEADING and a ReactNode rather than the browser's tooltip string — the collision
15
+ * `PickerSheet` met first. Everything else, `size`, `draggable`, `fullBleed`,
16
+ * `onKeyDown`, a `data-tour` anchor, still reaches the `Modal`.
17
+ */
18
+ interface DialogFrameProps extends Omit<ModalProps, "labelledBy" | "children" | "title"> {
19
+ /** The heading, and therefore the dialog's accessible name (`aria-labelledby`). */
20
+ title: ReactNode;
21
+ /** The smaller line under the heading; wired to `aria-describedby`. */
22
+ description?: ReactNode;
23
+ /**
24
+ * The heading's level. `h2` by default, which is what every dialog in both apps
25
+ * writes; a prop for a page that nests its demos under a real heading.
26
+ */
27
+ headingAs?: "h1" | "h2" | "h3" | "h4";
28
+ /**
29
+ * The row under the body — buttons, in the caller's own order and variants (the two
30
+ * apps disagree about the cancel button's variant, so the frame has no opinion on
31
+ * it). Stays put while the body scrolls.
32
+ *
33
+ * A FUNCTION receives the panel's animated close: `(close) => <Button
34
+ * onClick={close}>Cancel</Button>` lowers the panel the way Escape does, where
35
+ * calling `onClose` directly unmounts it at once.
36
+ */
37
+ actions?: ReactNode | ((close: () => void) => ReactNode);
38
+ /**
39
+ * Show an X in the header. Off by default: a centred dialog has a backdrop and
40
+ * Escape, and a form dialog has a Cancel. On for a dialog that commits as it goes
41
+ * and has no actions row, where the X is the only visible way out.
42
+ */
43
+ closeButton?: boolean;
44
+ /** Default: `dialogFrame.close` from the {@link UiKitProvider}, else "Close". */
45
+ closeLabel?: string;
46
+ /** Extra classes for the scrolling body (default spacing `space-y-3`). */
47
+ bodyClassName?: string;
48
+ /** The body. */
49
+ children: ReactNode;
50
+ }
51
+ /**
52
+ * A {@link Modal} with the frame every caller was writing by hand: a heading, an
53
+ * optional description, an optional X, a body that scrolls, and an actions row that
54
+ * does not.
55
+ *
56
+ * 34 dialogs across the two apps framed themselves — a heading with an id invented per
57
+ * file (and spelt three ways), four visible type sizes for one thing, ten spellings of
58
+ * one right-aligned button row — and the package's own feedback dialog shipped with no
59
+ * accessible name at all. This makes the name unforgettable: the heading's id comes
60
+ * from `useId()` and goes straight to `Modal`'s `labelledBy`, so a framed dialog cannot
61
+ * announce as just "dialog", and two open instances cannot share an id.
62
+ *
63
+ * ## It wraps, it does not change `Modal`
64
+ *
65
+ * `Modal` keeps `labelledBy` and every existing caller compiles untouched. What the
66
+ * frame changes is inside the panel: the panel becomes a flex column with no padding
67
+ * of its own, and only the BODY scrolls. The panel's `max-h-full` is still the outer
68
+ * bound, so a tall form keeps its heading and its Save button on screen instead of
69
+ * scrolling them away with the fields. A caller's own `className` still wins (it is
70
+ * tailwind-merged last), which is how a full-screen phone sheet is spelt:
71
+ * `fullBleed className="h-[100dvh] max-w-full rounded-none md:h-auto md:rounded-lg"`.
72
+ *
73
+ * ## What it is not
74
+ *
75
+ * Not `FullBleedDialog`: that is the phone's full-screen editor with its own `open`,
76
+ * Back handling and a required X, and it already draws a frame of its own. And not a
77
+ * form: submit handling, a pending label and close-on-success stay the caller's.
78
+ * Focus lands on the panel, as `Modal` decides — not on the first field, so opening
79
+ * does not pop a phone's keyboard; an `autoFocus` in the body overrides that from the
80
+ * caller's side, and should be a decision rather than a habit.
81
+ */
82
+ declare function DialogFrame({ title, description, headingAs: Heading, actions, closeButton, closeLabel, bodyClassName, className, children, "aria-describedby": describedBy, ...modal }: DialogFrameProps): react.JSX.Element;
83
+
84
+ export { DEFAULT_DIALOG_FRAME_LABELS, DialogFrame, type DialogFrameLabels, type DialogFrameProps };
@@ -0,0 +1,86 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useContext, useId } from "react";
4
+ import { X } from "lucide-react";
5
+ import { cn } from "../lib/cn.js";
6
+ import { Modal, ModalCloseContext } from "./modal.js";
7
+ import { useKitLabels } from "../i18n/kit-labels.js";
8
+ const DEFAULT_DIALOG_FRAME_LABELS = { close: "Close" };
9
+ function DialogFrame({
10
+ title,
11
+ description,
12
+ headingAs: Heading = "h2",
13
+ actions,
14
+ closeButton = false,
15
+ closeLabel,
16
+ bodyClassName,
17
+ className,
18
+ children,
19
+ "aria-describedby": describedBy,
20
+ ...modal
21
+ }) {
22
+ const titleId = useId();
23
+ const descriptionId = useId();
24
+ const hasDescription = description !== void 0 && description !== null;
25
+ return /* @__PURE__ */ jsxs(
26
+ Modal,
27
+ {
28
+ ...modal,
29
+ labelledBy: titleId,
30
+ "aria-describedby": [hasDescription ? descriptionId : void 0, describedBy].filter(Boolean).join(" ") || void 0,
31
+ className: cn("flex flex-col overflow-hidden p-0", className),
32
+ children: [
33
+ /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-start justify-between gap-2 px-4 pt-4 pb-3", children: [
34
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
35
+ /* @__PURE__ */ jsx(Heading, { id: titleId, className: "text-lg font-semibold leading-snug text-[var(--text-primary)]", children: title }),
36
+ hasDescription && /* @__PURE__ */ jsx("p", { id: descriptionId, className: "mt-0.5 text-sm text-[var(--text-muted)]", children: description })
37
+ ] }),
38
+ closeButton && /* @__PURE__ */ jsx(FrameClose, { label: closeLabel, onClose: modal.onClose })
39
+ ] }),
40
+ /* @__PURE__ */ jsx(
41
+ "div",
42
+ {
43
+ className: cn(
44
+ // `min-h-0` is what lets a flex child shrink below its content and scroll;
45
+ // `last:pb-4` closes a frame that has no actions row under it.
46
+ "min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-4 pb-3 last:pb-4",
47
+ bodyClassName
48
+ ),
49
+ children
50
+ }
51
+ ),
52
+ actions !== void 0 && actions !== null && /* @__PURE__ */ jsx(FrameActions, { actions, onClose: modal.onClose })
53
+ ]
54
+ }
55
+ );
56
+ }
57
+ function FrameClose({ label, onClose }) {
58
+ const close = useContext(ModalCloseContext) ?? onClose;
59
+ const labels = useKitLabels("dialogFrame", DEFAULT_DIALOG_FRAME_LABELS, label === void 0 ? void 0 : { close: label });
60
+ return /* @__PURE__ */ jsx(
61
+ "button",
62
+ {
63
+ type: "button",
64
+ onClick: close,
65
+ "aria-label": labels.close,
66
+ className: "-me-1.5 -mt-1 shrink-0 rounded p-1.5 text-[var(--text-muted)] outline-none hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)] focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
67
+ children: /* @__PURE__ */ jsx(X, { "aria-hidden": true, className: "size-5" })
68
+ }
69
+ );
70
+ }
71
+ function FrameActions({
72
+ actions,
73
+ onClose
74
+ }) {
75
+ const close = useContext(ModalCloseContext) ?? onClose;
76
+ return (
77
+ // The border marks where the scrolling stops; `flex-wrap` keeps three long
78
+ // translated labels on a phone from pushing the row wider than the sheet.
79
+ /* @__PURE__ */ jsx("div", { className: "flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-[var(--border)] px-4 py-3", children: typeof actions === "function" ? actions(close) : actions })
80
+ );
81
+ }
82
+ export {
83
+ DEFAULT_DIALOG_FRAME_LABELS,
84
+ DialogFrame
85
+ };
86
+ //# sourceMappingURL=dialog-frame.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/dialog-frame.tsx"],"sourcesContent":["import { useContext, useId } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { Modal, ModalCloseContext } from \"./modal\";\nimport type { ModalProps } from \"./modal\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\n\nexport interface DialogFrameLabels {\n /** Accessible name of the header's X, when {@link DialogFrameProps.closeButton} shows it. */\n close: string;\n}\n\nexport const DEFAULT_DIALOG_FRAME_LABELS: DialogFrameLabels = { close: \"Close\" };\n\n/**\n * `ModalProps` minus the three this component owns: the NAME (`labelledBy` — the\n * heading's id is generated inside and never reaches the caller), the content\n * (`children` is the body here, not the whole panel) and `title`, which is the dialog's\n * HEADING and a ReactNode rather than the browser's tooltip string — the collision\n * `PickerSheet` met first. Everything else, `size`, `draggable`, `fullBleed`,\n * `onKeyDown`, a `data-tour` anchor, still reaches the `Modal`.\n */\nexport interface DialogFrameProps extends Omit<ModalProps, \"labelledBy\" | \"children\" | \"title\"> {\n /** The heading, and therefore the dialog's accessible name (`aria-labelledby`). */\n title: ReactNode;\n /** The smaller line under the heading; wired to `aria-describedby`. */\n description?: ReactNode;\n /**\n * The heading's level. `h2` by default, which is what every dialog in both apps\n * writes; a prop for a page that nests its demos under a real heading.\n */\n headingAs?: \"h1\" | \"h2\" | \"h3\" | \"h4\";\n /**\n * The row under the body — buttons, in the caller's own order and variants (the two\n * apps disagree about the cancel button's variant, so the frame has no opinion on\n * it). Stays put while the body scrolls.\n *\n * A FUNCTION receives the panel's animated close: `(close) => <Button\n * onClick={close}>Cancel</Button>` lowers the panel the way Escape does, where\n * calling `onClose` directly unmounts it at once.\n */\n actions?: ReactNode | ((close: () => void) => ReactNode);\n /**\n * Show an X in the header. Off by default: a centred dialog has a backdrop and\n * Escape, and a form dialog has a Cancel. On for a dialog that commits as it goes\n * and has no actions row, where the X is the only visible way out.\n */\n closeButton?: boolean;\n /** Default: `dialogFrame.close` from the {@link UiKitProvider}, else \"Close\". */\n closeLabel?: string;\n /** Extra classes for the scrolling body (default spacing `space-y-3`). */\n bodyClassName?: string;\n /** The body. */\n children: ReactNode;\n}\n\n/**\n * A {@link Modal} with the frame every caller was writing by hand: a heading, an\n * optional description, an optional X, a body that scrolls, and an actions row that\n * does not.\n *\n * 34 dialogs across the two apps framed themselves — a heading with an id invented per\n * file (and spelt three ways), four visible type sizes for one thing, ten spellings of\n * one right-aligned button row — and the package's own feedback dialog shipped with no\n * accessible name at all. This makes the name unforgettable: the heading's id comes\n * from `useId()` and goes straight to `Modal`'s `labelledBy`, so a framed dialog cannot\n * announce as just \"dialog\", and two open instances cannot share an id.\n *\n * ## It wraps, it does not change `Modal`\n *\n * `Modal` keeps `labelledBy` and every existing caller compiles untouched. What the\n * frame changes is inside the panel: the panel becomes a flex column with no padding\n * of its own, and only the BODY scrolls. The panel's `max-h-full` is still the outer\n * bound, so a tall form keeps its heading and its Save button on screen instead of\n * scrolling them away with the fields. A caller's own `className` still wins (it is\n * tailwind-merged last), which is how a full-screen phone sheet is spelt:\n * `fullBleed className=\"h-[100dvh] max-w-full rounded-none md:h-auto md:rounded-lg\"`.\n *\n * ## What it is not\n *\n * Not `FullBleedDialog`: that is the phone's full-screen editor with its own `open`,\n * Back handling and a required X, and it already draws a frame of its own. And not a\n * form: submit handling, a pending label and close-on-success stay the caller's.\n * Focus lands on the panel, as `Modal` decides — not on the first field, so opening\n * does not pop a phone's keyboard; an `autoFocus` in the body overrides that from the\n * caller's side, and should be a decision rather than a habit.\n */\nexport function DialogFrame({\n title,\n description,\n headingAs: Heading = \"h2\",\n actions,\n closeButton = false,\n closeLabel,\n bodyClassName,\n className,\n children,\n \"aria-describedby\": describedBy,\n ...modal\n}: DialogFrameProps) {\n const titleId = useId();\n const descriptionId = useId();\n const hasDescription = description !== undefined && description !== null;\n\n return (\n <Modal\n {...modal}\n labelledBy={titleId}\n // The caller's own description (a warning inside the body, say) is ADDED to the\n // frame's, not traded for it: both are the dialog's.\n aria-describedby={[hasDescription ? descriptionId : undefined, describedBy].filter(Boolean).join(\" \") || undefined}\n // `overflow-hidden` replaces the panel's own `overflow-y-auto` (tailwind-merge\n // treats them as one group), `p-0` its `p-4`: the scroller and the padding move\n // to the body, which is the only part that should move.\n className={cn(\"flex flex-col overflow-hidden p-0\", className)}\n >\n <div className=\"flex shrink-0 items-start justify-between gap-2 px-4 pt-4 pb-3\">\n <div className=\"min-w-0\">\n <Heading id={titleId} className=\"text-lg font-semibold leading-snug text-[var(--text-primary)]\">\n {title}\n </Heading>\n {hasDescription && (\n <p id={descriptionId} className=\"mt-0.5 text-sm text-[var(--text-muted)]\">\n {description}\n </p>\n )}\n </div>\n {closeButton && <FrameClose label={closeLabel} onClose={modal.onClose} />}\n </div>\n <div\n className={cn(\n // `min-h-0` is what lets a flex child shrink below its content and scroll;\n // `last:pb-4` closes a frame that has no actions row under it.\n \"min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-4 pb-3 last:pb-4\",\n bodyClassName,\n )}\n >\n {children}\n </div>\n {actions !== undefined && actions !== null && <FrameActions actions={actions} onClose={modal.onClose} />}\n </Modal>\n );\n}\n\n/** The X. A component of its own so it can read the panel's animated close, which\n * only exists INSIDE the `Modal` (the frame's own body runs outside it). */\nfunction FrameClose({ label, onClose }: { label?: string; onClose: () => void }) {\n const close = useContext(ModalCloseContext) ?? onClose;\n const labels = useKitLabels(\"dialogFrame\", DEFAULT_DIALOG_FRAME_LABELS, label === undefined ? undefined : { close: label });\n return (\n <button\n type=\"button\"\n onClick={close}\n aria-label={labels.close}\n className=\"-me-1.5 -mt-1 shrink-0 rounded p-1.5 text-[var(--text-muted)] outline-none hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)] focus-visible:ring-2 focus-visible:ring-[var(--brand)]\"\n >\n <X aria-hidden className=\"size-5\" />\n </button>\n );\n}\n\nfunction FrameActions({\n actions,\n onClose,\n}: {\n actions: NonNullable<DialogFrameProps[\"actions\"]>;\n onClose: () => void;\n}) {\n const close = useContext(ModalCloseContext) ?? onClose;\n return (\n // The border marks where the scrolling stops; `flex-wrap` keeps three long\n // translated labels on a phone from pushing the row wider than the sheet.\n <div className=\"flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-[var(--border)] px-4 py-3\">\n {typeof actions === \"function\" ? actions(close) : actions}\n </div>\n );\n}\n"],"mappings":";AAuHQ,SACE,KADF;AAvHR,SAAS,YAAY,aAAa;AAElC,SAAS,SAAS;AAElB,SAAS,UAAU;AACnB,SAAS,OAAO,yBAAyB;AAEzC,SAAS,oBAAoB;AAOtB,MAAM,8BAAiD,EAAE,OAAO,QAAQ;AA2ExE,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA,WAAW,UAAU;AAAA,EACrB;AAAA,EACA,cAAc;AAAA,EACd;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,oBAAoB;AAAA,EACpB,GAAG;AACL,GAAqB;AACnB,QAAM,UAAU,MAAM;AACtB,QAAM,gBAAgB,MAAM;AAC5B,QAAM,iBAAiB,gBAAgB,UAAa,gBAAgB;AAEpE,SACE;AAAA,IAAC;AAAA;AAAA,MACE,GAAG;AAAA,MACJ,YAAY;AAAA,MAGZ,oBAAkB,CAAC,iBAAiB,gBAAgB,QAAW,WAAW,EAAE,OAAO,OAAO,EAAE,KAAK,GAAG,KAAK;AAAA,MAIzG,WAAW,GAAG,qCAAqC,SAAS;AAAA,MAE5D;AAAA,6BAAC,SAAI,WAAU,kEACb;AAAA,+BAAC,SAAI,WAAU,WACb;AAAA,gCAAC,WAAQ,IAAI,SAAS,WAAU,iEAC7B,iBACH;AAAA,YACC,kBACC,oBAAC,OAAE,IAAI,eAAe,WAAU,2CAC7B,uBACH;AAAA,aAEJ;AAAA,UACC,eAAe,oBAAC,cAAW,OAAO,YAAY,SAAS,MAAM,SAAS;AAAA,WACzE;AAAA,QACA;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA;AAAA;AAAA,cAGT;AAAA,cACA;AAAA,YACF;AAAA,YAEC;AAAA;AAAA,QACH;AAAA,QACC,YAAY,UAAa,YAAY,QAAQ,oBAAC,gBAAa,SAAkB,SAAS,MAAM,SAAS;AAAA;AAAA;AAAA,EACxG;AAEJ;AAIA,SAAS,WAAW,EAAE,OAAO,QAAQ,GAA4C;AAC/E,QAAM,QAAQ,WAAW,iBAAiB,KAAK;AAC/C,QAAM,SAAS,aAAa,eAAe,6BAA6B,UAAU,SAAY,SAAY,EAAE,OAAO,MAAM,CAAC;AAC1H,SACE;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,SAAS;AAAA,MACT,cAAY,OAAO;AAAA,MACnB,WAAU;AAAA,MAEV,8BAAC,KAAE,eAAW,MAAC,WAAU,UAAS;AAAA;AAAA,EACpC;AAEJ;AAEA,SAAS,aAAa;AAAA,EACpB;AAAA,EACA;AACF,GAGG;AACD,QAAM,QAAQ,WAAW,iBAAiB,KAAK;AAC/C;AAAA;AAAA;AAAA,IAGE,oBAAC,SAAI,WAAU,oGACZ,iBAAO,YAAY,aAAa,QAAQ,KAAK,IAAI,SACpD;AAAA;AAEJ;","names":[]}
@@ -0,0 +1,108 @@
1
+ import * as react from 'react';
2
+ import { ComponentPropsWithoutRef, ReactNode } from 'react';
3
+
4
+ /**
5
+ * `extends` the div's props so an `id` (what a trigger's `aria-controls` points at), a
6
+ * `data-tour` anchor or a test id reaches the element that folds.
7
+ */
8
+ interface CollapseProps extends ComponentPropsWithoutRef<"div"> {
9
+ open: boolean;
10
+ /**
11
+ * Keep the children mounted while shut (hidden and `inert`) instead of unmounting
12
+ * them once the fold has finished closing.
13
+ *
14
+ * Off by default, and that default is a behaviour contract rather than a
15
+ * performance note: Lenkbank's disclosure bodies FETCH, and a body that only exists
16
+ * while open is what stops a closed card asking — without every caller carrying its
17
+ * own `enabled: open`. On for a body whose state must outlive a close (a half-typed
18
+ * form) or that must be found by the browser's find-in-page.
19
+ */
20
+ keepMounted?: boolean;
21
+ children: ReactNode;
22
+ }
23
+ /**
24
+ * The fold on its own: content that opens and shuts in place, animated to a height
25
+ * nobody measured.
26
+ *
27
+ * THE TECHNIQUE is `AppShell`'s sidebar group's, lifted out so there is one copy of it.
28
+ * A `grid-template-rows` transition from `0fr` to `1fr` is the one way to animate to a
29
+ * content-sized height without measuring it — so a chart that resizes inside it, or a
30
+ * translated line that wraps, is still exactly as tall as it needs. `visibility` rides
31
+ * the same transition, so a shut body leaves the accessibility tree only once it has
32
+ * finished closing; `inert` takes it out of the tab order immediately.
33
+ *
34
+ * THE CHILDREN are unmounted once closed (see {@link CollapseProps.keepMounted}), but
35
+ * only once the track has finished closing: the track is what animates, so the
36
+ * children have to survive the movement that hides them. Under reduced motion they
37
+ * go at once — there is no movement to wait for — and the opening half is silenced by
38
+ * `motion-reduce:transition-none`, since tokens.css only silences the overlay
39
+ * animations by name.
40
+ *
41
+ * Padding and margins belong on a child: on this element, or the clipping one inside
42
+ * it, they would hold the row open by that much.
43
+ */
44
+ declare function Collapse({ open, keepMounted, children, className, style, ...rest }: CollapseProps): react.JSX.Element;
45
+ /**
46
+ * `title` is omitted from the div's own props because this component already owns the
47
+ * name: here it is the header's content (and a ReactNode), not the browser's tooltip.
48
+ * Everything else reaches the outer element.
49
+ */
50
+ interface DisclosureProps extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
51
+ /** What the header says. A node, so a status chip or a count can sit in it. */
52
+ title: ReactNode;
53
+ /** The smaller line under the title — what is inside, for a reader deciding whether
54
+ * to open it. */
55
+ hint?: ReactNode;
56
+ /**
57
+ * Who decides whether this one is open. Left out, the disclosure decides for itself,
58
+ * which is what one standing on its own wants. Passed, the caller does — a set of
59
+ * which one is open at a time, or an open state kept in the URL so the view can be
60
+ * linked to. There is no accordion component: "one open at a time" is a few lines in
61
+ * the caller and it usually owns a URL parameter, which is app state.
62
+ */
63
+ open?: boolean;
64
+ /** Whether an UNCONTROLLED disclosure starts open. Ignored when `open` is passed. */
65
+ defaultOpen?: boolean;
66
+ /** Called with the next state on every toggle, controlled or not. */
67
+ onOpenChange?: (open: boolean) => void;
68
+ /**
69
+ * `card` (default) draws the kit's `Card` surface round header and body, with the
70
+ * chevron trailing the header and turning over — the "section that opens" of a
71
+ * settings or analysis page. `bare` draws nothing: a leading chevron that turns
72
+ * down, for an inline "Show 3 hidden accounts" inside something that already has
73
+ * its own surface.
74
+ */
75
+ variant?: "card" | "bare";
76
+ /**
77
+ * Wrap the header button in a heading of this level. The WAI-ARIA disclosure pattern
78
+ * puts the button INSIDE the heading when the disclosure titles a section, so the
79
+ * page's heading outline still lists it. Off by default: an inline "show more" is
80
+ * not a section.
81
+ */
82
+ headingAs?: "h2" | "h3" | "h4" | "h5" | "h6";
83
+ /** See {@link CollapseProps.keepMounted}. */
84
+ keepMounted?: boolean;
85
+ disabled?: boolean;
86
+ /** Extra classes for the header button. */
87
+ headerClassName?: string;
88
+ /** Extra classes for the body's wrapper — where its padding and spacing live. */
89
+ bodyClassName?: string;
90
+ children: ReactNode;
91
+ }
92
+ /**
93
+ * A section that opens in place: a header button with `aria-expanded` and
94
+ * `aria-controls`, and a {@link Collapse} under it.
95
+ *
96
+ * Both apps had written this by hand — Lenkbank as a shared `CollapsibleCard` with ten
97
+ * importers, Keksdose three separate times (accounts, twice; the support panel) — and
98
+ * the copies had drifted: none of Keksdose's animated, and all three swapped a
99
+ * `ChevronRight` for a `ChevronDown` rather than turning one. Here the chevron is one
100
+ * icon that rotates, so the change of state is one element moving rather than two
101
+ * trading places.
102
+ *
103
+ * The body is unmounted while shut; see {@link CollapseProps.keepMounted} for why that
104
+ * is the default and when to opt out.
105
+ */
106
+ declare function Disclosure({ title, hint, open: controlled, defaultOpen, onOpenChange, variant, headingAs: Heading, keepMounted, disabled, headerClassName, bodyClassName, className, children, ...rest }: DisclosureProps): react.JSX.Element;
107
+
108
+ export { Collapse, type CollapseProps, Disclosure, type DisclosureProps };
@@ -0,0 +1,127 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useId, useState } from "react";
4
+ import { ChevronDown } from "lucide-react";
5
+ import { cn } from "../lib/cn.js";
6
+ const COLLAPSE_MS = 200;
7
+ function prefersReducedMotion() {
8
+ return typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches;
9
+ }
10
+ function Collapse({ open, keepMounted = false, children, className, style, ...rest }) {
11
+ const [lingering, setLingering] = useState(false);
12
+ const [prevOpen, setPrevOpen] = useState(open);
13
+ if (open !== prevOpen) {
14
+ setPrevOpen(open);
15
+ setLingering(!open && !prefersReducedMotion());
16
+ }
17
+ useEffect(() => {
18
+ if (!lingering) return;
19
+ const timer = setTimeout(() => setLingering(false), COLLAPSE_MS);
20
+ return () => clearTimeout(timer);
21
+ }, [lingering]);
22
+ const mounted = open || lingering || keepMounted;
23
+ return /* @__PURE__ */ jsx(
24
+ "div",
25
+ {
26
+ ...rest,
27
+ inert: !open,
28
+ className: cn(
29
+ "grid transition-[grid-template-rows,visibility] duration-200 ease-out motion-reduce:transition-none",
30
+ className
31
+ ),
32
+ style: { ...style, gridTemplateRows: open ? "1fr" : "0fr", visibility: open ? "visible" : "hidden" },
33
+ children: /* @__PURE__ */ jsx(
34
+ "div",
35
+ {
36
+ className: cn(
37
+ "min-h-0 overflow-hidden transition-opacity duration-200 ease-out motion-reduce:transition-none",
38
+ open ? "opacity-100" : "opacity-0"
39
+ ),
40
+ children: mounted && children
41
+ }
42
+ )
43
+ }
44
+ );
45
+ }
46
+ function Disclosure({
47
+ title,
48
+ hint,
49
+ open: controlled,
50
+ defaultOpen = false,
51
+ onOpenChange,
52
+ variant = "card",
53
+ headingAs: Heading,
54
+ keepMounted,
55
+ disabled,
56
+ headerClassName,
57
+ bodyClassName,
58
+ className,
59
+ children,
60
+ ...rest
61
+ }) {
62
+ const [own, setOwn] = useState(defaultOpen);
63
+ const open = controlled ?? own;
64
+ const bodyId = useId();
65
+ const card = variant === "card";
66
+ const toggle = () => {
67
+ const next = !open;
68
+ if (controlled === void 0) setOwn(next);
69
+ onOpenChange?.(next);
70
+ };
71
+ const button = /* @__PURE__ */ jsxs(
72
+ "button",
73
+ {
74
+ type: "button",
75
+ "aria-expanded": open,
76
+ "aria-controls": bodyId,
77
+ disabled,
78
+ onClick: toggle,
79
+ className: cn(
80
+ "flex w-full gap-2 text-start outline-none disabled:cursor-not-allowed disabled:opacity-50",
81
+ "focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
82
+ card ? cn(
83
+ "items-center justify-between rounded-lg p-4 hover:bg-[var(--bg-hover)] focus-visible:ring-inset",
84
+ open && "rounded-b-none"
85
+ ) : "items-center rounded-sm text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]",
86
+ headerClassName
87
+ ),
88
+ children: [
89
+ !card && /* @__PURE__ */ jsx(Chevron, { open, leading: true }),
90
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0", children: [
91
+ /* @__PURE__ */ jsx("span", { className: cn("block", card && "text-sm font-semibold text-[var(--text-primary)]"), children: title }),
92
+ hint !== void 0 && /* @__PURE__ */ jsx("span", { className: "mt-0.5 block text-xs font-normal text-[var(--text-muted)]", children: hint })
93
+ ] }),
94
+ card && /* @__PURE__ */ jsx(Chevron, { open })
95
+ ]
96
+ }
97
+ );
98
+ return /* @__PURE__ */ jsxs(
99
+ "div",
100
+ {
101
+ ...rest,
102
+ className: cn(card && "rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] shadow-sm", className),
103
+ children: [
104
+ Heading ? /* @__PURE__ */ jsx(Heading, { children: button }) : button,
105
+ /* @__PURE__ */ jsx(Collapse, { id: bodyId, open, keepMounted, children: /* @__PURE__ */ jsx("div", { className: cn(card ? "space-y-3 px-4 pb-4" : "space-y-2 pt-2", bodyClassName), children }) })
106
+ ]
107
+ }
108
+ );
109
+ }
110
+ function Chevron({ open, leading = false }) {
111
+ return /* @__PURE__ */ jsx(
112
+ ChevronDown,
113
+ {
114
+ "aria-hidden": true,
115
+ className: cn(
116
+ "shrink-0 text-[var(--text-muted)] transition-transform duration-200 ease-out motion-reduce:transition-none",
117
+ leading ? "size-3.5" : "size-4",
118
+ leading ? !open && "-rotate-90 rtl:rotate-90" : open && "rotate-180"
119
+ )
120
+ }
121
+ );
122
+ }
123
+ export {
124
+ Collapse,
125
+ Disclosure
126
+ };
127
+ //# sourceMappingURL=disclosure.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/disclosure.tsx"],"sourcesContent":["import { useEffect, useId, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { ChevronDown } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\n\n/** How long the fold takes, in ms. The same number as the `duration-200` below and as\n * the unmount timer, because they are the same movement. */\nconst COLLAPSE_MS = 200;\n\n/** Read at the moment of closing, like `useCloseTransition` does: the setting can\n * change under a long-lived page, and jsdom/SSR have no `matchMedia` — where \"no\n * animation\" is also the only correct answer, since nothing is painting. */\nfunction prefersReducedMotion(): boolean {\n return (\n typeof window === \"undefined\" ||\n typeof window.matchMedia !== \"function\" ||\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n );\n}\n\n/**\n * `extends` the div's props so an `id` (what a trigger's `aria-controls` points at), a\n * `data-tour` anchor or a test id reaches the element that folds.\n */\nexport interface CollapseProps extends ComponentPropsWithoutRef<\"div\"> {\n open: boolean;\n /**\n * Keep the children mounted while shut (hidden and `inert`) instead of unmounting\n * them once the fold has finished closing.\n *\n * Off by default, and that default is a behaviour contract rather than a\n * performance note: Lenkbank's disclosure bodies FETCH, and a body that only exists\n * while open is what stops a closed card asking — without every caller carrying its\n * own `enabled: open`. On for a body whose state must outlive a close (a half-typed\n * form) or that must be found by the browser's find-in-page.\n */\n keepMounted?: boolean;\n children: ReactNode;\n}\n\n/**\n * The fold on its own: content that opens and shuts in place, animated to a height\n * nobody measured.\n *\n * THE TECHNIQUE is `AppShell`'s sidebar group's, lifted out so there is one copy of it.\n * A `grid-template-rows` transition from `0fr` to `1fr` is the one way to animate to a\n * content-sized height without measuring it — so a chart that resizes inside it, or a\n * translated line that wraps, is still exactly as tall as it needs. `visibility` rides\n * the same transition, so a shut body leaves the accessibility tree only once it has\n * finished closing; `inert` takes it out of the tab order immediately.\n *\n * THE CHILDREN are unmounted once closed (see {@link CollapseProps.keepMounted}), but\n * only once the track has finished closing: the track is what animates, so the\n * children have to survive the movement that hides them. Under reduced motion they\n * go at once — there is no movement to wait for — and the opening half is silenced by\n * `motion-reduce:transition-none`, since tokens.css only silences the overlay\n * animations by name.\n *\n * Padding and margins belong on a child: on this element, or the clipping one inside\n * it, they would hold the row open by that much.\n */\nexport function Collapse({ open, keepMounted = false, children, className, style, ...rest }: CollapseProps) {\n // Whether the children are still on their way out. Adjusted DURING render when\n // `open` flips (React's \"storing information from previous renders\"), so a close\n // under reduced motion unmounts in the same render that shut it — a test, and a\n // screen reader, see the body go on the click — and a re-open mid-fold simply\n // cancels the linger.\n const [lingering, setLingering] = useState(false);\n const [prevOpen, setPrevOpen] = useState(open);\n if (open !== prevOpen) {\n setPrevOpen(open);\n setLingering(!open && !prefersReducedMotion());\n }\n useEffect(() => {\n if (!lingering) return;\n const timer = setTimeout(() => setLingering(false), COLLAPSE_MS);\n return () => clearTimeout(timer);\n }, [lingering]);\n\n const mounted = open || lingering || keepMounted;\n\n return (\n <div\n {...rest}\n inert={!open}\n className={cn(\n \"grid transition-[grid-template-rows,visibility] duration-200 ease-out motion-reduce:transition-none\",\n className,\n )}\n // Inline rather than `grid-rows-[…]` classes: a caller's `className` must not be\n // able to pin the track open, and the two values ARE the state.\n style={{ ...style, gridTemplateRows: open ? \"1fr\" : \"0fr\", visibility: open ? \"visible\" : \"hidden\" }}\n >\n <div\n className={cn(\n \"min-h-0 overflow-hidden transition-opacity duration-200 ease-out motion-reduce:transition-none\",\n open ? \"opacity-100\" : \"opacity-0\",\n )}\n >\n {mounted && children}\n </div>\n </div>\n );\n}\n\n/**\n * `title` is omitted from the div's own props because this component already owns the\n * name: here it is the header's content (and a ReactNode), not the browser's tooltip.\n * Everything else reaches the outer element.\n */\nexport interface DisclosureProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"title\"> {\n /** What the header says. A node, so a status chip or a count can sit in it. */\n title: ReactNode;\n /** The smaller line under the title — what is inside, for a reader deciding whether\n * to open it. */\n hint?: ReactNode;\n /**\n * Who decides whether this one is open. Left out, the disclosure decides for itself,\n * which is what one standing on its own wants. Passed, the caller does — a set of\n * which one is open at a time, or an open state kept in the URL so the view can be\n * linked to. There is no accordion component: \"one open at a time\" is a few lines in\n * the caller and it usually owns a URL parameter, which is app state.\n */\n open?: boolean;\n /** Whether an UNCONTROLLED disclosure starts open. Ignored when `open` is passed. */\n defaultOpen?: boolean;\n /** Called with the next state on every toggle, controlled or not. */\n onOpenChange?: (open: boolean) => void;\n /**\n * `card` (default) draws the kit's `Card` surface round header and body, with the\n * chevron trailing the header and turning over — the \"section that opens\" of a\n * settings or analysis page. `bare` draws nothing: a leading chevron that turns\n * down, for an inline \"Show 3 hidden accounts\" inside something that already has\n * its own surface.\n */\n variant?: \"card\" | \"bare\";\n /**\n * Wrap the header button in a heading of this level. The WAI-ARIA disclosure pattern\n * puts the button INSIDE the heading when the disclosure titles a section, so the\n * page's heading outline still lists it. Off by default: an inline \"show more\" is\n * not a section.\n */\n headingAs?: \"h2\" | \"h3\" | \"h4\" | \"h5\" | \"h6\";\n /** See {@link CollapseProps.keepMounted}. */\n keepMounted?: boolean;\n disabled?: boolean;\n /** Extra classes for the header button. */\n headerClassName?: string;\n /** Extra classes for the body's wrapper — where its padding and spacing live. */\n bodyClassName?: string;\n children: ReactNode;\n}\n\n/**\n * A section that opens in place: a header button with `aria-expanded` and\n * `aria-controls`, and a {@link Collapse} under it.\n *\n * Both apps had written this by hand — Lenkbank as a shared `CollapsibleCard` with ten\n * importers, Keksdose three separate times (accounts, twice; the support panel) — and\n * the copies had drifted: none of Keksdose's animated, and all three swapped a\n * `ChevronRight` for a `ChevronDown` rather than turning one. Here the chevron is one\n * icon that rotates, so the change of state is one element moving rather than two\n * trading places.\n *\n * The body is unmounted while shut; see {@link CollapseProps.keepMounted} for why that\n * is the default and when to opt out.\n */\nexport function Disclosure({\n title,\n hint,\n open: controlled,\n defaultOpen = false,\n onOpenChange,\n variant = \"card\",\n headingAs: Heading,\n keepMounted,\n disabled,\n headerClassName,\n bodyClassName,\n className,\n children,\n ...rest\n}: DisclosureProps) {\n const [own, setOwn] = useState(defaultOpen);\n const open = controlled ?? own;\n const bodyId = useId();\n const card = variant === \"card\";\n\n const toggle = () => {\n const next = !open;\n if (controlled === undefined) setOwn(next);\n onOpenChange?.(next);\n };\n\n const button = (\n <button\n type=\"button\"\n aria-expanded={open}\n aria-controls={bodyId}\n disabled={disabled}\n onClick={toggle}\n className={cn(\n \"flex w-full gap-2 text-start outline-none disabled:cursor-not-allowed disabled:opacity-50\",\n \"focus-visible:ring-2 focus-visible:ring-[var(--brand)]\",\n card\n ? cn(\n \"items-center justify-between rounded-lg p-4 hover:bg-[var(--bg-hover)] focus-visible:ring-inset\",\n open && \"rounded-b-none\",\n )\n : \"items-center rounded-sm text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]\",\n headerClassName,\n )}\n >\n {!card && <Chevron open={open} leading />}\n <span className=\"min-w-0\">\n <span className={cn(\"block\", card && \"text-sm font-semibold text-[var(--text-primary)]\")}>{title}</span>\n {hint !== undefined && (\n <span className=\"mt-0.5 block text-xs font-normal text-[var(--text-muted)]\">{hint}</span>\n )}\n </span>\n {card && <Chevron open={open} />}\n </button>\n );\n\n return (\n <div\n {...rest}\n className={cn(card && \"rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] shadow-sm\", className)}\n >\n {/* Preflight already makes h1–h6 inherit size and weight, so the heading adds\n structure and nothing visible. */}\n {Heading ? <Heading>{button}</Heading> : button}\n <Collapse id={bodyId} open={open} keepMounted={keepMounted}>\n <div className={cn(card ? \"space-y-3 px-4 pb-4\" : \"space-y-2 pt-2\", bodyClassName)}>{children}</div>\n </Collapse>\n </div>\n );\n}\n\n/**\n * One icon for both variants, turned rather than swapped. `leading` (bare) points it\n * along the reading direction while shut — right in LTR, left in RTL — and down when\n * open; trailing (card) points down while shut and up when open, which is the card\n * header's convention. Rotation, not a mirrored glyph, so RTL needs one opposite angle\n * and no `scale` composing with the turn.\n */\nfunction Chevron({ open, leading = false }: { open: boolean; leading?: boolean }) {\n return (\n <ChevronDown\n aria-hidden\n className={cn(\n \"shrink-0 text-[var(--text-muted)] transition-transform duration-200 ease-out motion-reduce:transition-none\",\n leading ? \"size-3.5\" : \"size-4\",\n leading ? !open && \"-rotate-90 rtl:rotate-90\" : open && \"rotate-180\",\n )}\n />\n );\n}\n"],"mappings":"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","names":[]}
@@ -14,8 +14,9 @@ interface EntityComboboxProps<V extends string | number> extends Omit<ComponentP
14
14
  /** Already-loaded options (client-side filtered). Also used to resolve the
15
15
  * trigger label for the current `value`. */
16
16
  options?: ComboOption<V>[];
17
- /** Async option source, debounced and called on open + as the query changes.
18
- * Stale responses are ignored. When set, `options` is used only for label
17
+ /** Async option source, debounced and called on open + as the query changes
18
+ * (at `minChars` and up). Stale responses are ignored; a rejection empties the
19
+ * list and says `loadErrorLabel` instead of leaving the last query's rows up. When set, `options` is used only for label
19
20
  * resolution, not as the result list. */
20
21
  loadOptions?: (query: string) => Promise<ComboOption<V>[]>;
21
22
  /** External loading flag (OR-ed with the internal async state). */
@@ -37,6 +38,19 @@ interface EntityComboboxProps<V extends string | number> extends Omit<ComponentP
37
38
  createLabel?: (query: string) => string;
38
39
  /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */
39
40
  invalid?: boolean;
41
+ /** What is wrong with the value, as {@link Input}'s `error`: rendered under the
42
+ * field, on the trigger's `aria-describedby`, and implies `invalid`. */
43
+ error?: ReactNode;
44
+ /** Narrow `options` client-side by the query. Default `true`; `false` shows them
45
+ * as given (a server-ranked list). */
46
+ filter?: boolean;
47
+ /** Offer nothing, and call no `loadOptions`, below this many characters. Default
48
+ * `0`, i.e. the list loads as the panel opens. */
49
+ minChars?: number;
50
+ /** `loadOptions` debounce. Default 150 ms. */
51
+ debounceMs?: number;
52
+ /** Shown when `loadOptions` rejects. Default: `combobox.loadError`. */
53
+ loadErrorLabel?: string;
40
54
  }
41
55
  /**
42
56
  * An id-keyed, searchable entity picker: a field-styled trigger showing the
@@ -45,6 +59,6 @@ interface EntityComboboxProps<V extends string | number> extends Omit<ComponentP
45
59
  * the shared field/anchor/dismiss/search primitives (no cmdk/Radix). For picking
46
60
  * several entities use {@link MultiEntityCombobox}.
47
61
  */
48
- declare function EntityCombobox<V extends string | number>({ value, onChange, options, loadOptions, loading, label, placeholder, searchPlaceholder, emptyLabel, clearable, clearLabel, closeLabel, disabled, onCreate, createLabel, className, invalid, "aria-label": ariaLabel, ...rest }: EntityComboboxProps<V>): react.JSX.Element;
62
+ declare function EntityCombobox<V extends string | number>({ value, onChange, options, loadOptions, loading, label, placeholder, searchPlaceholder, emptyLabel, clearable, clearLabel, closeLabel, disabled, onCreate, createLabel, className, invalid, error, filter, minChars, debounceMs, loadErrorLabel, "aria-label": ariaLabel, ...rest }: EntityComboboxProps<V>): react.JSX.Element;
49
63
 
50
64
  export { ComboOption, EntityCombobox, type EntityComboboxProps };
@@ -4,7 +4,11 @@ import { useId } from "react";
4
4
  import { X } from "lucide-react";
5
5
  import { cn } from "../lib/cn.js";
6
6
  import { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from "./ui.js";
7
- import { ComboboxPanel, useComboboxCore } from "./combobox-core.js";
7
+ import {
8
+ ComboboxPanel,
9
+ useComboboxCore,
10
+ useComboboxFieldError
11
+ } from "./combobox-core.js";
8
12
  import { DEFAULT_COMBOBOX_LABELS, DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels.js";
9
13
  function EntityCombobox({
10
14
  value,
@@ -24,10 +28,23 @@ function EntityCombobox({
24
28
  createLabel,
25
29
  className,
26
30
  invalid,
31
+ error,
32
+ filter,
33
+ minChars,
34
+ debounceMs,
35
+ loadErrorLabel,
27
36
  "aria-label": ariaLabel,
28
37
  ...rest
29
38
  }) {
30
- const core = useComboboxCore({ options, loadOptions, loading });
39
+ const core = useComboboxCore({
40
+ options,
41
+ loadOptions,
42
+ loading,
43
+ filter,
44
+ minChars,
45
+ debounceMs
46
+ });
47
+ const field = useComboboxFieldError(error, invalid);
31
48
  const labels = useKitLabels("combobox", DEFAULT_COMBOBOX_LABELS, {
32
49
  search: searchPlaceholder,
33
50
  noResults: emptyLabel,
@@ -64,7 +81,8 @@ function EntityCombobox({
64
81
  "aria-haspopup": "listbox",
65
82
  "aria-label": ariaLabel ?? (typeof label === "string" ? common.fieldValue(label, triggerText) : void 0),
66
83
  disabled,
67
- "aria-invalid": invalid || void 0,
84
+ "aria-invalid": field.isInvalid || void 0,
85
+ "aria-describedby": field.describedBy,
68
86
  onClick: () => !disabled && setOpen((o) => !o),
69
87
  onKeyDown: (e) => {
70
88
  if (disabled) return;
@@ -78,7 +96,7 @@ function EntityCombobox({
78
96
  "pr-9",
79
97
  label !== void 0 && FIELD_FLOATING_PAD,
80
98
  disabled && "cursor-not-allowed opacity-50",
81
- invalid && FIELD_INVALID
99
+ field.isInvalid && FIELD_INVALID
82
100
  ),
83
101
  children: [
84
102
  /* @__PURE__ */ jsxs("span", { className: "flex min-w-0 items-center gap-2", children: [
@@ -127,6 +145,7 @@ function EntityCombobox({
127
145
  searchPlaceholder: labels.search,
128
146
  emptyLabel: labels.noResults,
129
147
  closeLabel,
148
+ loadErrorLabel,
130
149
  isSelected: (v) => v === value,
131
150
  onChoose: choose,
132
151
  showCreate,
@@ -136,7 +155,8 @@ function EntityCombobox({
136
155
  },
137
156
  createContent: labels.create(q)
138
157
  }
139
- )
158
+ ),
159
+ field.errorEl
140
160
  ] })
141
161
  );
142
162
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/entity-combobox.tsx"],"sourcesContent":["import { useId } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from \"./ui\";\nimport { ComboboxPanel, useComboboxCore, type ComboOption } from \"./combobox-core\";\nimport { DEFAULT_COMBOBOX_LABELS, DEFAULT_COMMON_LABELS, useKitLabels } from \"../i18n/kit-labels\";\n\nexport type { ComboOption } from \"./combobox-core\";\n\n/** `onChange` is the kit's — \"a selection was made\", carrying values — rather\n * than the div's form event, so the DOM's spelling of it is omitted. */\nexport interface EntityComboboxProps<V extends string | number>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\"> {\n /** Selected id, or null/undefined when nothing is selected. */\n value: V | null | undefined;\n /** Selecting an option emits its value; the clear button emits `null`. */\n onChange: (value: V | null) => void;\n /** Already-loaded options (client-side filtered). Also used to resolve the\n * trigger label for the current `value`. */\n options?: ComboOption<V>[];\n /** Async option source, debounced and called on open + as the query changes.\n * Stale responses are ignored. When set, `options` is used only for label\n * resolution, not as the result list. */\n loadOptions?: (query: string) => Promise<ComboOption<V>[]>;\n /** External loading flag (OR-ed with the internal async state). */\n loading?: boolean;\n label?: ReactNode;\n placeholder?: string;\n searchPlaceholder?: string;\n emptyLabel?: string;\n clearable?: boolean;\n clearLabel?: string;\n /** The phone sheet's close button. Its own prop rather than a reuse of\n * `clearLabel`: \"clear the selection\" and \"close this screen\" are different\n * actions, and on a full-screen sheet the close button is the only way out —\n * so it is the one control here that MUST be in the reader's language. */\n closeLabel?: string;\n disabled?: boolean;\n /** When set, a \"create\" row appears for a non-empty query with no exact match. */\n onCreate?: (query: string) => void;\n createLabel?: (query: string) => string;\n /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */\n invalid?: boolean;\n}\n\n/**\n * An id-keyed, searchable entity picker: a field-styled trigger showing the\n * selected item's label, and a portalled dropdown of `{label, sublabel, icon}`\n * options — loaded up front via `options` or lazily via `loadOptions`. Built on\n * the shared field/anchor/dismiss/search primitives (no cmdk/Radix). For picking\n * several entities use {@link MultiEntityCombobox}.\n */\nexport function EntityCombobox<V extends string | number>({\n value,\n onChange,\n options,\n loadOptions,\n loading,\n label,\n placeholder,\n searchPlaceholder,\n emptyLabel,\n clearable,\n clearLabel,\n closeLabel,\n disabled,\n onCreate,\n createLabel,\n className,\n invalid,\n \"aria-label\": ariaLabel,\n ...rest\n}: EntityComboboxProps<V>) {\n const core = useComboboxCore<V>({ options, loadOptions, loading });\n // The props are the per-instance overrides, the provider the app-wide ones; a\n // prop left `undefined` falls through to the provider rather than masking it.\n const labels = useKitLabels(\"combobox\", DEFAULT_COMBOBOX_LABELS, {\n search: searchPlaceholder,\n noResults: emptyLabel,\n clear: clearLabel,\n create: createLabel,\n });\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const { open, results, resolve, setOpen } = core;\n // One id per instance, generated here rather than in the core: `aria-controls` on\n // the trigger has to name the list while the list is still closed, so the id\n // belongs to whoever renders both ends of it.\n const listboxId = useId();\n\n const selectedOption = value == null ? null : resolve(value);\n const q = core.query.trim();\n const showCreate =\n Boolean(onCreate) && q.length > 0 && !results.some((o) => o.label.toLowerCase() === q.toLowerCase());\n const showClear = Boolean(clearable && value != null && !disabled);\n /** What the closed control is showing — the second half of its accessible name. */\n const triggerText = selectedOption?.label ?? placeholder ?? \"\";\n\n const choose = (o: ComboOption<V>) => {\n core.cacheRef.current.set(o.value, o);\n onChange(o.value);\n // Back to the trigger, not to <body>: the panel that held focus is about to\n // unmount, and a keyboard user who just answered this field should be standing\n // on it, ready to Tab to the next one.\n core.closeToTrigger();\n };\n\n return (\n // `rest` dresses the wrapper, which has no role; the accessible NAME goes on\n // the trigger, which has one. Spread FIRST so the trigger's ARIA and the\n // handlers that open the panel cannot be clobbered from outside.\n <div {...rest} className={cn(\"relative\", className)}>\n {label !== undefined && <FieldLabel>{label}</FieldLabel>}\n <button\n ref={core.triggerRef}\n type=\"button\"\n // A combobox, not a button. The distinction is not pedantry: this control\n // carried `aria-invalid`, which `button` does not support, so a required\n // field left empty painted a rose border and told a reader nothing at all —\n // and ESLint flagged it as exactly that (`role-supports-aria-props`). The\n // fix the audit asked for is the role that describes what this IS: a closed\n // choice that expands into the list named below. `combobox` supports\n // `aria-invalid`, so the border and the announcement finally agree.\n role=\"combobox\"\n aria-expanded={open}\n aria-controls={listboxId}\n aria-haspopup=\"listbox\"\n // The label is a floating <span>, not a <label for>, so without this the\n // trigger's accessible name is whatever value happens to be selected —\n // \"Checking\" with nothing saying it is the account. The label AND the\n // value, because `aria-label` replaces the content rather than adding to\n // it, and a control that announces only its name has lost the answer.\n // A caller's own name wins over the composition: two fields labelled\n // \"Account\" on a transfer form are the from and the to, and only the\n // caller knows which is which.\n aria-label={\n ariaLabel ??\n (typeof label === \"string\" ? common.fieldValue(label, triggerText) : undefined)\n }\n disabled={disabled}\n aria-invalid={invalid || undefined}\n onClick={() => !disabled && setOpen((o) => !o)}\n // Down/Up opens the list from the closed trigger, per the APG. Enter and\n // Space already do it through the button's own click.\n onKeyDown={(e) => {\n if (disabled) return;\n if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n e.preventDefault();\n setOpen(true);\n }\n }}\n className={cn(\n FIELD_TRIGGER,\n \"pr-9\",\n label !== undefined && FIELD_FLOATING_PAD,\n disabled && \"cursor-not-allowed opacity-50\",\n invalid && FIELD_INVALID,\n )}\n >\n <span className=\"flex min-w-0 items-center gap-2\">\n {selectedOption?.icon && <span className=\"shrink-0\">{selectedOption.icon}</span>}\n <span\n className={cn(\n \"truncate\",\n // A chosen value is the field's VALUE, so it is set in the same ink an\n // <input>'s value is — FIELD_BASE's own text colour, inherited rather\n // than restated (Keksdose dev#477). It used to be one notch lighter\n // than the typeahead fields beside it, which is visible when a picker\n // and a text field share a form row. Nothing selected keeps the\n // placeholder tone (`--text-placeholder`), which every field here\n // agrees on.\n !selectedOption && \"text-[var(--text-placeholder)]\",\n )}\n >\n {selectedOption?.label ?? placeholder ?? \"\"}\n </span>\n </span>\n {showClear ? (\n <span\n role=\"button\"\n tabIndex={-1}\n aria-label={labels.clear}\n onClick={(e) => {\n e.stopPropagation();\n onChange(null);\n }}\n className=\"absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]\"\n >\n <X className=\"size-4\" />\n </span>\n ) : (\n <FieldChevron />\n )}\n </button>\n <ComboboxPanel\n core={core}\n listboxId={listboxId}\n // On a phone the panel becomes a full-screen sheet, which needs the field's\n // own label to say what it is asking for (live #200).\n sheetTitle={label ?? placeholder}\n searchPlaceholder={labels.search}\n emptyLabel={labels.noResults}\n closeLabel={closeLabel}\n isSelected={(v) => v === value}\n onChoose={choose}\n showCreate={showCreate}\n onCreate={() => {\n onCreate?.(q);\n core.closeToTrigger();\n }}\n createContent={labels.create(q)}\n />\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/entity-combobox.tsx"],"sourcesContent":["import { useId } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { FieldChevron, FieldLabel, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID } from \"./ui\";\nimport {\n ComboboxPanel,\n useComboboxCore,\n useComboboxFieldError,\n type ComboOption,\n} from \"./combobox-core\";\nimport { DEFAULT_COMBOBOX_LABELS, DEFAULT_COMMON_LABELS, useKitLabels } from \"../i18n/kit-labels\";\n\nexport type { ComboOption } from \"./combobox-core\";\n\n/** `onChange` is the kit's — \"a selection was made\", carrying values — rather\n * than the div's form event, so the DOM's spelling of it is omitted. */\nexport interface EntityComboboxProps<V extends string | number>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\"> {\n /** Selected id, or null/undefined when nothing is selected. */\n value: V | null | undefined;\n /** Selecting an option emits its value; the clear button emits `null`. */\n onChange: (value: V | null) => void;\n /** Already-loaded options (client-side filtered). Also used to resolve the\n * trigger label for the current `value`. */\n options?: ComboOption<V>[];\n /** Async option source, debounced and called on open + as the query changes\n * (at `minChars` and up). Stale responses are ignored; a rejection empties the\n * list and says `loadErrorLabel` instead of leaving the last query's rows up. When set, `options` is used only for label\n * resolution, not as the result list. */\n loadOptions?: (query: string) => Promise<ComboOption<V>[]>;\n /** External loading flag (OR-ed with the internal async state). */\n loading?: boolean;\n label?: ReactNode;\n placeholder?: string;\n searchPlaceholder?: string;\n emptyLabel?: string;\n clearable?: boolean;\n clearLabel?: string;\n /** The phone sheet's close button. Its own prop rather than a reuse of\n * `clearLabel`: \"clear the selection\" and \"close this screen\" are different\n * actions, and on a full-screen sheet the close button is the only way out —\n * so it is the one control here that MUST be in the reader's language. */\n closeLabel?: string;\n disabled?: boolean;\n /** When set, a \"create\" row appears for a non-empty query with no exact match. */\n onCreate?: (query: string) => void;\n createLabel?: (query: string) => string;\n /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */\n invalid?: boolean;\n /** What is wrong with the value, as {@link Input}'s `error`: rendered under the\n * field, on the trigger's `aria-describedby`, and implies `invalid`. */\n error?: ReactNode;\n /** Narrow `options` client-side by the query. Default `true`; `false` shows them\n * as given (a server-ranked list). */\n filter?: boolean;\n /** Offer nothing, and call no `loadOptions`, below this many characters. Default\n * `0`, i.e. the list loads as the panel opens. */\n minChars?: number;\n /** `loadOptions` debounce. Default 150 ms. */\n debounceMs?: number;\n /** Shown when `loadOptions` rejects. Default: `combobox.loadError`. */\n loadErrorLabel?: string;\n}\n\n/**\n * An id-keyed, searchable entity picker: a field-styled trigger showing the\n * selected item's label, and a portalled dropdown of `{label, sublabel, icon}`\n * options — loaded up front via `options` or lazily via `loadOptions`. Built on\n * the shared field/anchor/dismiss/search primitives (no cmdk/Radix). For picking\n * several entities use {@link MultiEntityCombobox}.\n */\nexport function EntityCombobox<V extends string | number>({\n value,\n onChange,\n options,\n loadOptions,\n loading,\n label,\n placeholder,\n searchPlaceholder,\n emptyLabel,\n clearable,\n clearLabel,\n closeLabel,\n disabled,\n onCreate,\n createLabel,\n className,\n invalid,\n error,\n filter,\n minChars,\n debounceMs,\n loadErrorLabel,\n \"aria-label\": ariaLabel,\n ...rest\n}: EntityComboboxProps<V>) {\n const core = useComboboxCore<V>({\n options,\n loadOptions,\n loading,\n filter,\n minChars,\n debounceMs,\n });\n const field = useComboboxFieldError(error, invalid);\n // The props are the per-instance overrides, the provider the app-wide ones; a\n // prop left `undefined` falls through to the provider rather than masking it.\n const labels = useKitLabels(\"combobox\", DEFAULT_COMBOBOX_LABELS, {\n search: searchPlaceholder,\n noResults: emptyLabel,\n clear: clearLabel,\n create: createLabel,\n });\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const { open, results, resolve, setOpen } = core;\n // One id per instance, generated here rather than in the core: `aria-controls` on\n // the trigger has to name the list while the list is still closed, so the id\n // belongs to whoever renders both ends of it.\n const listboxId = useId();\n\n const selectedOption = value == null ? null : resolve(value);\n const q = core.query.trim();\n const showCreate =\n Boolean(onCreate) && q.length > 0 && !results.some((o) => o.label.toLowerCase() === q.toLowerCase());\n const showClear = Boolean(clearable && value != null && !disabled);\n /** What the closed control is showing — the second half of its accessible name. */\n const triggerText = selectedOption?.label ?? placeholder ?? \"\";\n\n const choose = (o: ComboOption<V>) => {\n core.cacheRef.current.set(o.value, o);\n onChange(o.value);\n // Back to the trigger, not to <body>: the panel that held focus is about to\n // unmount, and a keyboard user who just answered this field should be standing\n // on it, ready to Tab to the next one.\n core.closeToTrigger();\n };\n\n return (\n // `rest` dresses the wrapper, which has no role; the accessible NAME goes on\n // the trigger, which has one. Spread FIRST so the trigger's ARIA and the\n // handlers that open the panel cannot be clobbered from outside.\n <div {...rest} className={cn(\"relative\", className)}>\n {label !== undefined && <FieldLabel>{label}</FieldLabel>}\n <button\n ref={core.triggerRef}\n type=\"button\"\n // A combobox, not a button. The distinction is not pedantry: this control\n // carried `aria-invalid`, which `button` does not support, so a required\n // field left empty painted a rose border and told a reader nothing at all —\n // and ESLint flagged it as exactly that (`role-supports-aria-props`). The\n // fix the audit asked for is the role that describes what this IS: a closed\n // choice that expands into the list named below. `combobox` supports\n // `aria-invalid`, so the border and the announcement finally agree.\n role=\"combobox\"\n aria-expanded={open}\n aria-controls={listboxId}\n aria-haspopup=\"listbox\"\n // The label is a floating <span>, not a <label for>, so without this the\n // trigger's accessible name is whatever value happens to be selected —\n // \"Checking\" with nothing saying it is the account. The label AND the\n // value, because `aria-label` replaces the content rather than adding to\n // it, and a control that announces only its name has lost the answer.\n // A caller's own name wins over the composition: two fields labelled\n // \"Account\" on a transfer form are the from and the to, and only the\n // caller knows which is which.\n aria-label={\n ariaLabel ??\n (typeof label === \"string\" ? common.fieldValue(label, triggerText) : undefined)\n }\n disabled={disabled}\n aria-invalid={field.isInvalid || undefined}\n aria-describedby={field.describedBy}\n onClick={() => !disabled && setOpen((o) => !o)}\n // Down/Up opens the list from the closed trigger, per the APG. Enter and\n // Space already do it through the button's own click.\n onKeyDown={(e) => {\n if (disabled) return;\n if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n e.preventDefault();\n setOpen(true);\n }\n }}\n className={cn(\n FIELD_TRIGGER,\n \"pr-9\",\n label !== undefined && FIELD_FLOATING_PAD,\n disabled && \"cursor-not-allowed opacity-50\",\n field.isInvalid && FIELD_INVALID,\n )}\n >\n <span className=\"flex min-w-0 items-center gap-2\">\n {selectedOption?.icon && <span className=\"shrink-0\">{selectedOption.icon}</span>}\n <span\n className={cn(\n \"truncate\",\n // A chosen value is the field's VALUE, so it is set in the same ink an\n // <input>'s value is — FIELD_BASE's own text colour, inherited rather\n // than restated (Keksdose dev#477). It used to be one notch lighter\n // than the typeahead fields beside it, which is visible when a picker\n // and a text field share a form row. Nothing selected keeps the\n // placeholder tone (`--text-placeholder`), which every field here\n // agrees on.\n !selectedOption && \"text-[var(--text-placeholder)]\",\n )}\n >\n {selectedOption?.label ?? placeholder ?? \"\"}\n </span>\n </span>\n {showClear ? (\n <span\n role=\"button\"\n tabIndex={-1}\n aria-label={labels.clear}\n onClick={(e) => {\n e.stopPropagation();\n onChange(null);\n }}\n className=\"absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]\"\n >\n <X className=\"size-4\" />\n </span>\n ) : (\n <FieldChevron />\n )}\n </button>\n <ComboboxPanel\n core={core}\n listboxId={listboxId}\n // On a phone the panel becomes a full-screen sheet, which needs the field's\n // own label to say what it is asking for (live #200).\n sheetTitle={label ?? placeholder}\n searchPlaceholder={labels.search}\n emptyLabel={labels.noResults}\n closeLabel={closeLabel}\n loadErrorLabel={loadErrorLabel}\n isSelected={(v) => v === value}\n onChoose={choose}\n showCreate={showCreate}\n onCreate={() => {\n onCreate?.(q);\n core.closeToTrigger();\n }}\n createContent={labels.create(q)}\n />\n {field.errorEl}\n </div>\n );\n}\n"],"mappings":"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","names":[]}