@eifi1/ui-kit 0.5.0 → 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 (144) 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 +22 -7
  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/currency-select.js +10 -2
  20. package/dist/components/currency-select.js.map +1 -1
  21. package/dist/components/danger-confirm.d.ts +91 -0
  22. package/dist/components/danger-confirm.js +181 -0
  23. package/dist/components/danger-confirm.js.map +1 -0
  24. package/dist/components/date-picker.js +19 -7
  25. package/dist/components/date-picker.js.map +1 -1
  26. package/dist/components/dialog-frame.d.ts +84 -0
  27. package/dist/components/dialog-frame.js +86 -0
  28. package/dist/components/dialog-frame.js.map +1 -0
  29. package/dist/components/disclosure.d.ts +108 -0
  30. package/dist/components/disclosure.js +127 -0
  31. package/dist/components/disclosure.js.map +1 -0
  32. package/dist/components/entity-combobox.d.ts +17 -3
  33. package/dist/components/entity-combobox.js +25 -5
  34. package/dist/components/entity-combobox.js.map +1 -1
  35. package/dist/components/file-button.d.ts +161 -0
  36. package/dist/components/file-button.js +225 -0
  37. package/dist/components/file-button.js.map +1 -0
  38. package/dist/components/file-dropzone.d.ts +72 -23
  39. package/dist/components/file-dropzone.js +219 -94
  40. package/dist/components/file-dropzone.js.map +1 -1
  41. package/dist/components/icon-picker.d.ts +72 -0
  42. package/dist/components/icon-picker.js +104 -0
  43. package/dist/components/icon-picker.js.map +1 -0
  44. package/dist/components/mini-calendar.d.ts +3 -0
  45. package/dist/components/mini-calendar.js +4 -3
  46. package/dist/components/mini-calendar.js.map +1 -1
  47. package/dist/components/modal.d.ts +8 -1
  48. package/dist/components/modal.js +4 -2
  49. package/dist/components/modal.js.map +1 -1
  50. package/dist/components/month-picker.js +16 -5
  51. package/dist/components/month-picker.js.map +1 -1
  52. package/dist/components/multi-entity-combobox.d.ts +16 -3
  53. package/dist/components/multi-entity-combobox.js +25 -5
  54. package/dist/components/multi-entity-combobox.js.map +1 -1
  55. package/dist/components/number-field.d.ts +41 -1
  56. package/dist/components/number-field.js +42 -10
  57. package/dist/components/number-field.js.map +1 -1
  58. package/dist/components/number-input.d.ts +35 -2
  59. package/dist/components/number-input.js +35 -4
  60. package/dist/components/number-input.js.map +1 -1
  61. package/dist/components/numpad-sheet.d.ts +7 -0
  62. package/dist/components/popover.js +4 -1
  63. package/dist/components/popover.js.map +1 -1
  64. package/dist/components/search-field.d.ts +16 -0
  65. package/dist/components/search-field.js +29 -7
  66. package/dist/components/search-field.js.map +1 -1
  67. package/dist/components/signature-pad.d.ts +43 -1
  68. package/dist/components/signature-pad.js +74 -2
  69. package/dist/components/signature-pad.js.map +1 -1
  70. package/dist/components/swatch-picker.d.ts +69 -0
  71. package/dist/components/swatch-picker.js +75 -0
  72. package/dist/components/swatch-picker.js.map +1 -0
  73. package/dist/components/tile-radio.d.ts +50 -0
  74. package/dist/components/tile-radio.js +140 -0
  75. package/dist/components/tile-radio.js.map +1 -0
  76. package/dist/components/toggle-group.d.ts +27 -5
  77. package/dist/components/toggle-group.js +22 -15
  78. package/dist/components/toggle-group.js.map +1 -1
  79. package/dist/components/trigger-aria.d.ts +18 -0
  80. package/dist/components/trigger-aria.js +25 -0
  81. package/dist/components/trigger-aria.js.map +1 -0
  82. package/dist/components/ui.d.ts +164 -18
  83. package/dist/components/ui.js +207 -34
  84. package/dist/components/ui.js.map +1 -1
  85. package/dist/i18n/defaults.d.ts +7 -0
  86. package/dist/i18n/defaults.js +13 -2
  87. package/dist/i18n/defaults.js.map +1 -1
  88. package/dist/i18n/kit-labels.d.ts +38 -5
  89. package/dist/i18n/kit-labels.js +12 -4
  90. package/dist/i18n/kit-labels.js.map +1 -1
  91. package/dist/index.d.ts +16 -7
  92. package/dist/index.js +12 -0
  93. package/dist/index.js.map +1 -1
  94. package/dist/lib/table-text.d.ts +127 -0
  95. package/dist/lib/table-text.js +82 -0
  96. package/dist/lib/table-text.js.map +1 -0
  97. package/dist/rhf/form.d.ts +79 -0
  98. package/dist/rhf/form.js +143 -0
  99. package/dist/rhf/form.js.map +1 -0
  100. package/dist/rhf.d.ts +4 -0
  101. package/dist/rhf.js +3 -0
  102. package/dist/rhf.js.map +1 -0
  103. package/dist/shell/app-shell.js +3 -1
  104. package/dist/shell/app-shell.js.map +1 -1
  105. package/dist/table-text.d.ts +1 -0
  106. package/dist/table-text.js +3 -0
  107. package/dist/table-text.js.map +1 -0
  108. package/package.json +14 -1
  109. package/src/components/autocomplete.tsx +425 -0
  110. package/src/components/chip.tsx +43 -7
  111. package/src/components/choice-card.tsx +305 -0
  112. package/src/components/combobox-core.tsx +228 -58
  113. package/src/components/combobox.tsx +58 -21
  114. package/src/components/currency-select.tsx +16 -2
  115. package/src/components/danger-confirm.tsx +286 -0
  116. package/src/components/date-picker.tsx +31 -6
  117. package/src/components/dialog-frame.tsx +179 -0
  118. package/src/components/disclosure.tsx +259 -0
  119. package/src/components/entity-combobox.tsx +41 -6
  120. package/src/components/file-button.tsx +431 -0
  121. package/src/components/file-dropzone.tsx +323 -117
  122. package/src/components/icon-picker.tsx +181 -0
  123. package/src/components/mini-calendar.tsx +7 -3
  124. package/src/components/modal.tsx +10 -2
  125. package/src/components/month-picker.tsx +22 -4
  126. package/src/components/multi-entity-combobox.tsx +40 -6
  127. package/src/components/number-field.tsx +86 -10
  128. package/src/components/number-input.tsx +79 -2
  129. package/src/components/popover.tsx +4 -1
  130. package/src/components/search-field.tsx +49 -6
  131. package/src/components/signature-pad.tsx +112 -0
  132. package/src/components/swatch-picker.tsx +141 -0
  133. package/src/components/tile-radio.tsx +228 -0
  134. package/src/components/toggle-group.tsx +54 -18
  135. package/src/components/trigger-aria.ts +42 -0
  136. package/src/components/ui.tsx +427 -40
  137. package/src/i18n/defaults.ts +12 -1
  138. package/src/i18n/kit-labels.tsx +45 -5
  139. package/src/index.ts +19 -0
  140. package/src/lib/table-text.ts +265 -0
  141. package/src/rhf/form.tsx +300 -0
  142. package/src/rhf.ts +9 -0
  143. package/src/shell/app-shell.tsx +3 -1
  144. package/src/table-text.ts +8 -0
