@eifi1/ui-kit 0.15.3 → 0.15.4

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 (69) hide show
  1. package/README.md +8 -7
  2. package/dist/chart.d.ts +51 -1
  3. package/dist/components/amount-input.d.ts +1 -1
  4. package/dist/components/amount-input.js +5 -10
  5. package/dist/components/amount-input.js.map +1 -1
  6. package/dist/components/button-group.d.ts +1 -1
  7. package/dist/components/calculator.d.ts +1 -1
  8. package/dist/components/chart.d.ts +16 -1
  9. package/dist/components/chart.js +65 -37
  10. package/dist/components/chart.js.map +1 -1
  11. package/dist/components/chip.d.ts +8 -2
  12. package/dist/components/chip.js +12 -3
  13. package/dist/components/chip.js.map +1 -1
  14. package/dist/components/copy-button.d.ts +1 -1
  15. package/dist/components/facing-pair.d.ts +51 -0
  16. package/dist/components/file-button.d.ts +1 -1
  17. package/dist/components/file-dropzone.d.ts +1 -1
  18. package/dist/components/form-actions.d.ts +1 -1
  19. package/dist/components/form-actions.js +9 -1
  20. package/dist/components/form-actions.js.map +1 -1
  21. package/dist/components/money-field.d.ts +1 -1
  22. package/dist/components/number-field.d.ts +1 -1
  23. package/dist/components/number-input.d.ts +1 -1
  24. package/dist/components/number-input.js +17 -3
  25. package/dist/components/number-input.js.map +1 -1
  26. package/dist/components/numpad-sheet.d.ts +1 -1
  27. package/dist/components/series-chart.d.ts +79 -1
  28. package/dist/components/series-chart.js +74 -13
  29. package/dist/components/series-chart.js.map +1 -1
  30. package/dist/components/settings-fields.d.ts +1 -1
  31. package/dist/components/text-link.d.ts +1 -1
  32. package/dist/components/time-input.d.ts +1 -1
  33. package/dist/components/ui.d.ts +1 -1
  34. package/dist/hooks/use-file-drop.d.ts +1 -1
  35. package/dist/i18n/defaults.d.ts +1 -1
  36. package/dist/i18n/kit-labels.d.ts +1 -1
  37. package/dist/i18n/kit-labels.js +8 -2
  38. package/dist/i18n/kit-labels.js.map +1 -1
  39. package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
  40. package/dist/i18n/locales/de-CH.d.ts +1 -1
  41. package/dist/i18n/locales/de-informal.d.ts +1 -1
  42. package/dist/i18n/locales/de.d.ts +1 -1
  43. package/dist/i18n/locales/es.d.ts +1 -1
  44. package/dist/i18n/locales/fr.d.ts +1 -1
  45. package/dist/i18n/locales/hu.d.ts +1 -1
  46. package/dist/i18n/locales/it.d.ts +1 -1
  47. package/dist/i18n/locales/zh.d.ts +1 -1
  48. package/dist/index.d.ts +3 -3
  49. package/dist/{kit-labels-BHfRBbHs.d.ts → kit-labels-CnnBd-2N.d.ts} +13 -2
  50. package/dist/lib/format.d.ts +15 -1
  51. package/dist/lib/format.js +16 -0
  52. package/dist/lib/format.js.map +1 -1
  53. package/dist/rhf/fields.d.ts +1 -1
  54. package/dist/rhf/form.d.ts +1 -1
  55. package/dist/rhf.d.ts +1 -1
  56. package/dist/shell/app-shell.d.ts +1 -1
  57. package/dist/shell/top-bar-brand.d.ts +1 -1
  58. package/dist/shell.d.ts +1 -1
  59. package/dist/wizard/stepper-nav.d.ts +1 -1
  60. package/dist/wizard.d.ts +1 -1
  61. package/package.json +1 -1
  62. package/src/components/amount-input.tsx +8 -13
  63. package/src/components/chart.tsx +102 -41
  64. package/src/components/chip.tsx +29 -4
  65. package/src/components/form-actions.tsx +9 -1
  66. package/src/components/number-input.tsx +22 -2
  67. package/src/components/series-chart.tsx +122 -20
  68. package/src/i18n/kit-labels.tsx +19 -1
  69. package/src/lib/format.ts +30 -0
@@ -63,7 +63,15 @@ function FormActions({
63
63
  "data-slot": "form-actions",
64
64
  "data-placement": placement,
65
65
  ...rest,
66
- style: placement === "sticky" ? { paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))", ...style } : style,
66
+ style: placement === "sticky" ? {
67
+ paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))",
68
+ // On the phone's bottom nav, not behind it (keksdose live #361): `bottom-0`
69
+ // pinned the row under AppShell's nav, which covers the page's last ~56px.
70
+ // `--app-nav-h` is the nav's measured height (0px from md up, and outside an
71
+ // AppShell); the 1px overlaps the two top borders into one line.
72
+ bottom: "max(0px, calc(var(--app-nav-h, 0px) - 1px))",
73
+ ...style
74
+ } : style,
67
75
  className: cn("flex flex-wrap items-center gap-2", ALIGN_CLASS[justify], PLACEMENT_CLASS[placement], className),