@@ -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":[]}
@@ -0,0 +1,161 @@
1
+ import * as react from 'react';
2
+ import { ComponentPropsWithoutRef, ReactNode, ReactElement } from 'react';
3
+ import { Button } from './ui.js';
4
+
5
+ /**
6
+ * A button that opens the file picker — the shape all three apps kept writing by hand
7
+ * as a `<Button>` plus a hidden `<input type="file">` plus a ref between them (seven
8
+ * copies in keksdose, two each in kastlan and lenkbank).
9
+ *
10
+ * Every copy had to remember the same four things, and each one forgot at least one:
11
+ *
12
+ * 1. **Reset the input after every pick.** A file input fires `change` only when its
13
+ * value CHANGES, so picking the same file twice in a row — the retry after a failed
14
+ * upload, the second photo of the same receipt — did nothing at all, which reads as
15
+ * a broken button. `value = ""` after each pick; always, not per call site.
16
+ * 2. **`type="button"`.** `<Button>` does not set it, and inside a form a bare button
17
+ * submits the form before the picker opens.
18
+ * 3. **Check the file.** `accept` filters the DIALOG, not the result: the dialog's
19
+ * "All files" switch, a drop, and a mobile share sheet all hand over whatever the
20
+ * user chose. So `accept` is re-checked here, along with `maxSize`, `maxFiles` and
21
+ * an optional `isValid`.
22
+ * 4. **Say no without a toast.** A rejection is reported through `onReject`, with a
23
+ * translated message per file, and spoken through a live region — the kit does not
24
+ * decide how an app surfaces errors (keksdose's proposal §1 asked for exactly that).
25
+ *
26
+ * The part that is not a button — the hidden input, the check, the announcement — is
27
+ * {@link useFilePicker}, for the case where the thing that opens the picker is someone
28
+ * else's control (a card's "Add" action, a menu item, a second button for the camera).
29
+ */
30
+ /** Every string the file pickers ({@link FileButton}, `FileDropzone`) render or speak.
31
+ * Messages are functions of the file name so a translation can put it anywhere. */
32
+ interface FilePickerLabels {
33
+ /** The file's type is not in `accept`. */
34
+ rejectedType: (name: string) => string;
35
+ /** The file is larger than `maxSize`; `maxSize` arrives formatted ("5 MB"). */
36
+ rejectedSize: (name: string, maxSize: string) => string;
37
+ /** The file was one too many for `maxFiles`. */
38
+ rejectedCount: (name: string, maxFiles: number) => string;
39
+ /** `isValid` said no and the caller gave no `invalidMessage`. */
40
+ rejectedInvalid: (name: string) => string;
41
+ /** Spoken instead of the per-file message when more than one file was refused. */
42
+ rejectedMany: (count: number) => string;
43
+ /** Spoken after a pick the component itself echoes (the dropzone). */
44
+ selected: (count: number, firstName: string) => string;
45
+ /** The dropzone's remove button for one file. */
46
+ remove: (name: string) => string;
47
+ /** The dropzone's remove-everything button in `multiple` mode. */
48
+ clearAll: string;
49
+ /** Spoken after a remove / clear, since the button that was pressed is gone. */
50
+ removed: (name: string) => string;
51
+ cleared: string;
52
+ }
53
+ declare const DEFAULT_FILE_PICKER_LABELS: FilePickerLabels;
54
+ type FileRejectionReason = "type" | "size" | "count" | "invalid";
55
+ /** One file the picker refused, and why. `message` is already translated (see
56
+ * {@link FilePickerLabels}, or the caller's `invalidMessage`), so a host that just
57
+ * wants to show it can render `rejections[0].message` as is. */
58
+ interface FileRejection {
59
+ file: File;
60
+ reason: FileRejectionReason;
61
+ message: string;
62
+ }
63
+ /**
64
+ * Does `file` satisfy an `accept` string, the way the browser's dialog reads it?
65
+ * Comma-separated tokens: `.ext` (case-insensitive suffix of the name), `type/*`
66
+ * (a MIME family) or an exact MIME type. An empty or absent `accept` takes anything.
67
+ *
68
+ * A file with no `type` — common for `.step`, `.dat`, anything the OS has no MIME
69
+ * mapping for — can only match by extension, which is why lenkbank lists `.stp` AND
70
+ * `model/step`: that is how `accept` has to be written for the dialog anyway.
71
+ */
72
+ declare function matchesAccept(file: File, accept: string | undefined): boolean;
73
+ /** What the pickers screen a pick with. All optional; nothing set accepts everything. */
74
+ interface FileScreenOptions {
75
+ accept?: string;
76
+ /** Bytes. Larger files are refused with reason `"size"`. */
77
+ maxSize?: number;
78
+ /** How many files one pick may deliver. The rest are refused with reason `"count"`
79
+ * — first come, first kept. For a running cap ("at most 5 attachments") pass what
80
+ * is LEFT: `maxFiles={5 - attachments.length}`. */
81
+ maxFiles?: number;
82
+ /** The caller's own check, after `accept` and `maxSize`. */
83
+ isValid?: (file: File) => boolean;
84
+ /** The message for an `isValid` refusal; `labels.rejectedInvalid` otherwise. */
85
+ invalidMessage?: string;
86
+ }
87
+ /** @internal Split a pick into the files that pass and the ones that do not. */
88
+ declare function screenFiles(files: readonly File[], opts: FileScreenOptions, labels: FilePickerLabels, formatSize: (bytes: number) => string): {
89
+ accepted: File[];
90
+ rejected: FileRejection[];
91
+ };
92
+ /** One sentence for a whole batch of refusals: the file's own message for one, a
93
+ * count for several — reading out five sentences in a row helps nobody. */
94
+ declare function summariseRejections(rejected: readonly FileRejection[], labels: FilePickerLabels): string;
95
+ interface UseFilePickerOptions extends FileScreenOptions {
96
+ /** Let one pick deliver several files. Without it a drop of several keeps the first. */
97
+ multiple?: boolean;
98
+ /**
99
+ * Ask a phone for its camera instead of the file chooser: `"environment"` is the
100
+ * back camera, `"user"` the front. A HINT — desktop browsers ignore it, and Chrome
101
+ * drops it when `multiple` is also set (a camera cannot deliver a list), which is why
102
+ * keksdose's camera input has no `multiple`. Pass one or the other.
103
+ */
104
+ capture?: boolean | "user" | "environment";
105
+ /** The files that passed, in pick order. Never called with an empty array. */
106
+ onFiles: (files: File[]) => void;
107
+ /** The files that did not, with a translated message each. The refusals are also
108
+ * spoken through a live region, so this is for SHOWING them, not for a11y. */
109
+ onReject?: (rejections: FileRejection[]) => void;
110
+ /** `open()` and `take()` do nothing while set. */
111
+ disabled?: boolean;
112
+ /** Per-instance overrides of the `filePicker` label namespace. */
113
+ labels?: Partial<FilePickerLabels>;
114
+ }
115
+ interface UseFilePickerReturn {
116
+ /** Open the system picker. Call it from a click handler — browsers only open a file
117
+ * dialog in response to a user gesture. */
118
+ open: () => void;
119
+ /** Screen and deliver files that arrived some other way — a drop, a paste. Honours
120
+ * `multiple` (only the first file without it) and every check. */
121
+ take: (files: ArrayLike<File> | null | undefined) => void;
122
+ /** The hidden input and the live region. Render it once, anywhere — it takes no
123
+ * space and needs no positioned ancestor. */
124
+ element: ReactElement;
125
+ }
126
+ /**
127
+ * The headless half of {@link FileButton}: a hidden file input you can open from any
128
+ * control, with the reset, the screening and the announcement built in.
129
+ *
130
+ * ```tsx
131
+ * const picker = useFilePicker({ accept: ".pdf", onFiles: ([f]) => upload(f) });
132
+ * return <>{picker.element}<DetailTableCard onAdd={picker.open} … /></>;
133
+ * ```
134
+ */
135
+ declare function useFilePicker({ accept, multiple, capture, maxSize, maxFiles, isValid, invalidMessage, onFiles, onReject, disabled, labels: labelsProp, }: UseFilePickerOptions): UseFilePickerReturn;
136
+ type ButtonOwnProps = ComponentPropsWithoutRef<typeof Button>;
137
+ interface FileButtonProps extends Omit<UseFilePickerOptions, "disabled">, Omit<ButtonOwnProps, "type" | "children" | "accept" | "capture" | "multiple"> {
138
+ /** The button's content — usually an icon and a word. It is the accessible name. */
139
+ children: ReactNode;
140
+ /** Busy: disabled, `aria-busy`, and a spinner before the content — for "uploading". */
141
+ pending?: boolean;
142
+ /** Also accept files dropped ON the button (lenkbank's "drop onto the button").
143
+ * Off by default: a button that silently takes drops is a surprise on a page that
144
+ * has a real drop target elsewhere. */
145
+ droppable?: boolean;
146
+ }
147
+ /**
148
+ * {@link useFilePicker} behind a {@link Button}: `variant` and every other button prop
149
+ * pass through, and the ref is the `<button>` (so `ref.current.click()` opens the
150
+ * picker from elsewhere too).
151
+ *
152
+ * ```tsx
153
+ * <FileButton accept="image/*,application/pdf" capture="environment" variant="secondary"
154
+ * maxSize={10_000_000} onFiles={([f]) => upload(f)} onReject={([r]) => setError(r.message)}>
155
+ * <Camera aria-hidden /> Photograph receipt
156
+ * </FileButton>
157
+ * ```
158
+ */
159
+ declare const FileButton: react.ForwardRefExoticComponent<FileButtonProps & react.RefAttributes<HTMLButtonElement>>;
160
+
161
+ export { DEFAULT_FILE_PICKER_LABELS, FileButton, type FileButtonProps, type FilePickerLabels, type FileRejection, type FileRejectionReason, type FileScreenOptions, type UseFilePickerOptions, type UseFilePickerReturn, matchesAccept, screenFiles, summariseRejections, useFilePicker };