68
76
  children: [
69
77
  start && /* @__PURE__ */ jsx("div", { className: "me-auto flex items-center gap-2", children: start }),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/form-actions.tsx"],"sourcesContent":["import { isValidElement } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { Button, Spinner, type ButtonVariant } from \"./ui\";\n\n/** The words {@link FormActions} renders on its own behalf — the `form` namespace. */\nexport interface FormActionsLabels {\n save: string;\n cancel: string;\n}\n\nexport const DEFAULT_FORM_ACTIONS_LABELS: FormActionsLabels = {\n save: \"Save\",\n cancel: \"Cancel\",\n};\n\nexport type FormActionsAlign = \"start\" | \"center\" | \"end\" | \"between\";\n\n/**\n * Where the row sits:\n * - `inline` (default): under the form's last field, `pt-4` above it.\n * - `sticky`: stuck to the bottom of the scrolling page or pane, on the surface\n * colour with a rule above, clear of the phone's home indicator — for a long form\n * whose Save should not be a scroll away.\n * - `dialog`: no spacing of its own, for a dialog's footer slot (DialogFrame,\n * FullBleedDialog's `footer`), which already pads and rules it.\n */\nexport type FormActionsPlacement = \"inline\" | \"sticky\" | \"dialog\";\n\n/** The start-side action of {@link FormActionsProps.destructive}, as data. */\nexport interface FormActionsDestructive {\n label: ReactNode;\n onClick: () => void;\n disabled?: boolean;\n /** See Button's `disabledReason`. */\n disabledReason?: ReactNode;\n}\n\nconst ALIGN_CLASS: Record<FormActionsAlign, string> = {\n start: \"justify-start\",\n center: \"justify-center\",\n end: \"justify-end\",\n between: \"justify-between\",\n};\n\nconst PLACEMENT_CLASS: Record<FormActionsPlacement, string> = {\n inline: \"pt-4\",\n sticky: \"sticky bottom-0 z-10 border-t border-[var(--border)] bg-[var(--bg-surface)] pt-3\",\n dialog: \"\",\n};\n\nexport interface FormActionsProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n /** Called by Cancel. Omitted: no Cancel button (a form that only saves). */\n onCancel?: () => void;\n /**\n * Given: the save button is `type=\"button\"` and calls this — for a dialog or a row\n * that saves from a handler. Omitted: it is `type=\"submit\"` and submits the\n * enclosing `<form>` (or the one named by {@link form}).\n */\n onSubmit?: () => void;\n /** The save button's text. Default `form.save` from the provider, else \"Save\". */\n submitLabel?: ReactNode;\n /** The cancel button's text. Default `form.cancel` from the provider, else \"Cancel\". */\n cancelLabel?: ReactNode;\n /** The save button's text while `pending` (\"Saving…\"). Default: `submitLabel`. */\n pendingLabel?: ReactNode;\n /**\n * The save is running: a spinner in the save button, `aria-busy` on it, and the\n * button disabled, so a second click cannot send the form twice. Cancel stays\n * usable.\n */\n pending?: boolean;\n /** Disable save for a reason of the form's own (an unbalanced entry). */\n submitDisabled?: boolean;\n /** Why save is disabled — see Button's `disabledReason`. Keeps it focusable. */\n submitDisabledReason?: ReactNode;\n /** The save button's variant. Default `brand`; `danger` for a save that destroys\n * (kastlan's `destructive` flag on its own FormActions). */\n submitVariant?: ButtonVariant;\n /**\n * A destructive action at the START of the row, apart from Save and Cancel — the\n * \"Delete\" of an edit dialog. Pass `{ label, onClick }` for the kit's quiet danger\n * button, or an element of your own (a {@link DangerConfirm}, say). With it the row\n * is `between`-aligned unless `align` says otherwise.\n */\n destructive?: ReactNode | FormActionsDestructive;\n /** Horizontal alignment. Default `end` (`between` with a `destructive` action). */\n align?: FormActionsAlign;\n /** See {@link FormActionsPlacement}. Default `inline`. */\n placement?: FormActionsPlacement;\n /** The `id` of the `<form>` the save button submits, when the row is rendered\n * outside it (a dialog's footer slot). */\n form?: string;\n /** Extra actions, placed before Cancel. */\n children?: ReactNode;\n}\n\nfunction isDestructiveData(value: unknown): value is FormActionsDestructive {\n return typeof value === \"object\" && value !== null && !isValidElement(value) && \"onClick\" in value;\n}\n\n/**\n * The Save / Cancel row every form, wizard-step form and dialog ends in — kastlan's\n * `FormActions`, with the labels from the provider, a pending state and a place for\n * the destructive action.\n *\n * ```tsx\n * <form onSubmit={form.handleSubmit(save)}>\n * …\n * <FormActions onCancel={close} pending={mutation.isPending} />\n * </form>\n * ```\n *\n * The spinner is the kit's {@link Spinner}, drawn inside the button with `label={null}`\n * so the button's name stays its text; `aria-busy` is what says it is working.\n */\nexport function FormActions({\n onCancel,\n onSubmit,\n submitLabel,\n cancelLabel,\n pendingLabel,\n pending = false,\n submitDisabled = false,\n submitDisabledReason,\n submitVariant = \"brand\",\n destructive,\n align,\n placement = \"inline\",\n form,\n children,\n className,\n style,\n ...rest\n}: FormActionsProps) {\n const labels = useKitLabels(\"form\", DEFAULT_FORM_ACTIONS_LABELS);\n const hasDestructive = destructive !== undefined && destructive !== null && destructive !== false;\n const justify = align ?? (hasDestructive ? \"between\" : \"end\");\n const start = !hasDestructive ? null : isDestructiveData(destructive) ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n tone=\"danger\"\n onClick={destructive.onClick}\n disabled={destructive.disabled}\n disabledReason={destructive.disabledReason}\n >\n {destructive.label}\n </Button>\n ) : (\n destructive\n );\n const saveLabel = submitLabel ?? labels.save;\n\n return (\n <div\n data-slot=\"form-actions\"\n data-placement={placement}\n {...rest}\n style={\n placement === \"sticky\"\n ? { paddingBottom: \"max(0.75rem, env(safe-area-inset-bottom))\", ...style }\n : style\n }\n className={cn(\"flex flex-wrap items-center gap-2\", ALIGN_CLASS[justify], PLACEMENT_CLASS[placement], className)}\n >\n {start && <div className=\"me-auto flex items-center gap-2\">{start}</div>}\n <div className=\"flex flex-wrap items-center gap-2\">\n {children}\n {onCancel && (\n <Button type=\"button\" variant=\"secondary\" onClick={onCancel}>\n {cancelLabel ?? labels.cancel}\n </Button>\n )}\n <Button\n type={onSubmit ? \"button\" : \"submit\"}\n form={form}\n onClick={onSubmit}\n variant={submitVariant}\n disabled={pending || submitDisabled}\n disabledReason={pending ? undefined : submitDisabledReason}\n aria-busy={pending || undefined}\n >\n {pending && <Spinner label={null} className=\"size-4\" />}\n {pending && pendingLabel !== undefined ? pendingLabel : saveLabel}\n </Button>\n </div>\n </div>\n );\n}\n"],"mappings":";AA4II,cAmCI,YAnCJ;AA5IJ,SAAS,sBAAsB;AAE/B,SAAS,UAAU;AACnB,SAAS,oBAAoB;AAC7B,SAAS,QAAQ,eAAmC;AAQ7C,MAAM,8BAAiD;AAAA,EAC5D,MAAM;AAAA,EACN,QAAQ;AACV;AAwBA,MAAM,cAAgD;AAAA,EACpD,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,KAAK;AAAA,EACL,SAAS;AACX;AAEA,MAAM,kBAAwD;AAAA,EAC5D,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AACV;AAgDA,SAAS,kBAAkB,OAAiD;AAC1E,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,eAAe,KAAK,KAAK,aAAa;AAC/F;AAiBO,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV,iBAAiB;AAAA,EACjB;AAAA,EACA,gBAAgB;AAAA,EAChB;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAqB;AACnB,QAAM,SAAS,aAAa,QAAQ,2BAA2B;AAC/D,QAAM,iBAAiB,gBAAgB,UAAa,gBAAgB,QAAQ,gBAAgB;AAC5F,QAAM,UAAU,UAAU,iBAAiB,YAAY;AACvD,QAAM,QAAQ,CAAC,iBAAiB,OAAO,kBAAkB,WAAW,IAClE;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,SAAQ;AAAA,MACR,MAAK;AAAA,MACL,SAAS,YAAY;AAAA,MACrB,UAAU,YAAY;AAAA,MACtB,gBAAgB,YAAY;AAAA,MAE3B,sBAAY;AAAA;AAAA,EACf,IAEA;AAEF,QAAM,YAAY,eAAe,OAAO;AAExC,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,kBAAgB;AAAA,MACf,GAAG;AAAA,MACJ,OACE,cAAc,WACV,EAAE,eAAe,6CAA6C,GAAG,MAAM,IACvE;AAAA,MAEN,WAAW,GAAG,qCAAqC,YAAY,OAAO,GAAG,gBAAgB,SAAS,GAAG,SAAS;AAAA,MAE7G;AAAA,iBAAS,oBAAC,SAAI,WAAU,mCAAmC,iBAAM;AAAA,QAClE,qBAAC,SAAI,WAAU,qCACZ;AAAA;AAAA,UACA,YACC,oBAAC,UAAO,MAAK,UAAS,SAAQ,aAAY,SAAS,UAChD,yBAAe,OAAO,QACzB;AAAA,UAEF;AAAA,YAAC;AAAA;AAAA,cACC,MAAM,WAAW,WAAW;AAAA,cAC5B;AAAA,cACA,SAAS;AAAA,cACT,SAAS;AAAA,cACT,UAAU,WAAW;AAAA,cACrB,gBAAgB,UAAU,SAAY;AAAA,cACtC,aAAW,WAAW;AAAA,cAErB;AAAA,2BAAW,oBAAC,WAAQ,OAAO,MAAM,WAAU,UAAS;AAAA,gBACpD,WAAW,iBAAiB,SAAY,eAAe;AAAA;AAAA;AAAA,UAC1D;AAAA,WACF;AAAA;AAAA;AAAA,EACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/components/form-actions.tsx"],"sourcesContent":["import { isValidElement } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { Button, Spinner, type ButtonVariant } from \"./ui\";\n\n/** The words {@link FormActions} renders on its own behalf — the `form` namespace. */\nexport interface FormActionsLabels {\n save: string;\n cancel: string;\n}\n\nexport const DEFAULT_FORM_ACTIONS_LABELS: FormActionsLabels = {\n save: \"Save\",\n cancel: \"Cancel\",\n};\n\nexport type FormActionsAlign = \"start\" | \"center\" | \"end\" | \"between\";\n\n/**\n * Where the row sits:\n * - `inline` (default): under the form's last field, `pt-4` above it.\n * - `sticky`: stuck to the bottom of the scrolling page or pane, on the surface\n * colour with a rule above, clear of the phone's home indicator — for a long form\n * whose Save should not be a scroll away.\n * - `dialog`: no spacing of its own, for a dialog's footer slot (DialogFrame,\n * FullBleedDialog's `footer`), which already pads and rules it.\n */\nexport type FormActionsPlacement = \"inline\" | \"sticky\" | \"dialog\";\n\n/** The start-side action of {@link FormActionsProps.destructive}, as data. */\nexport interface FormActionsDestructive {\n label: ReactNode;\n onClick: () => void;\n disabled?: boolean;\n /** See Button's `disabledReason`. */\n disabledReason?: ReactNode;\n}\n\nconst ALIGN_CLASS: Record<FormActionsAlign, string> = {\n start: \"justify-start\",\n center: \"justify-center\",\n end: \"justify-end\",\n between: \"justify-between\",\n};\n\nconst PLACEMENT_CLASS: Record<FormActionsPlacement, string> = {\n inline: \"pt-4\",\n sticky: \"sticky bottom-0 z-10 border-t border-[var(--border)] bg-[var(--bg-surface)] pt-3\",\n dialog: \"\",\n};\n\nexport interface FormActionsProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n /** Called by Cancel. Omitted: no Cancel button (a form that only saves). */\n onCancel?: () => void;\n /**\n * Given: the save button is `type=\"button\"` and calls this — for a dialog or a row\n * that saves from a handler. Omitted: it is `type=\"submit\"` and submits the\n * enclosing `<form>` (or the one named by {@link form}).\n */\n onSubmit?: () => void;\n /** The save button's text. Default `form.save` from the provider, else \"Save\". */\n submitLabel?: ReactNode;\n /** The cancel button's text. Default `form.cancel` from the provider, else \"Cancel\". */\n cancelLabel?: ReactNode;\n /** The save button's text while `pending` (\"Saving…\"). Default: `submitLabel`. */\n pendingLabel?: ReactNode;\n /**\n * The save is running: a spinner in the save button, `aria-busy` on it, and the\n * button disabled, so a second click cannot send the form twice. Cancel stays\n * usable.\n */\n pending?: boolean;\n /** Disable save for a reason of the form's own (an unbalanced entry). */\n submitDisabled?: boolean;\n /** Why save is disabled — see Button's `disabledReason`. Keeps it focusable. */\n submitDisabledReason?: ReactNode;\n /** The save button's variant. Default `brand`; `danger` for a save that destroys\n * (kastlan's `destructive` flag on its own FormActions). */\n submitVariant?: ButtonVariant;\n /**\n * A destructive action at the START of the row, apart from Save and Cancel — the\n * \"Delete\" of an edit dialog. Pass `{ label, onClick }` for the kit's quiet danger\n * button, or an element of your own (a {@link DangerConfirm}, say). With it the row\n * is `between`-aligned unless `align` says otherwise.\n */\n destructive?: ReactNode | FormActionsDestructive;\n /** Horizontal alignment. Default `end` (`between` with a `destructive` action). */\n align?: FormActionsAlign;\n /** See {@link FormActionsPlacement}. Default `inline`. */\n placement?: FormActionsPlacement;\n /** The `id` of the `<form>` the save button submits, when the row is rendered\n * outside it (a dialog's footer slot). */\n form?: string;\n /** Extra actions, placed before Cancel. */\n children?: ReactNode;\n}\n\nfunction isDestructiveData(value: unknown): value is FormActionsDestructive {\n return typeof value === \"object\" && value !== null && !isValidElement(value) && \"onClick\" in value;\n}\n\n/**\n * The Save / Cancel row every form, wizard-step form and dialog ends in — kastlan's\n * `FormActions`, with the labels from the provider, a pending state and a place for\n * the destructive action.\n *\n * ```tsx\n * <form onSubmit={form.handleSubmit(save)}>\n * …\n * <FormActions onCancel={close} pending={mutation.isPending} />\n * </form>\n * ```\n *\n * The spinner is the kit's {@link Spinner}, drawn inside the button with `label={null}`\n * so the button's name stays its text; `aria-busy` is what says it is working.\n */\nexport function FormActions({\n onCancel,\n onSubmit,\n submitLabel,\n cancelLabel,\n pendingLabel,\n pending = false,\n submitDisabled = false,\n submitDisabledReason,\n submitVariant = \"brand\",\n destructive,\n align,\n placement = \"inline\",\n form,\n children,\n className,\n style,\n ...rest\n}: FormActionsProps) {\n const labels = useKitLabels(\"form\", DEFAULT_FORM_ACTIONS_LABELS);\n const hasDestructive = destructive !== undefined && destructive !== null && destructive !== false;\n const justify = align ?? (hasDestructive ? \"between\" : \"end\");\n const start = !hasDestructive ? null : isDestructiveData(destructive) ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n tone=\"danger\"\n onClick={destructive.onClick}\n disabled={destructive.disabled}\n disabledReason={destructive.disabledReason}\n >\n {destructive.label}\n </Button>\n ) : (\n destructive\n );\n const saveLabel = submitLabel ?? labels.save;\n\n return (\n <div\n data-slot=\"form-actions\"\n data-placement={placement}\n {...rest}\n style={\n placement === \"sticky\"\n ? {\n paddingBottom: \"max(0.75rem, env(safe-area-inset-bottom))\",\n // On the phone's bottom nav, not behind it (keksdose live #361): `bottom-0`\n // pinned the row under AppShell's nav, which covers the page's last ~56px.\n // `--app-nav-h` is the nav's measured height (0px from md up, and outside an\n // AppShell); the 1px overlaps the two top borders into one line.\n bottom: \"max(0px, calc(var(--app-nav-h, 0px) - 1px))\",\n ...style,\n }\n : style\n }\n className={cn(\"flex flex-wrap items-center gap-2\", ALIGN_CLASS[justify], PLACEMENT_CLASS[placement], className)}\n >\n {start && <div className=\"me-auto flex items-center gap-2\">{start}</div>}\n <div className=\"flex flex-wrap items-center gap-2\">\n {children}\n {onCancel && (\n <Button type=\"button\" variant=\"secondary\" onClick={onCancel}>\n {cancelLabel ?? labels.cancel}\n </Button>\n )}\n <Button\n type={onSubmit ? \"button\" : \"submit\"}\n form={form}\n onClick={onSubmit}\n variant={submitVariant}\n disabled={pending || submitDisabled}\n disabledReason={pending ? undefined : submitDisabledReason}\n aria-busy={pending || undefined}\n >\n {pending && <Spinner label={null} className=\"size-4\" />}\n {pending && pendingLabel !== undefined ? pendingLabel : saveLabel}\n </Button>\n </div>\n </div>\n );\n}\n"],"mappings":";AA4II,cA2CI,YA3CJ;AA5IJ,SAAS,sBAAsB;AAE/B,SAAS,UAAU;AACnB,SAAS,oBAAoB;AAC7B,SAAS,QAAQ,eAAmC;AAQ7C,MAAM,8BAAiD;AAAA,EAC5D,MAAM;AAAA,EACN,QAAQ;AACV;AAwBA,MAAM,cAAgD;AAAA,EACpD,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,KAAK;AAAA,EACL,SAAS;AACX;AAEA,MAAM,kBAAwD;AAAA,EAC5D,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AACV;AAgDA,SAAS,kBAAkB,OAAiD;AAC1E,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,eAAe,KAAK,KAAK,aAAa;AAC/F;AAiBO,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV,iBAAiB;AAAA,EACjB;AAAA,EACA,gBAAgB;AAAA,EAChB;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAqB;AACnB,QAAM,SAAS,aAAa,QAAQ,2BAA2B;AAC/D,QAAM,iBAAiB,gBAAgB,UAAa,gBAAgB,QAAQ,gBAAgB;AAC5F,QAAM,UAAU,UAAU,iBAAiB,YAAY;AACvD,QAAM,QAAQ,CAAC,iBAAiB,OAAO,kBAAkB,WAAW,IAClE;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,SAAQ;AAAA,MACR,MAAK;AAAA,MACL,SAAS,YAAY;AAAA,MACrB,UAAU,YAAY;AAAA,MACtB,gBAAgB,YAAY;AAAA,MAE3B,sBAAY;AAAA;AAAA,EACf,IAEA;AAEF,QAAM,YAAY,eAAe,OAAO;AAExC,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,kBAAgB;AAAA,MACf,GAAG;AAAA,MACJ,OACE,cAAc,WACV;AAAA,QACE,eAAe;AAAA;AAAA;AAAA;AAAA;AAAA,QAKf,QAAQ;AAAA,QACR,GAAG;AAAA,MACL,IACA;AAAA,MAEN,WAAW,GAAG,qCAAqC,YAAY,OAAO,GAAG,gBAAgB,SAAS,GAAG,SAAS;AAAA,MAE7G;AAAA,iBAAS,oBAAC,SAAI,WAAU,mCAAmC,iBAAM;AAAA,QAClE,qBAAC,SAAI,WAAU,qCACZ;AAAA;AAAA,UACA,YACC,oBAAC,UAAO,MAAK,UAAS,SAAQ,aAAY,SAAS,UAChD,yBAAe,OAAO,QACzB;AAAA,UAEF;AAAA,YAAC;AAAA;AAAA,cACC,MAAM,WAAW,WAAW;AAAA,cAC5B;AAAA,cACA,SAAS;AAAA,cACT,SAAS;AAAA,cACT,UAAU,WAAW;AAAA,cACrB,gBAAgB,UAAU,SAAY;AAAA,cACtC,aAAW,WAAW;AAAA,cAErB;AAAA,2BAAW,oBAAC,WAAQ,OAAO,MAAM,WAAU,UAAS;AAAA,gBACpD,WAAW,iBAAiB,SAAY,eAAe;AAAA;AAAA;AAAA,UAC1D;AAAA,WACF;AAAA;AAAA;AAAA,EACF;AAEJ;","names":[]}
@@ -2,7 +2,7 @@ import * as react from 'react';
2
2
  import { ComponentProps, Ref } from 'react';
3
3
  import { AmountInput } from './amount-input.js';
4
4
  import './calculator.js';
5
- import '../kit-labels-BHfRBbHs.js';
5
+ import '../kit-labels-CnnBd-2N.js';
6
6
  import './data-table-labels.js';
7
7
  import './mini-calendar.js';
8
8
  import './calendar-heatmap.js';
@@ -2,7 +2,7 @@ import * as react from 'react';
2
2
  import { ComponentProps, ReactNode } from 'react';
3
3
  import { NumberInput } from './number-input.js';
4
4
  import './calculator.js';
5
- import '../kit-labels-BHfRBbHs.js';
5
+ import '../kit-labels-CnnBd-2N.js';
6
6
  import './data-table-labels.js';
7
7
  import './mini-calendar.js';
8
8
  import './calendar-heatmap.js';
@@ -2,7 +2,7 @@ import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
3
  import { CalculatorButtonLabels } from './calculator.js';
4
4
  import { NumberPadSheetLabels } from './numpad-sheet.js';
5
- import '../kit-labels-BHfRBbHs.js';
5
+ import '../kit-labels-CnnBd-2N.js';
6
6
  import './data-table-labels.js';
7
7
  import './mini-calendar.js';
8
8
  import './calendar-heatmap.js';
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { useId, useRef, useState } from "react";
3
+ import { useId, useLayoutEffect, useRef, useState } from "react";
4
4
  import { CalculatorButton } from "./calculator.js";
5
5
  import { NumberPadSheet } from "./numpad-sheet.js";
6
6
  import { FIELD_BASE, FIELD_DISPLAY, FIELD_INVALID, FLOATING_INPUT_CLASS, FloatingField, PHONE_QUERY } from "./ui.js";
@@ -59,6 +59,19 @@ function NumberInput({
59
59
  const asDisplay = isMobile && variant === "display";
60
60
  const showCalc = calculator && !disabled && !isMobile;
61
61
  const [focused, setFocused] = useState(false);
62
+ const endRef = useRef(null);
63
+ const [endWidth, setEndWidth] = useState(null);
64
+ const hasEnd = showCalc || suffix !== void 0;
65
+ useLayoutEffect(() => {
66
+ const el = endRef.current;
67
+ if (!hasEnd || !el || typeof ResizeObserver === "undefined") return;
68
+ const observer = new ResizeObserver(() => {
69
+ const width = el.getBoundingClientRect().width;
70
+ setEndWidth(width > 0 ? Math.ceil(width) : null);
71
+ });
72
+ observer.observe(el);
73
+ return () => observer.disconnect();
74
+ }, [hasEnd]);
62
75
  const showNumpad = isMobile && !disabled && focused;
63
76
  const inputRef = useRef(null);
64
77
  const commit = () => {
@@ -117,13 +130,14 @@ function NumberInput({
117
130
  inputClassName,
118
131
  showCalc && suffix !== void 0 ? "pe-16" : showCalc ? "pe-9" : suffix !== void 0 ? "pe-8" : void 0,
119
132
  invalid && FIELD_INVALID
120
- )
133
+ ),
134
+ style: hasEnd && endWidth != null ? { paddingInlineEnd: endWidth + 4 } : void 0
121
135
  }
122
136
  ),
123
137
  (showCalc || suffix !== void 0) && // One flex track for both, so the unit and the calculator sit side by side
124
138
  // instead of stacking on the same corner — AmountInput's arrangement, which
125
139
  // has carried a chip and a calculator together since #430.
126
- /* @__PURE__ */ jsxs("div", { className: "absolute inset-y-0 end-0 flex items-center", children: [
140
+ /* @__PURE__ */ jsxs("div", { ref: endRef, className: "absolute inset-y-0 end-0 flex items-center", children: [
127
141
  showCalc && /* @__PURE__ */ jsx(
128
142
  CalculatorButton,
129
143
  {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/number-input.tsx"],"sourcesContent":["import { useId, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\nimport { CalculatorButton, type CalculatorButtonLabels } from \"./calculator\";\nimport { NumberPadSheet, type NumberPadSheetLabels } from \"./numpad-sheet\";\nimport { FIELD_BASE, FIELD_DISPLAY, FIELD_INVALID, FLOATING_INPUT_CLASS, FloatingField, PHONE_QUERY } from \"./ui\";\nimport { cn } from \"../lib/cn\";\nimport { commitExpression, formatResult, sanitizeLive } from \"../lib/calc\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\n\ninterface NumberInputProps {\n value: string;\n /** Fired on every keystroke with the raw text (comma → dot, operators kept so\n * a typed expression survives). */\n onChange: (value: string) => void;\n /** Fired on blur/Enter with the *evaluated* value — use this for save-on-blur\n * fields so the save always sees the resolved number, never \"10+5\". */\n onCommit?: (value: string) => void;\n label?: ReactNode;\n ariaLabel?: string;\n /** Names for the calculator this field renders — its trigger, and the controls\n * inside the popover — and for the numpad sheet it opens on a phone. Overrides\n * for THIS field only: both resolve the `calculator` namespace of\n * `<UiKitProvider labels>` themselves, then fall back to English.\n *\n * `pad` is not optional plumbing: this field renders a {@link NumberPadSheet}\n * exactly as {@link AmountInput} does, but had no way to pass it anything, so\n * the SAME keypad announced itself in German when a money field opened it and in\n * English when a goal target did. */\n labels?: {\n calculatorTrigger?: string;\n calculator?: CalculatorButtonLabels;\n pad?: NumberPadSheetLabels;\n };\n placeholder?: string;\n disabled?: boolean;\n autoFocus?: boolean;\n /** Wrapper class. */\n className?: string;\n /** Overrides the input's own styling (merged after the field base). */\n inputClassName?: string;\n id?: string;\n /** Show the calculator-popover trigger (default true). Set false for fields in\n * an ephemeral close-on-blur editor, where clicking the icon would blur away\n * and tear the editor down before the popover could open — inline typing\n * (e.g. \"200+50\" → Enter) still evaluates there. */\n calculator?: boolean;\n /** {@link FIELD_DISPLAY} — on a phone, the figure at display size with the field\n * chrome dropped. The currency-free half of the same treatment `AmountInput`\n * carries, for a dialog whose whole point is the one number (a budget goal's\n * target); not for a field among many, and never for the compact inline\n * editors this control also serves. */\n variant?: \"field\" | \"display\";\n /** A {@link FieldHint} \"?\" on the label's own line, beside the label rather\n * than at the far end of the strip — the far end is where the calculator\n * lives, and a hint placed there landed on top of it (steering-design\n * feedback #48). Plain TEXT (a string or a number) is a caption instead, UNDER\n * the field and attached through `aria-describedby` after the caller's own —\n * the same rule as {@link Select}'s `hint`: the label line has no room for a\n * sentence, and one placed there ran over the label and into the value. */\n hint?: ReactNode;\n /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */\n invalid?: boolean;\n /**\n * Set on the `<input>`, with `id` above. Declared so `Field`'s render-prop spread —\n * `{(ids) => <NumberInput {...ids} … />}` — is typed and reaches the input: the hint\n * and error are described, a required number is announced as required, and\n * `aria-invalid` paints {@link FIELD_INVALID} the way `invalid` does.\n */\n \"aria-describedby\"?: string;\n \"aria-invalid\"?: boolean | \"true\" | \"false\";\n \"aria-required\"?: boolean | \"true\" | \"false\";\n /**\n * A static unit shown at the field's right edge — \"%\", \"kg\", \"km/h\".\n *\n * The read-out half of {@link AmountInput}'s currency chip, and it exists for the\n * same reason that one does: a unit belongs to the FIELD, not to the text, so\n * typing it is a way to get it into the value. Keksdose had a percentage spelled\n * three different ways across three screens — `<Input type=\"number\" step=\"0.01\">`\n * for a loan rate, `step=\"0.1\"` for a tax rate, `inputMode=\"decimal\"` for a VAT\n * rate — and none of the three showed a \"%\" anywhere near the box, so what the\n * digits meant was a question the label alone had to answer.\n *\n * `aria-hidden`, like the currency read-out: it is a property of the field that\n * the label already names (\"Interest rate (%)\"), and announcing it again after\n * every value reads as part of the number.\n */\n suffix?: ReactNode;\n /**\n * Turns on the step keys: ArrowUp / ArrowDown add or subtract `step`, PageUp /\n * PageDown ten of them. The result lands on the grid `min + k × step` (or `0 + k ×\n * step` without a `min`), so 3.1 with `step={0.25}` goes up to 3.25, not 3.35 —\n * what a native number input does. Decimal-exact: `0.1 + 0.2` steps to \"0.3\".\n *\n * A step changes the text through `onChange` like a keystroke would; `onCommit`\n * still fires on blur/Enter. Without `step` the arrow keys move the caret as before.\n */\n step?: number;\n /** Bounds for the step keys only — a step never leaves `[min, max]`. This field's\n * value is a STRING, so typed text is not clamped: {@link NumberField} is the\n * numeric field that clamps what is typed as well. */\n min?: number;\n max?: number;\n}\n\n/** Decimal places of `n` as written (\"0.25\" → 2, \"1e-7\" → 7), for decimal-exact\n * stepping. */\nfunction decimalsOf(n: number): number {\n const text = formatResult(n);\n const dot = text.indexOf(\".\");\n return dot === -1 ? 0 : text.length - dot - 1;\n}\n\n/**\n * `current` moved by `count` steps (negative = down), on the grid `origin + k × step`\n * and clamped into `[min, max]`. An off-grid value moves to the NEXT grid point in\n * that direction first, so one press is never more than one step.\n *\n * The grid index is found by division and the result rebuilt as `origin + k × step`,\n * then cut to the decimals `step` and `origin` are written with — which is where\n * binary noise goes: `0.1 × 3` is 0.30000000000000004, and `toFixed(1)` of it is\n * \"0.3\". Summing steps would accumulate that noise press by press instead.\n */\nexport function stepNumber(\n current: number | null,\n count: number,\n step: number,\n min?: number,\n max?: number,\n): number {\n const clamp = (n: number) => Math.min(max ?? Infinity, Math.max(min ?? -Infinity, n));\n // Nothing to step from: land on the value nearest zero the bounds allow, rather\n // than on ±step — a room count with `min={1}` goes to 1, a rate to 0.\n if (current === null || !Number.isFinite(current)) return clamp(0);\n const origin = min !== undefined && Number.isFinite(min) ? min : 0;\n const raw = (current - origin) / step;\n const nearest = Math.round(raw);\n // \"On the grid\" within float tolerance: (0.3 - 0) / 0.1 is 2.9999999999999996.\n const onGrid = Math.abs(raw - nearest) < 1e-9;\n const k = onGrid\n ? nearest + count\n : count > 0\n ? Math.ceil(raw) + count - 1\n : Math.floor(raw) + count + 1;\n const places = Math.min(Math.max(decimalsOf(step), decimalsOf(origin)), 20);\n return clamp(Number((origin + k * step).toFixed(places)));\n}\n\n/**\n * A numeric text field with a built-in calculator: type a calculation straight\n * in (e.g. \"12+5\", evaluated on blur/Enter) or click the trailing calculator\n * icon for a keypad. String `value`/`onChange` contract, mirroring\n * {@link AmountInput} — the currency-free counterpart for budgets, goals,\n * invoice totals and split lines.\n */\nexport function NumberInput({\n value,\n onChange,\n onCommit,\n label,\n ariaLabel,\n labels,\n placeholder,\n disabled,\n autoFocus,\n className,\n inputClassName,\n id,\n calculator = true,\n variant = \"field\",\n hint,\n invalid: invalidProp,\n \"aria-describedby\": ariaDescribedBy,\n \"aria-invalid\": ariaInvalid,\n \"aria-required\": ariaRequired,\n suffix,\n step,\n min,\n max,\n}: NumberInputProps) {\n const generatedId = useId();\n const fieldId = id ?? generatedId;\n const hintId = useId();\n // Text is a caption under the field; a FieldHint rides the label line. See `hint`.\n const textHint = (typeof hint === \"string\" && hint !== \"\") || typeof hint === \"number\";\n const describedBy = textHint ? (ariaDescribedBy ? `${ariaDescribedBy} ${hintId}` : hintId) : ariaDescribedBy;\n const invalid = Boolean(invalidProp) || ariaInvalid === true || ariaInvalid === \"true\";\n const labelled = label !== undefined;\n // On phones we suppress the OS keyboard (inputMode=\"none\" below) for our own\n // calculator numpad, so the desktop popover trigger is hidden (feedback #334).\n const isMobile = useMediaQuery(PHONE_QUERY, false);\n const asDisplay = isMobile && variant === \"display\";\n const showCalc = calculator && !disabled && !isMobile;\n const [focused, setFocused] = useState(false);\n // On mobile, focusing opens the numpad bottom sheet instead of the native\n // keyboard (#334); the ref lets its \"Done\" blur → commit + close.\n const showNumpad = isMobile && !disabled && focused;\n const inputRef = useRef<HTMLInputElement>(null);\n\n const commit = () => {\n const next = commitExpression(value);\n if (next !== value) onChange(next);\n onCommit?.(next);\n };\n\n const stepBy = (count: number) => {\n // The step starts from the text as a commit would read it, so \"12+5\" steps from 17.\n const resolved = commitExpression(value).trim();\n const n = resolved === \"\" ? NaN : Number(resolved);\n const next = formatResult(stepNumber(Number.isFinite(n) ? n : null, count, step as number, min, max));\n if (next !== value) onChange(next);\n };\n\n const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === \"Enter\") return commit();\n // A zero, negative or non-finite step is no step: the keys stay the caret's.\n if (!step || !(step > 0) || !Number.isFinite(step) || e.altKey || e.ctrlKey || e.metaKey) return;\n const count =\n e.key === \"ArrowUp\" ? 1 : e.key === \"ArrowDown\" ? -1 : e.key === \"PageUp\" ? 10 : e.key === \"PageDown\" ? -10 : 0;\n if (count === 0) return;\n // Otherwise ArrowUp/Down also jump the caret to the start/end of the text.\n e.preventDefault();\n stepBy(count);\n };\n\n const field = (\n <FloatingField\n className={cn(\"w-full\", className)}\n htmlFor={fieldId}\n label={label}\n srOnlyLabel={asDisplay}\n hint={textHint ? undefined : hint}\n >\n <input\n ref={inputRef}\n id={fieldId}\n aria-label={ariaLabel}\n type=\"text\"\n // Mobile: suppress the OS keyboard so the numpad sheet owns entry (field\n // keeps focus/caret); desktop keeps the native decimal keypad.\n inputMode={isMobile ? \"none\" : \"decimal\"}\n autoComplete=\"off\"\n // eslint-disable-next-line jsx-a11y/no-autofocus -- a documented prop the caller opts into (off by default); the field never takes focus on its own.\n autoFocus={autoFocus}\n disabled={disabled}\n // Display mode has no floating label to feed the blank-placeholder trick,\n // and an empty borderless figure shows nothing — so it keeps whatever\n // placeholder the caller gave, falling back to a zero to aim at.\n placeholder={asDisplay ? (placeholder ?? \"0\") : labelled ? \" \" : placeholder}\n value={value}\n onChange={(e) => onChange(sanitizeLive(e.target.value))}\n onFocus={() => setFocused(true)}\n onBlur={() => {\n commit();\n setFocused(false);\n }}\n onKeyDown={onKeyDown}\n aria-invalid={invalid || undefined}\n aria-describedby={describedBy}\n aria-required={ariaRequired}\n // The end padding sits last so it always wins over an inputClassName that\n // sets its own px. Both trailing controls can be on at once, so the room\n // they need is reserved together rather than by whichever happens to render:\n // the calculator is ~36px and a short unit ~24px, and a field that reserved\n // only one of them would let the digits run under the other.\n className={cn(\n asDisplay\n ? cn(FIELD_DISPLAY, \"text-4xl font-semibold leading-tight tracking-tight tabular-nums\")\n : labelled\n ? FLOATING_INPUT_CLASS\n : FIELD_BASE,\n inputClassName,\n showCalc && suffix !== undefined ? \"pe-16\" : showCalc ? \"pe-9\" : suffix !== undefined ? \"pe-8\" : undefined,\n invalid && FIELD_INVALID,\n )}\n />\n {(showCalc || suffix !== undefined) && (\n // One flex track for both, so the unit and the calculator sit side by side\n // instead of stacking on the same corner — AmountInput's arrangement, which\n // has carried a chip and a calculator together since #430.\n <div className=\"absolute inset-y-0 end-0 flex items-center\">\n {showCalc && (\n <CalculatorButton\n value={value}\n onChange={onChange}\n className=\"self-stretch px-2.5\"\n ariaLabel={labels?.calculatorTrigger}\n labels={labels?.calculator}\n />\n )}\n {suffix !== undefined && (\n <span\n aria-hidden\n className=\"pointer-events-none pe-3 text-xs font-medium text-[var(--text-muted)]\"\n >\n {suffix}\n </span>\n )}\n </div>\n )}\n {showNumpad && (\n <NumberPadSheet\n value={value}\n onChange={onChange}\n onDone={() => inputRef.current?.blur()}\n label={label}\n labels={labels?.pad}\n />\n )}\n </FloatingField>\n );\n if (!textHint) return field;\n // Outside the field's own `relative` box, as Select's caption is: the calculator\n // and the unit are `inset-y-0` in it and would stretch down over a second line.\n // `className` stays on the field, so adding a caption cannot change what it styles.\n return (\n <div>\n {field}\n <p id={hintId} className=\"mt-1 text-[11px] leading-tight text-[var(--text-muted)]\">\n {hint}\n </p>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/number-input.tsx"],"sourcesContent":["import { useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\nimport { CalculatorButton, type CalculatorButtonLabels } from \"./calculator\";\nimport { NumberPadSheet, type NumberPadSheetLabels } from \"./numpad-sheet\";\nimport { FIELD_BASE, FIELD_DISPLAY, FIELD_INVALID, FLOATING_INPUT_CLASS, FloatingField, PHONE_QUERY } from \"./ui\";\nimport { cn } from \"../lib/cn\";\nimport { commitExpression, formatResult, sanitizeLive } from \"../lib/calc\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\n\ninterface NumberInputProps {\n value: string;\n /** Fired on every keystroke with the raw text (comma → dot, operators kept so\n * a typed expression survives). */\n onChange: (value: string) => void;\n /** Fired on blur/Enter with the *evaluated* value — use this for save-on-blur\n * fields so the save always sees the resolved number, never \"10+5\". */\n onCommit?: (value: string) => void;\n label?: ReactNode;\n ariaLabel?: string;\n /** Names for the calculator this field renders — its trigger, and the controls\n * inside the popover — and for the numpad sheet it opens on a phone. Overrides\n * for THIS field only: both resolve the `calculator` namespace of\n * `<UiKitProvider labels>` themselves, then fall back to English.\n *\n * `pad` is not optional plumbing: this field renders a {@link NumberPadSheet}\n * exactly as {@link AmountInput} does, but had no way to pass it anything, so\n * the SAME keypad announced itself in German when a money field opened it and in\n * English when a goal target did. */\n labels?: {\n calculatorTrigger?: string;\n calculator?: CalculatorButtonLabels;\n pad?: NumberPadSheetLabels;\n };\n placeholder?: string;\n disabled?: boolean;\n autoFocus?: boolean;\n /** Wrapper class. */\n className?: string;\n /** Overrides the input's own styling (merged after the field base). */\n inputClassName?: string;\n id?: string;\n /** Show the calculator-popover trigger (default true). Set false for fields in\n * an ephemeral close-on-blur editor, where clicking the icon would blur away\n * and tear the editor down before the popover could open — inline typing\n * (e.g. \"200+50\" → Enter) still evaluates there. */\n calculator?: boolean;\n /** {@link FIELD_DISPLAY} — on a phone, the figure at display size with the field\n * chrome dropped. The currency-free half of the same treatment `AmountInput`\n * carries, for a dialog whose whole point is the one number (a budget goal's\n * target); not for a field among many, and never for the compact inline\n * editors this control also serves. */\n variant?: \"field\" | \"display\";\n /** A {@link FieldHint} \"?\" on the label's own line, beside the label rather\n * than at the far end of the strip — the far end is where the calculator\n * lives, and a hint placed there landed on top of it (steering-design\n * feedback #48). Plain TEXT (a string or a number) is a caption instead, UNDER\n * the field and attached through `aria-describedby` after the caller's own —\n * the same rule as {@link Select}'s `hint`: the label line has no room for a\n * sentence, and one placed there ran over the label and into the value. */\n hint?: ReactNode;\n /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */\n invalid?: boolean;\n /**\n * Set on the `<input>`, with `id` above. Declared so `Field`'s render-prop spread —\n * `{(ids) => <NumberInput {...ids} … />}` — is typed and reaches the input: the hint\n * and error are described, a required number is announced as required, and\n * `aria-invalid` paints {@link FIELD_INVALID} the way `invalid` does.\n */\n \"aria-describedby\"?: string;\n \"aria-invalid\"?: boolean | \"true\" | \"false\";\n \"aria-required\"?: boolean | \"true\" | \"false\";\n /**\n * A static unit shown at the field's right edge — \"%\", \"kg\", \"km/h\".\n *\n * The read-out half of {@link AmountInput}'s currency chip, and it exists for the\n * same reason that one does: a unit belongs to the FIELD, not to the text, so\n * typing it is a way to get it into the value. Keksdose had a percentage spelled\n * three different ways across three screens — `<Input type=\"number\" step=\"0.01\">`\n * for a loan rate, `step=\"0.1\"` for a tax rate, `inputMode=\"decimal\"` for a VAT\n * rate — and none of the three showed a \"%\" anywhere near the box, so what the\n * digits meant was a question the label alone had to answer.\n *\n * `aria-hidden`, like the currency read-out: it is a property of the field that\n * the label already names (\"Interest rate (%)\"), and announcing it again after\n * every value reads as part of the number.\n */\n suffix?: ReactNode;\n /**\n * Turns on the step keys: ArrowUp / ArrowDown add or subtract `step`, PageUp /\n * PageDown ten of them. The result lands on the grid `min + k × step` (or `0 + k ×\n * step` without a `min`), so 3.1 with `step={0.25}` goes up to 3.25, not 3.35 —\n * what a native number input does. Decimal-exact: `0.1 + 0.2` steps to \"0.3\".\n *\n * A step changes the text through `onChange` like a keystroke would; `onCommit`\n * still fires on blur/Enter. Without `step` the arrow keys move the caret as before.\n */\n step?: number;\n /** Bounds for the step keys only — a step never leaves `[min, max]`. This field's\n * value is a STRING, so typed text is not clamped: {@link NumberField} is the\n * numeric field that clamps what is typed as well. */\n min?: number;\n max?: number;\n}\n\n/** Decimal places of `n` as written (\"0.25\" → 2, \"1e-7\" → 7), for decimal-exact\n * stepping. */\nfunction decimalsOf(n: number): number {\n const text = formatResult(n);\n const dot = text.indexOf(\".\");\n return dot === -1 ? 0 : text.length - dot - 1;\n}\n\n/**\n * `current` moved by `count` steps (negative = down), on the grid `origin + k × step`\n * and clamped into `[min, max]`. An off-grid value moves to the NEXT grid point in\n * that direction first, so one press is never more than one step.\n *\n * The grid index is found by division and the result rebuilt as `origin + k × step`,\n * then cut to the decimals `step` and `origin` are written with — which is where\n * binary noise goes: `0.1 × 3` is 0.30000000000000004, and `toFixed(1)` of it is\n * \"0.3\". Summing steps would accumulate that noise press by press instead.\n */\nexport function stepNumber(\n current: number | null,\n count: number,\n step: number,\n min?: number,\n max?: number,\n): number {\n const clamp = (n: number) => Math.min(max ?? Infinity, Math.max(min ?? -Infinity, n));\n // Nothing to step from: land on the value nearest zero the bounds allow, rather\n // than on ±step — a room count with `min={1}` goes to 1, a rate to 0.\n if (current === null || !Number.isFinite(current)) return clamp(0);\n const origin = min !== undefined && Number.isFinite(min) ? min : 0;\n const raw = (current - origin) / step;\n const nearest = Math.round(raw);\n // \"On the grid\" within float tolerance: (0.3 - 0) / 0.1 is 2.9999999999999996.\n const onGrid = Math.abs(raw - nearest) < 1e-9;\n const k = onGrid\n ? nearest + count\n : count > 0\n ? Math.ceil(raw) + count - 1\n : Math.floor(raw) + count + 1;\n const places = Math.min(Math.max(decimalsOf(step), decimalsOf(origin)), 20);\n return clamp(Number((origin + k * step).toFixed(places)));\n}\n\n/**\n * A numeric text field with a built-in calculator: type a calculation straight\n * in (e.g. \"12+5\", evaluated on blur/Enter) or click the trailing calculator\n * icon for a keypad. String `value`/`onChange` contract, mirroring\n * {@link AmountInput} — the currency-free counterpart for budgets, goals,\n * invoice totals and split lines.\n */\nexport function NumberInput({\n value,\n onChange,\n onCommit,\n label,\n ariaLabel,\n labels,\n placeholder,\n disabled,\n autoFocus,\n className,\n inputClassName,\n id,\n calculator = true,\n variant = \"field\",\n hint,\n invalid: invalidProp,\n \"aria-describedby\": ariaDescribedBy,\n \"aria-invalid\": ariaInvalid,\n \"aria-required\": ariaRequired,\n suffix,\n step,\n min,\n max,\n}: NumberInputProps) {\n const generatedId = useId();\n const fieldId = id ?? generatedId;\n const hintId = useId();\n // Text is a caption under the field; a FieldHint rides the label line. See `hint`.\n const textHint = (typeof hint === \"string\" && hint !== \"\") || typeof hint === \"number\";\n const describedBy = textHint ? (ariaDescribedBy ? `${ariaDescribedBy} ${hintId}` : hintId) : ariaDescribedBy;\n const invalid = Boolean(invalidProp) || ariaInvalid === true || ariaInvalid === \"true\";\n const labelled = label !== undefined;\n // On phones we suppress the OS keyboard (inputMode=\"none\" below) for our own\n // calculator numpad, so the desktop popover trigger is hidden (feedback #334).\n const isMobile = useMediaQuery(PHONE_QUERY, false);\n const asDisplay = isMobile && variant === \"display\";\n const showCalc = calculator && !disabled && !isMobile;\n const [focused, setFocused] = useState(false);\n // The unit and the calculator's measured width, reserved as the field's end padding.\n const endRef = useRef<HTMLDivElement>(null);\n const [endWidth, setEndWidth] = useState<number | null>(null);\n const hasEnd = showCalc || suffix !== undefined;\n useLayoutEffect(() => {\n const el = endRef.current;\n if (!hasEnd || !el || typeof ResizeObserver === \"undefined\") return;\n // The first callback fires on `observe`; no layout (jsdom) keeps the class fallback.\n const observer = new ResizeObserver(() => {\n const width = el.getBoundingClientRect().width;\n setEndWidth(width > 0 ? Math.ceil(width) : null);\n });\n observer.observe(el);\n return () => observer.disconnect();\n }, [hasEnd]);\n // On mobile, focusing opens the numpad bottom sheet instead of the native\n // keyboard (#334); the ref lets its \"Done\" blur → commit + close.\n const showNumpad = isMobile && !disabled && focused;\n const inputRef = useRef<HTMLInputElement>(null);\n\n const commit = () => {\n const next = commitExpression(value);\n if (next !== value) onChange(next);\n onCommit?.(next);\n };\n\n const stepBy = (count: number) => {\n // The step starts from the text as a commit would read it, so \"12+5\" steps from 17.\n const resolved = commitExpression(value).trim();\n const n = resolved === \"\" ? NaN : Number(resolved);\n const next = formatResult(stepNumber(Number.isFinite(n) ? n : null, count, step as number, min, max));\n if (next !== value) onChange(next);\n };\n\n const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n if (e.key === \"Enter\") return commit();\n // A zero, negative or non-finite step is no step: the keys stay the caret's.\n if (!step || !(step > 0) || !Number.isFinite(step) || e.altKey || e.ctrlKey || e.metaKey) return;\n const count =\n e.key === \"ArrowUp\" ? 1 : e.key === \"ArrowDown\" ? -1 : e.key === \"PageUp\" ? 10 : e.key === \"PageDown\" ? -10 : 0;\n if (count === 0) return;\n // Otherwise ArrowUp/Down also jump the caret to the start/end of the text.\n e.preventDefault();\n stepBy(count);\n };\n\n const field = (\n <FloatingField\n className={cn(\"w-full\", className)}\n htmlFor={fieldId}\n label={label}\n srOnlyLabel={asDisplay}\n hint={textHint ? undefined : hint}\n >\n <input\n ref={inputRef}\n id={fieldId}\n aria-label={ariaLabel}\n type=\"text\"\n // Mobile: suppress the OS keyboard so the numpad sheet owns entry (field\n // keeps focus/caret); desktop keeps the native decimal keypad.\n inputMode={isMobile ? \"none\" : \"decimal\"}\n autoComplete=\"off\"\n // eslint-disable-next-line jsx-a11y/no-autofocus -- a documented prop the caller opts into (off by default); the field never takes focus on its own.\n autoFocus={autoFocus}\n disabled={disabled}\n // Display mode has no floating label to feed the blank-placeholder trick,\n // and an empty borderless figure shows nothing — so it keeps whatever\n // placeholder the caller gave, falling back to a zero to aim at.\n placeholder={asDisplay ? (placeholder ?? \"0\") : labelled ? \" \" : placeholder}\n value={value}\n onChange={(e) => onChange(sanitizeLive(e.target.value))}\n onFocus={() => setFocused(true)}\n onBlur={() => {\n commit();\n setFocused(false);\n }}\n onKeyDown={onKeyDown}\n aria-invalid={invalid || undefined}\n aria-describedby={describedBy}\n aria-required={ariaRequired}\n // The end padding sits last so it always wins over an inputClassName that\n // sets its own px. Both trailing controls can be on at once, so the room\n // they need is reserved together rather than by whichever happens to render:\n // the calculator is ~36px and a short unit ~24px, and a field that reserved\n // only one of them would let the digits run under the other.\n className={cn(\n asDisplay\n ? cn(FIELD_DISPLAY, \"text-4xl font-semibold leading-tight tracking-tight tabular-nums\")\n : labelled\n ? FLOATING_INPUT_CLASS\n : FIELD_BASE,\n inputClassName,\n showCalc && suffix !== undefined ? \"pe-16\" : showCalc ? \"pe-9\" : suffix !== undefined ? \"pe-8\" : undefined,\n invalid && FIELD_INVALID,\n )}\n // The classes above are the fallback; once the end controls are measured the\n // field reserves exactly their width (+4px). A fixed pe-8 for any unit left a\n // \"%\" in a ~78px table cell 32px of padding and the digits ~32px of room\n // (keksdose live #367); a long unit (\"km/h\") got too little.\n style={hasEnd && endWidth != null ? { paddingInlineEnd: endWidth + 4 } : undefined}\n />\n {(showCalc || suffix !== undefined) && (\n // One flex track for both, so the unit and the calculator sit side by side\n // instead of stacking on the same corner — AmountInput's arrangement, which\n // has carried a chip and a calculator together since #430.\n <div ref={endRef} className=\"absolute inset-y-0 end-0 flex items-center\">\n {showCalc && (\n <CalculatorButton\n value={value}\n onChange={onChange}\n className=\"self-stretch px-2.5\"\n ariaLabel={labels?.calculatorTrigger}\n labels={labels?.calculator}\n />\n )}\n {suffix !== undefined && (\n <span\n aria-hidden\n className=\"pointer-events-none pe-3 text-xs font-medium text-[var(--text-muted)]\"\n >\n {suffix}\n </span>\n )}\n </div>\n )}\n {showNumpad && (\n <NumberPadSheet\n value={value}\n onChange={onChange}\n onDone={() => inputRef.current?.blur()}\n label={label}\n labels={labels?.pad}\n />\n )}\n </FloatingField>\n );\n if (!textHint) return field;\n // Outside the field's own `relative` box, as Select's caption is: the calculator\n // and the unit are `inset-y-0` in it and would stretch down over a second line.\n // `className` stays on the field, so adding a caption cannot change what it styles.\n return (\n <div>\n {field}\n <p id={hintId} className=\"mt-1 text-[11px] leading-tight text-[var(--text-muted)]\">\n {hint}\n </p>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
- import { C as CalculatorLabels } from '../kit-labels-BHfRBbHs.js';
3
+ import { C as CalculatorLabels } from '../kit-labels-CnnBd-2N.js';
4
4
  import './data-table-labels.js';
5
5
  import './mini-calendar.js';
6
6
  import './calendar-heatmap.js';
@@ -5,7 +5,58 @@ import { LegendEntry } from './toggle-legend.js';
5
5
  import { SeriesChartLabels } from './series-chart-labels.js';
6
6
  import { TimeTickUnit } from './series-chart-ticks.js';
7
7
  import { SeriesChartAxisBudget } from './series-chart-budget.js';
8
+ import { v as ChartTooltipPlacement } from '../kit-labels-CnnBd-2N.js';
8
9
  import 'recharts';
10
+ import './data-table-labels.js';
11
+ import './mini-calendar.js';
12
+ import './calendar-heatmap.js';
13
+ import './popover.js';
14
+ import './chip.js';
15
+ import 'lucide-react';
16
+ import './field-sync.js';
17
+ import './tooltip.js';
18
+ import '../hooks/use-anchored-rect.js';
19
+ import '../lib/clipping.js';
20
+ import '../wizard/types.js';
21
+ import '../tour/tour.js';
22
+ import '../search/command-palette.js';
23
+ import '../search/global-search.js';
24
+ import '../search/search-index.js';
25
+ import './month-picker.js';
26
+ import './page-contents.js';
27
+ import './pie-chart-labels.js';
28
+ import './sparkline.js';
29
+ import './stat-tile.js';
30
+ import './signature-pad.js';
31
+ import './password-strength.js';
32
+ import './danger-confirm.js';
33
+ import './swatch-picker.js';
34
+ import './tile-radio.js';
35
+ import './icon-picker.js';
36
+ import './dialog-frame.js';
37
+ import './modal.js';
38
+ import '../hooks/use-search-param-state.js';
39
+ import './measured-grid.js';
40
+ import '../feedback/feedback-attachment.js';
41
+ import '../feedback/feedback-dialog.js';
42
+ import '../feedback/feedback-thread.js';
43
+ import '../feedback/feedback-inbox.js';
44
+ import './account-settings-labels.js';
45
+ import './confirm-dialog.js';
46
+ import './floating-panel.js';
47
+ import './bulk-action-bar.js';
48
+ import './list.js';
49
+ import './breadcrumbs.js';
50
+ import './toast.js';
51
+ import './description-list.js';
52
+ import './line-items.js';
53
+ import './progress-bar.js';
54
+ import './signed-amount.js';
55
+ import './error-boundary.js';
56
+ import './authed-image.js';
57
+ import '../hooks/use-authed-src.js';
58
+ import './image-grid.js';
59
+ import './lightbox.js';
9
60
 
10
61
  interface SeriesChartSeries {
11
62
  /** The row key this line reads — and the `--color-<key>` it is painted with, so it
@@ -287,8 +338,35 @@ interface SeriesChartX {
287
338
  */
288
339
  integerTicks?: boolean;
289
340
  }
341
+ /** Where the tooltip goes — see {@link SeriesChartTooltip.placement}. */
342
+ type SeriesChartTooltipPlacement = ChartTooltipPlacement;
343
+ /**
344
+ * The placement a chart actually uses: its own, else the provider's, else `"cursor"`;
345
+ * `"auto"` is `"above"` on a phone (`phone`: below `sm`) and `"cursor"` from `sm` up.
346
+ */
347
+ declare function resolveTooltipPlacement(own: SeriesChartTooltipPlacement | undefined, fromProvider: SeriesChartTooltipPlacement | undefined, phone: boolean): "cursor" | "above" | "below";
290
348
  /** Room for the tooltip beyond the chart — see {@link SeriesChartProps.tooltip}. */
291
349
  interface SeriesChartTooltip {
350
+ /**
351
+ * Where the values under the pointer are shown (keksdose #359: on a phone the box
352
+ * sat under the finger, on the very data it was reporting).
353
+ * - `"cursor"` (the default): the floating box that follows the pointer.
354
+ * - `"above"` / `"below"`: a READOUT row — the same heading and values, flat — in
355
+ * room reserved over or under the plot from the first render, so nothing jumps on
356
+ * the first tap. It follows hover, a tap and the keyboard's stops; the cursor line
357
+ * and the active dot stay on the plot, and no box floats. Idle, it lists the series
358
+ * with "—" under the x axis' title. The row is taken out of the chart's `height`
359
+ * (the plot gets shorter, the page around it does not move).
360
+ * - `"auto"`: `"above"` below Tailwind's `sm` (640 px), `"cursor"` from there up.
361
+ *
362
+ * Default: the provider's `chartTooltipPlacement`, else `"cursor"`.
363
+ *
364
+ * The row is deliberately NOT an `aria-live` region: scrubbing a line announces a
365
+ * value per pixel of travel. A keyboard reader already hears each stop's name
366
+ * ("period — series: value") from the stop itself, and the row stays in the
367
+ * document for a screen reader to read on its way past.
368
+ */
369
+ placement?: SeriesChartTooltipPlacement;
292
370
  /** Let the tooltip run past the chart's own box on that axis instead of recharts
293
371
  * clamping it inside. Default: `{ x: true }` when `boundary` is set, else neither. */
294
372
  allowEscapeViewBox?: {
@@ -583,4 +661,4 @@ declare function StaticSeriesChart(props: SeriesChartProps): react.JSX.Element;
583
661
  */
584
662
  declare function SeriesChart(props: SeriesChartProps): react.JSX.Element;
585
663
 
586
- export { AXIS_TICK_WIDTH, AXIS_TITLE_STRIP, SeriesChart, type SeriesChartAxis, SeriesChartAxisBudget, type SeriesChartHit, type SeriesChartMarker, type SeriesChartPoint, type SeriesChartProps, type SeriesChartReference, type SeriesChartRow, type SeriesChartSeries, type SeriesChartSpan, type SeriesChartTickValues, type SeriesChartTone, type SeriesChartTooltip, type SeriesChartType, type SeriesChartX, type SeriesChartXTick, type SeriesChartXValue, type SeriesLegendAxes, type SeriesSource, StaticSeriesChart, TimeTickUnit, anchoredBand, axisBandWidth, mergeSeries, oneAxis, padBand, paddedDomain, seriesKey, seriesLegendEntries, soleSeriesColor, visibleSeries };
664
+ export { AXIS_TICK_WIDTH, AXIS_TITLE_STRIP, SeriesChart, type SeriesChartAxis, SeriesChartAxisBudget, type SeriesChartHit, type SeriesChartMarker, type SeriesChartPoint, type SeriesChartProps, type SeriesChartReference, type SeriesChartRow, type SeriesChartSeries, type SeriesChartSpan, type SeriesChartTickValues, type SeriesChartTone, type SeriesChartTooltip, type SeriesChartTooltipPlacement, type SeriesChartType, type SeriesChartX, type SeriesChartXTick, type SeriesChartXValue, type SeriesLegendAxes, type SeriesSource, StaticSeriesChart, TimeTickUnit, anchoredBand, axisBandWidth, mergeSeries, oneAxis, padBand, paddedDomain, resolveTooltipPlacement, seriesKey, seriesLegendEntries, soleSeriesColor, visibleSeries };
@@ -27,8 +27,18 @@ import { DEFAULT_SERIES_CHART_LABELS } from "./series-chart-labels.js";
27
27
  import { categoryTicks, integerTicks, niceTicks, timeTicksWithUnit } from "./series-chart-ticks.js";
28
28
  import { autoAxisBudget, axisUnit, budgetedAxes } from "./series-chart-budget.js";
29
29
  import { paletteFor } from "../theme/chart-palette.js";
30
- import { useKitLabels, useKitLocale } from "../i18n/kit-labels.js";
30
+ import {
31
+ useKitChartTooltipPlacement,
32
+ useKitLabels,
33
+ useKitLocale
34
+ } from "../i18n/kit-labels.js";
35
+ import { useMediaQuery } from "../hooks/use-media-query.js";
31
36
  import { cn } from "../lib/cn.js";
37
+ const TOOLTIP_READOUT_QUERY = "(max-width: 639px)";
38
+ function resolveTooltipPlacement(own, fromProvider, phone) {
39
+ const wanted = own ?? fromProvider ?? "cursor";
40
+ return wanted === "auto" ? phone ? "above" : "cursor" : wanted;
41
+ }
32
42
  const TONE_COLOR = {
33
43
  muted: "var(--text-muted)",
34
44
  brand: "var(--brand)",
@@ -351,6 +361,9 @@ function SeriesPlot({
351
361
  const number = useDefaultFormat(localeProp);
352
362
  const [keyStop, setKeyStop] = useState(0);
353
363
  const [keyFocus, setKeyFocus] = useState(void 0);
364
+ const phone = useMediaQuery(TOOLTIP_READOUT_QUERY, false);
365
+ const placement = resolveTooltipPlacement(tooltip?.placement, useKitChartTooltipPlacement(), phone);
366
+ const [readout, setReadout] = useState(null);
354
367
  const [box, setBox] = useState(null);
355
368
  const [width, setWidth] = useState(void 0);
356
369
  useLayoutEffect(() => {
@@ -428,15 +441,15 @@ function SeriesPlot({
428
441
  ...extend(
429
442
  void 0,
430
443
  [
431
- ...plotted.map((row) => finite(row[xKey])),
444
+ ...plotted.map((row2) => finite(row2[xKey])),
432
445
  ...xRefs.map((ref) => xPos(ref.value)),
433
446
  ...(markers ?? []).map((marker) => xPos(marker.x))
434
447
  ]
435
448
  ) ?? [Infinity, -Infinity]
436
449
  ));
437
450
  const timeUnit = model.kind === "time" ? timeTicksWithUnit(fittedX) : void 0;
438
- const integerX = model.kind === "number" && (x.integerTicks ?? plotted.every((row) => {
439
- const value = finite(row[xKey]);
451
+ const integerX = model.kind === "number" && (x.integerTicks ?? plotted.every((row2) => {
452
+ const value = finite(row2[xKey]);
440
453
  return value === void 0 || Number.isInteger(value);
441
454
  }));
442
455
  const xTicks = resolveTicks(x.tickValues, fittedX, xPos) ?? (model.kind === "category" ? categoryTicks(fittedX, rows.length) : model.kind === "time" ? timeUnit?.ticks : integerX ? integerTicks(fittedX) ?? niceTicks(fittedX) : niceTicks(fittedX));
@@ -468,8 +481,8 @@ function SeriesPlot({
468
481
  (entry) => (entry.axis ?? DEFAULT_Y_AXIS) === axis.id && (entry.type ?? "line") !== "line"
469
482
  );
470
483
  const wholeOnly = (axis) => axis.integerTicks ?? series.filter((entry) => (entry.axis ?? DEFAULT_Y_AXIS) === axis.id).every(
471
- (entry) => plotted.every((row) => {
472
- const value = finite(row[entry.key]);
484
+ (entry) => plotted.every((row2) => {
485
+ const value = finite(row2[entry.key]);
473
486
  return value === void 0 || Number.isInteger(value);
474
487
  })
475
488
  );
@@ -487,8 +500,8 @@ function SeriesPlot({
487
500
  };
488
501
  const xOf = (index) => model.kind === "category" ? rows[index][model.sourceKey] : model.valueAt(finite(plotted[index][xKey]) ?? NaN);
489
502
  const hitAt = (index, key) => {
490
- const row = rows[index];
491
- return row ? { index, row, x: xOf(index), key } : void 0;
503
+ const row2 = rows[index];
504
+ return row2 ? { index, row: row2, x: xOf(index), key } : void 0;
492
505
  };
493
506
  const stops = [];
494
507
  if (onPointClick) {
@@ -500,7 +513,7 @@ function SeriesPlot({
500
513
  };
501
514
  const bars = series.filter((entry) => entry.type === "bar");
502
515
  const [low, high] = fittedX ?? [-Infinity, Infinity];
503
- const placed = plotted.map((row, index) => ({ index, at: model.kind === "category" ? index : finite(row[xKey]) })).filter((slot) => slot.at !== void 0 && slot.at >= low && slot.at <= high).sort((a, b) => a.at - b.at);
516
+ const placed = plotted.map((row2, index) => ({ index, at: model.kind === "category" ? index : finite(row2[xKey]) })).filter((slot) => slot.at !== void 0 && slot.at >= low && slot.at <= high).sort((a, b) => a.at - b.at);
504
517
  placed.forEach(({ index, at }, k) => {
505
518
  const from = k > 0 ? (placed[k - 1].at + at) / 2 : low;
506
519
  const to = k < placed.length - 1 ? (placed[k + 1].at + at) / 2 : high;
@@ -564,14 +577,22 @@ function SeriesPlot({
564
577
  animationDuration: animationMs,
565
578
  animationEasing: "ease-out"
566
579
  };
567
- return /* @__PURE__ */ jsx(
580
+ const inRow = placement !== "cursor";
581
+ const chart = /* @__PURE__ */ jsx(
568
582
  ChartContainer,
569
583
  {
570
584
  ref: setBox,
571
585
  "data-axis-budget": budgeted.length ? budgetKey.replace(/\n/g, " ") : void 0,
572
586
  config,
573
- className: cn("w-full", heightClass, className, onPointClick && "cursor-pointer"),
574
- style: heightStyle,
587
+ className: cn(
588
+ "w-full",
589
+ // With a readout the chart's height is the whole block's, and the plot takes
590
+ // what the row leaves.
591
+ inRow ? "min-h-0 flex-1" : heightClass,
592
+ className,
593
+ onPointClick && "cursor-pointer"
594
+ ),
595
+ style: inRow ? void 0 : heightStyle,
575
596
  children: /* @__PURE__ */ jsxs(
576
597
  ComposedChart,
577
598
  {
@@ -645,7 +666,28 @@ function SeriesPlot({
645
666
  axis.id
646
667
  );
647
668
  }),
648
- /* @__PURE__ */ jsx(
669
+ inRow ? (
670
+ // Not before the row exists: without its own portal recharts would float this
671
+ // content over the plot for the first frame.
672
+ readout && /* @__PURE__ */ jsx(
673
+ ChartTooltip,
674
+ {
675
+ portal: readout,
676
+ wrapperStyle: { visibility: "visible" },
677
+ defaultIndex: focusedStop?.index,
678
+ content: /* @__PURE__ */ jsx(
679
+ ChartTooltipContent,
680
+ {
681
+ variant: "readout",
682
+ idleKeys: series.map((entry) => entry.key),
683
+ idleLabel: x.title || void 0,
684
+ labelFormatter: (value) => xLabel(Number(value)),
685
+ valueFormatter: valueFormat ?? number
686
+ }
687
+ )
688
+ }
689
+ )
690
+ ) : /* @__PURE__ */ jsx(
649
691
  ChartTooltip,
650
692
  {
651
693
  ...tooltip?.allowEscapeViewBox || tooltip?.boundary ? { allowEscapeViewBox: tooltip.allowEscapeViewBox ?? { x: true } } : {},
@@ -811,6 +853,24 @@ function SeriesPlot({
811
853
  )
812
854
  }
813
855
  );
856
+ if (!inRow) return chart;
857
+ const row = /* @__PURE__ */ jsx(
858
+ "div",
859
+ {
860
+ ref: setReadout,
861
+ "data-tooltip-readout": placement,
862
+ className: cn(
863
+ "shrink-0",
864
+ // Above, the heading line shares the top corner with the zoom's reset button.
865
+ placement === "above" ? "pb-2 [&_[data-readout-heading]]:pe-32" : "pt-2"
866
+ )
867
+ }
868
+ );
869
+ return /* @__PURE__ */ jsxs("div", { className: cn("flex w-full flex-col", heightClass), style: heightStyle, children: [
870
+ placement === "above" && row,
871
+ chart,
872
+ placement === "below" && row
873
+ ] });
814
874
  }
815
875
  const ZoomablePlot = withChartZoom(SeriesPlot);
816
876
  function plotProps(props) {
@@ -835,6 +895,7 @@ export {
835
895
  oneAxis,
836
896
  padBand,
837
897
  paddedDomain,
898
+ resolveTooltipPlacement,
838
899
  seriesKey,
839
900
  seriesLegendEntries,
840
901
  soleSeriesColor,