@eifi1/ui-kit 0.14.2 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/chart.d.ts +2 -1
  2. package/dist/components/alert-banner.js +5 -2
  3. package/dist/components/alert-banner.js.map +1 -1
  4. package/dist/components/amount-input.d.ts +1 -1
  5. package/dist/components/autocomplete.js +9 -2
  6. package/dist/components/autocomplete.js.map +1 -1
  7. package/dist/components/bulk-action-bar.js +6 -3
  8. package/dist/components/bulk-action-bar.js.map +1 -1
  9. package/dist/components/button-group.d.ts +1 -1
  10. package/dist/components/calculator.d.ts +1 -1
  11. package/dist/components/calendar-heatmap.js +5 -5
  12. package/dist/components/calendar-heatmap.js.map +1 -1
  13. package/dist/components/copy-button.d.ts +1 -1
  14. package/dist/components/data-table-cells.d.ts +1 -1
  15. package/dist/components/data-table-filter-popover.d.ts +1 -1
  16. package/dist/components/data-table-filter-popover.js +3 -3
  17. package/dist/components/data-table-filter-popover.js.map +1 -1
  18. package/dist/components/data-table-filters.d.ts +1 -1
  19. package/dist/components/data-table.d.ts +1 -1
  20. package/dist/components/data-table.js +12 -4
  21. package/dist/components/data-table.js.map +1 -1
  22. package/dist/components/facing-pair.d.ts +1 -0
  23. package/dist/components/field.js +4 -1
  24. package/dist/components/field.js.map +1 -1
  25. package/dist/components/file-button.d.ts +1 -1
  26. package/dist/components/file-dropzone.d.ts +1 -1
  27. package/dist/components/form-actions.d.ts +1 -1
  28. package/dist/components/image-grid.d.ts +8 -1
  29. package/dist/components/image-grid.js +16 -3
  30. package/dist/components/image-grid.js.map +1 -1
  31. package/dist/components/measured-grid.js +2 -2
  32. package/dist/components/measured-grid.js.map +1 -1
  33. package/dist/components/money-field.d.ts +1 -1
  34. package/dist/components/multi-select.js +3 -0
  35. package/dist/components/multi-select.js.map +1 -1
  36. package/dist/components/number-field.d.ts +1 -1
  37. package/dist/components/number-input.d.ts +5 -2
  38. package/dist/components/number-input.js +11 -3
  39. package/dist/components/number-input.js.map +1 -1
  40. package/dist/components/numpad-sheet.d.ts +1 -1
  41. package/dist/components/series-chart-budget.d.ts +60 -0
  42. package/dist/components/series-chart-budget.js +56 -0
  43. package/dist/components/series-chart-budget.js.map +1 -0
  44. package/dist/components/series-chart.d.ts +54 -2
  45. package/dist/components/series-chart.js +104 -20
  46. package/dist/components/series-chart.js.map +1 -1
  47. package/dist/components/settings-fields.d.ts +1 -1
  48. package/dist/components/slider.js +6 -2
  49. package/dist/components/slider.js.map +1 -1
  50. package/dist/components/stat-tile.js +3 -3
  51. package/dist/components/stat-tile.js.map +1 -1
  52. package/dist/components/text-link.d.ts +1 -1
  53. package/dist/components/time-input.d.ts +1 -1
  54. package/dist/components/treemap.d.ts +3 -2
  55. package/dist/components/treemap.js +14 -3
  56. package/dist/components/treemap.js.map +1 -1
  57. package/dist/components/ui.d.ts +1 -1
  58. package/dist/components/ui.js +44 -10
  59. package/dist/components/ui.js.map +1 -1
  60. package/dist/components/use-table-state.d.ts +1 -1
  61. package/dist/{data-table-Drun8Fk9.d.ts → data-table-CfVXtCeG.d.ts} +4 -3
  62. package/dist/data-table.d.ts +1 -1
  63. package/dist/feedback/feedback-thread.js +31 -12
  64. package/dist/feedback/feedback-thread.js.map +1 -1
  65. package/dist/hooks/use-file-drop.d.ts +1 -1
  66. package/dist/i18n/defaults.d.ts +1 -1
  67. package/dist/i18n/kit-labels.d.ts +1 -1
  68. package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
  69. package/dist/i18n/locales/de-CH.d.ts +1 -1
  70. package/dist/i18n/locales/de-informal.d.ts +1 -1
  71. package/dist/i18n/locales/de.d.ts +1 -1
  72. package/dist/i18n/locales/es.d.ts +1 -1
  73. package/dist/i18n/locales/fr.d.ts +1 -1
  74. package/dist/i18n/locales/hu.d.ts +1 -1
  75. package/dist/i18n/locales/it.d.ts +1 -1
  76. package/dist/i18n/locales/zh.d.ts +1 -1
  77. package/dist/index.d.ts +5 -4
  78. package/dist/{kit-labels-Mz8zZHIF.d.ts → kit-labels-BHfRBbHs.d.ts} +5 -1
  79. package/dist/lib/strip-fade.d.ts +31 -0
  80. package/dist/lib/strip-fade.js +54 -0
  81. package/dist/lib/strip-fade.js.map +1 -0
  82. package/dist/rhf/fields.d.ts +1 -1
  83. package/dist/rhf/form.d.ts +1 -1
  84. package/dist/rhf/form.js +1 -1
  85. package/dist/rhf/form.js.map +1 -1
  86. package/dist/rhf.d.ts +1 -1
  87. package/dist/shell/app-shell.d.ts +11 -2
  88. package/dist/shell/app-shell.js +20 -4
  89. package/dist/shell/app-shell.js.map +1 -1
  90. package/dist/shell/top-bar-brand.d.ts +1 -1
  91. package/dist/shell.d.ts +1 -1
  92. package/dist/wizard/stepper-nav.d.ts +1 -1
  93. package/dist/wizard/stepper-nav.js +14 -2
  94. package/dist/wizard/stepper-nav.js.map +1 -1
  95. package/dist/wizard.d.ts +1 -1
  96. package/package.json +1 -1
  97. package/src/components/alert-banner.tsx +10 -2
  98. package/src/components/autocomplete.tsx +10 -1
  99. package/src/components/bulk-action-bar.tsx +12 -3
  100. package/src/components/calendar-heatmap.tsx +10 -5
  101. package/src/components/data-table-filter-popover.tsx +6 -3
  102. package/src/components/data-table.tsx +21 -7
  103. package/src/components/field.tsx +4 -1
  104. package/src/components/image-grid.tsx +30 -2
  105. package/src/components/measured-grid.tsx +3 -2
  106. package/src/components/multi-select.tsx +3 -0
  107. package/src/components/number-input.tsx +23 -4
  108. package/src/components/series-chart-budget.ts +121 -0
  109. package/src/components/series-chart.tsx +201 -15
  110. package/src/components/slider.tsx +6 -2
  111. package/src/components/stat-tile.tsx +8 -3
  112. package/src/components/treemap.tsx +30 -5
  113. package/src/components/ui.tsx +91 -11
  114. package/src/feedback/feedback-thread.tsx +36 -13
  115. package/src/lib/strip-fade.ts +82 -0
  116. package/src/rhf/form.tsx +3 -1
  117. package/src/shell/app-shell.tsx +34 -7
  118. package/src/wizard/stepper-nav.tsx +23 -2
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/wizard/stepper-nav.tsx"],"sourcesContent":["import { useId, useMemo } from \"react\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { Check } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { Button, Spinner } from \"../components/ui\";\nimport type { ButtonVariant } from \"../components/ui\";\nimport { Modal } from \"../components/modal\";\nimport { alertFrameClass } from \"../components/alert-banner\";\nimport { WizardContextProvider } from \"./wizard-context\";\nimport { DEFAULT_WIZARD_LABELS } from \"./types\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport type { UseWizardReturn, WizardLabels } from \"./types\";\n\n/**\n * The chrome around a wizard: the step indicator, the step content, and the\n * Cancel / Back / Skip / Next / Finish bar — plus the cancel-confirmation\n * dialog, because \"are you sure you want to lose this\" is part of the wizard's\n * contract rather than something each app should re-decide.\n *\n * It is a component and not a set of parts for one reason: which button shows,\n * and whether it is enabled, is a function of `wizard` state that was getting\n * re-derived (and re-derived slightly differently) at every call site. Finish is\n * gated on `canFinish`, Next and Skip on `nextBlocked`, and Skip only appears on\n * an `optional` step that is not the last — that policy lives here now.\n *\n * The `<StepperNav>` is also what publishes {@link WizardContextProvider}, which\n * is how a mounted step registers its own validator. A step's\n * `useRhfWizardStep` / `useWizardStepValidate` therefore only works inside one.\n *\n * Which buttons show is read off `wizard` (`canCancel`, `canGoBack`, `canDone`,\n * `isCommitStep`), so the policy options — `cancellable`, `onExit`, `commits`,\n * `onDone` — are all set on `useWizard`. What is set HERE is how the Finish button\n * looks and what wraps it, because that is presentation and usually depends on\n * state the app holds (a \"replace everything\" toggle, a write lock).\n */\nexport function StepperNav<TData extends Record<string, unknown>>({\n wizard,\n children,\n title,\n description,\n className,\n labels,\n finishVariant = \"brand\",\n finishDisabled = false,\n doneDisabled = false,\n renderFinish,\n}: {\n wizard: UseWizardReturn<TData>;\n children: ReactNode;\n title?: ReactNode;\n description?: ReactNode;\n className?: string;\n labels?: Partial<WizardLabels>;\n /** The Finish button's variant — `\"danger\"` for a destructive commit (an import\n * that replaces what is there). Default `\"brand\"`. Next keeps `\"brand\"`. */\n finishVariant?: ButtonVariant;\n /** Disable Finish for a reason of the app's own, on top of the kit's gates\n * (`canFinish`, submitting, validating). */\n finishDisabled?: boolean;\n /**\n * Disable Done — the button of the last step after a commit — while the step still\n * has work of its own running. keksdose's YNAB import ends on a recurring step whose\n * convert loop writes one rule at a time (ynab-import-panel.tsx): leaving mid-loop\n * abandons the rest. Until now the panel dropped `onDone` for the duration, which\n * takes the button away instead of saying \"not yet\": disabled, it stays where the\n * eye left it and comes back to life when the loop ends.\n */\n doneDisabled?: boolean;\n /**\n * Wrap the Finish button: receives the kit's button element — already labelled,\n * gated and wired to `wizard.finish` — and returns what to render in its place.\n *\n * ```tsx\n * renderFinish={(button) => <SaveGuard lock={lock}>{button}</SaveGuard>}\n * ```\n *\n * A wrapper and not a replacement (`finishButton: (props) => …`) or a hook\n * (`onBeforeFinish`): the app's need is to put something AROUND the commit — a\n * write-lock tooltip, which has to sit on a wrapping element because a disabled\n * button receives no pointer events — while the kit keeps owning what the button\n * says and when it is enabled. A replacement would hand every app the gating to\n * re-derive (the problem this component exists to end); `onBeforeFinish` can veto\n * a click but cannot explain a disabled button. The element is a plain `<Button>`,\n * so a wrapper that clones it with `disabled` works as expected.\n */\n renderFinish?: (button: ReactElement) => ReactNode;\n}) {\n const l = useKitLabels(\"wizard\", DEFAULT_WIZARD_LABELS, labels);\n const cancelTitleId = useId();\n\n const contextValue = useMemo(\n () => ({\n registerStepValidate: wizard.registerStepValidate,\n setNextBlocked: wizard.setNextBlocked,\n }),\n [wizard.registerStepValidate, wizard.setNextBlocked],\n );\n\n // Finish is only valid once every step before the last has been completed or\n // skipped (derived once in useWizard). Prevents a restored last-step index\n // (empty data after a reload) from firing an empty submit.\n const canFinish = wizard.canFinish;\n\n // Pending while the step's validators run (an async server check): a spinner and\n // `aria-busy`, and disabled — the hook's own re-entry guard still refuses a second\n // press, this just stops it looking like the first one did nothing.\n const pending = wizard.isValidating;\n const stepNextLabel = wizard.currentStep.nextLabel;\n\n // Every branch below assigns it; no initial value to overwrite.\n let forwardButton: ReactNode;\n if (wizard.isCommitStep) {\n const finishButton = (\n <Button\n variant={finishVariant}\n data-tour=\"wizard-finish\"\n onClick={wizard.finish}\n disabled={wizard.isSubmitting || pending || !canFinish || finishDisabled}\n aria-busy={wizard.isSubmitting || pending || undefined}\n >\n {/* Decorative: the button's text is what a reader hears. */}\n {pending && <Spinner label={null} className=\"size-4\" />}\n {wizard.isSubmitting ? l.submitting : (stepNextLabel ?? l.finish)}\n </Button>\n );\n forwardButton = renderFinish ? renderFinish(finishButton) : finishButton;\n } else if (wizard.isLastStep) {\n // The last step after a commit. Nothing to finish; Done only when the app gave\n // an `onDone` — otherwise the step carries its own way out.\n forwardButton = wizard.canDone ? (\n <Button variant=\"brand\" data-tour=\"wizard-done\" onClick={wizard.done} disabled={doneDisabled}>\n {stepNextLabel ?? l.done ?? DEFAULT_WIZARD_LABELS.done}\n </Button>\n ) : null;\n } else {\n forwardButton = (\n <Button\n variant=\"brand\"\n data-tour=\"wizard-next\"\n onClick={wizard.goNext}\n disabled={wizard.nextBlocked || pending}\n aria-busy={pending || undefined}\n >\n {pending && <Spinner label={null} className=\"size-4\" />}\n {stepNextLabel ?? l.next}\n </Button>\n );\n }\n\n return (\n <div className={cn(\"flex flex-col gap-6\", className)}>\n {(title || description) && (\n <div>\n {title && (\n <h1 className=\"text-2xl font-bold tracking-tight text-[var(--text-primary)]\">\n {title}\n </h1>\n )}\n {description && (\n <p className=\"mt-1 text-sm text-[var(--text-muted)]\">{description}</p>\n )}\n </div>\n )}\n\n {/* Step indicator */}\n <nav aria-label={l.steps}>\n <ol className=\"flex w-full items-center\">\n {wizard.steps.map((step, index) => {\n const status = wizard.stepStatus(index);\n const isLast = index === wizard.steps.length - 1;\n\n return (\n <li key={step.id} className={cn(\"flex items-center\", !isLast && \"flex-1\")}>\n <button\n type=\"button\"\n onClick={() => wizard.goToStep(index)}\n // `canGoToStep` adds the commit lock: once past a committing step,\n // the steps up to it are done with and cannot be reopened.\n disabled={status === \"upcoming\" || !wizard.canGoToStep(index)}\n aria-current={status === \"active\" ? \"step\" : undefined}\n className={cn(\n \"group flex flex-col items-center gap-1.5\",\n status === \"upcoming\" ? \"cursor-default\" : \"cursor-pointer\",\n )}\n >\n {/* Circle */}\n <span\n className={cn(\n \"flex size-8 shrink-0 items-center justify-center rounded-full border-2 text-xs font-semibold transition-colors\",\n status === \"completed\" &&\n \"border-[var(--brand)] bg-[var(--brand)] text-[var(--brand-contrast)]\",\n status === \"active\" &&\n \"border-[var(--brand)] bg-[var(--bg-surface)] text-[var(--brand)]\",\n status === \"upcoming\" &&\n \"border-[var(--border)] bg-[var(--bg-surface)] text-[var(--text-placeholder)]\",\n status === \"skipped\" &&\n \"border-[var(--border)] bg-[var(--bg-surface-2)] text-[var(--text-muted)]\",\n )}\n >\n {status === \"completed\" ? <Check className=\"size-4\" /> : index + 1}\n </span>\n {/* Label — hidden on mobile, where the line below carries it */}\n <span\n className={cn(\n \"hidden text-xs font-medium md:block\",\n status === \"active\" || status === \"completed\"\n ? \"text-[var(--text-primary)]\"\n : \"text-[var(--text-muted)]\",\n )}\n >\n {step.label}\n </span>\n </button>\n\n {/* Connector line */}\n {!isLast && (\n <div\n className={cn(\n \"mx-2 h-0.5 flex-1\",\n status === \"completed\" ? \"bg-[var(--brand)]\" : \"bg-[var(--border)]\",\n )}\n />\n )}\n </li>\n );\n })}\n </ol>\n {/* Mobile: the labels above are hidden, so name the current step here */}\n <p className=\"mt-2 text-center text-sm font-medium text-[var(--text-primary)] md:hidden\">\n {l.step(wizard.currentStepIndex + 1, wizard.steps.length)}\n {\" — \"}\n {wizard.currentStep.label}\n </p>\n </nav>\n\n {/* Step content */}\n <WizardContextProvider value={contextValue}>\n <div className=\"min-h-[300px]\">{children}</div>\n </WizardContextProvider>\n\n {/* Submit error */}\n {wizard.error && (\n <div\n role=\"alert\"\n className={cn(\n \"text-sm text-[var(--danger)]\",\n alertFrameClass(\"danger\"),\n )}\n >\n {wizard.error}\n </div>\n )}\n\n {/* Navigation bar */}\n <div className=\"flex items-center justify-between border-t border-[var(--border)] pt-4\">\n {/* The empty span keeps the right-hand group on the right when Cancel is off. */}\n {wizard.canCancel ? (\n <Button variant=\"secondary\" onClick={wizard.cancel}>\n {l.cancel}\n </Button>\n ) : (\n <span />\n )}\n <div className=\"flex items-center gap-2\">\n {wizard.canGoBack && (\n // Disabled, not hidden, while the commit runs: it comes back if the commit\n // fails, and a button that vanishes mid-click is its own kind of surprise.\n <Button variant=\"secondary\" onClick={wizard.goBack} disabled={wizard.isSubmitting}>\n {l.back}\n </Button>\n )}\n {wizard.currentStep.optional && !wizard.isLastStep && !wizard.isCommitStep && (\n <Button\n variant=\"ghost\"\n onClick={wizard.skip}\n disabled={wizard.nextBlocked || wizard.isValidating}\n >\n {l.skip}\n </Button>\n )}\n {forwardButton}\n </div>\n </div>\n\n {/* Cancel confirmation */}\n {wizard.showCancelDialog && (\n <Modal onClose={wizard.dismissCancel} labelledBy={cancelTitleId} className=\"space-y-4\">\n <div className=\"space-y-1.5\">\n <h2 id={cancelTitleId} className=\"text-lg font-semibold text-[var(--text-primary)]\">\n {l.cancelTitle}\n </h2>\n <p className=\"text-sm text-[var(--text-muted)]\">{l.confirmCancel}</p>\n </div>\n <div className=\"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\">\n <Button variant=\"secondary\" onClick={wizard.dismissCancel}>\n {l.cancelDismissLabel}\n </Button>\n <Button variant=\"danger\" onClick={wizard.confirmCancel}>\n {l.cancelConfirmLabel}\n </Button>\n </div>\n </Modal>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/wizard/stepper-nav.tsx"],"sourcesContent":["import { useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { Check } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { Button, Spinner } from \"../components/ui\";\nimport type { ButtonVariant } from \"../components/ui\";\nimport { Modal } from \"../components/modal\";\nimport { alertFrameClass } from \"../components/alert-banner\";\nimport { WizardContextProvider } from \"./wizard-context\";\nimport { DEFAULT_WIZARD_LABELS } from \"./types\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport type { UseWizardReturn, WizardLabels } from \"./types\";\n\n/**\n * The chrome around a wizard: the step indicator, the step content, and the\n * Cancel / Back / Skip / Next / Finish bar — plus the cancel-confirmation\n * dialog, because \"are you sure you want to lose this\" is part of the wizard's\n * contract rather than something each app should re-decide.\n *\n * It is a component and not a set of parts for one reason: which button shows,\n * and whether it is enabled, is a function of `wizard` state that was getting\n * re-derived (and re-derived slightly differently) at every call site. Finish is\n * gated on `canFinish`, Next and Skip on `nextBlocked`, and Skip only appears on\n * an `optional` step that is not the last — that policy lives here now.\n *\n * The `<StepperNav>` is also what publishes {@link WizardContextProvider}, which\n * is how a mounted step registers its own validator. A step's\n * `useRhfWizardStep` / `useWizardStepValidate` therefore only works inside one.\n *\n * Which buttons show is read off `wizard` (`canCancel`, `canGoBack`, `canDone`,\n * `isCommitStep`), so the policy options — `cancellable`, `onExit`, `commits`,\n * `onDone` — are all set on `useWizard`. What is set HERE is how the Finish button\n * looks and what wraps it, because that is presentation and usually depends on\n * state the app holds (a \"replace everything\" toggle, a write lock).\n */\nexport function StepperNav<TData extends Record<string, unknown>>({\n wizard,\n children,\n title,\n description,\n className,\n labels,\n finishVariant = \"brand\",\n finishDisabled = false,\n doneDisabled = false,\n renderFinish,\n}: {\n wizard: UseWizardReturn<TData>;\n children: ReactNode;\n title?: ReactNode;\n description?: ReactNode;\n className?: string;\n labels?: Partial<WizardLabels>;\n /** The Finish button's variant — `\"danger\"` for a destructive commit (an import\n * that replaces what is there). Default `\"brand\"`. Next keeps `\"brand\"`. */\n finishVariant?: ButtonVariant;\n /** Disable Finish for a reason of the app's own, on top of the kit's gates\n * (`canFinish`, submitting, validating). */\n finishDisabled?: boolean;\n /**\n * Disable Done — the button of the last step after a commit — while the step still\n * has work of its own running. keksdose's YNAB import ends on a recurring step whose\n * convert loop writes one rule at a time (ynab-import-panel.tsx): leaving mid-loop\n * abandons the rest. Until now the panel dropped `onDone` for the duration, which\n * takes the button away instead of saying \"not yet\": disabled, it stays where the\n * eye left it and comes back to life when the loop ends.\n */\n doneDisabled?: boolean;\n /**\n * Wrap the Finish button: receives the kit's button element — already labelled,\n * gated and wired to `wizard.finish` — and returns what to render in its place.\n *\n * ```tsx\n * renderFinish={(button) => <SaveGuard lock={lock}>{button}</SaveGuard>}\n * ```\n *\n * A wrapper and not a replacement (`finishButton: (props) => …`) or a hook\n * (`onBeforeFinish`): the app's need is to put something AROUND the commit — a\n * write-lock tooltip, which has to sit on a wrapping element because a disabled\n * button receives no pointer events — while the kit keeps owning what the button\n * says and when it is enabled. A replacement would hand every app the gating to\n * re-derive (the problem this component exists to end); `onBeforeFinish` can veto\n * a click but cannot explain a disabled button. The element is a plain `<Button>`,\n * so a wrapper that clones it with `disabled` works as expected.\n */\n renderFinish?: (button: ReactElement) => ReactNode;\n}) {\n const l = useKitLabels(\"wizard\", DEFAULT_WIZARD_LABELS, labels);\n const cancelTitleId = useId();\n\n // The step area never shrinks below the tallest step shown so far, so the button\n // bar does not jump up and down as Next and Back move between a long step and a\n // short one. This replaces a fixed `min-h-[300px]` (carried over from Kastlan,\n // whose steps are all long forms), which did the same for steps under 300px but\n // left a short wizard — two fields a step — with a ~200px hole above its buttons.\n // Measured rather than guessed: the floor is whatever the app's own steps need.\n const contentRef = useRef<HTMLDivElement>(null);\n const [contentFloor, setContentFloor] = useState(0);\n useLayoutEffect(() => {\n const el = contentRef.current;\n if (!el || typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(() => {\n const h = el.offsetHeight;\n setContentFloor((floor) => (h > floor ? h : floor));\n });\n observer.observe(el);\n return () => observer.disconnect();\n }, []);\n\n const contextValue = useMemo(\n () => ({\n registerStepValidate: wizard.registerStepValidate,\n setNextBlocked: wizard.setNextBlocked,\n }),\n [wizard.registerStepValidate, wizard.setNextBlocked],\n );\n\n // Finish is only valid once every step before the last has been completed or\n // skipped (derived once in useWizard). Prevents a restored last-step index\n // (empty data after a reload) from firing an empty submit.\n const canFinish = wizard.canFinish;\n\n // Pending while the step's validators run (an async server check): a spinner and\n // `aria-busy`, and disabled — the hook's own re-entry guard still refuses a second\n // press, this just stops it looking like the first one did nothing.\n const pending = wizard.isValidating;\n const stepNextLabel = wizard.currentStep.nextLabel;\n\n // Every branch below assigns it; no initial value to overwrite.\n let forwardButton: ReactNode;\n if (wizard.isCommitStep) {\n const finishButton = (\n <Button\n variant={finishVariant}\n data-tour=\"wizard-finish\"\n onClick={wizard.finish}\n disabled={wizard.isSubmitting || pending || !canFinish || finishDisabled}\n aria-busy={wizard.isSubmitting || pending || undefined}\n >\n {/* Decorative: the button's text is what a reader hears. */}\n {pending && <Spinner label={null} className=\"size-4\" />}\n {wizard.isSubmitting ? l.submitting : (stepNextLabel ?? l.finish)}\n </Button>\n );\n forwardButton = renderFinish ? renderFinish(finishButton) : finishButton;\n } else if (wizard.isLastStep) {\n // The last step after a commit. Nothing to finish; Done only when the app gave\n // an `onDone` — otherwise the step carries its own way out.\n forwardButton = wizard.canDone ? (\n <Button variant=\"brand\" data-tour=\"wizard-done\" onClick={wizard.done} disabled={doneDisabled}>\n {stepNextLabel ?? l.done ?? DEFAULT_WIZARD_LABELS.done}\n </Button>\n ) : null;\n } else {\n forwardButton = (\n <Button\n variant=\"brand\"\n data-tour=\"wizard-next\"\n onClick={wizard.goNext}\n disabled={wizard.nextBlocked || pending}\n aria-busy={pending || undefined}\n >\n {pending && <Spinner label={null} className=\"size-4\" />}\n {stepNextLabel ?? l.next}\n </Button>\n );\n }\n\n return (\n <div className={cn(\"flex flex-col gap-6\", className)}>\n {(title || description) && (\n <div>\n {title && (\n <h1 className=\"text-2xl font-bold tracking-tight text-[var(--text-primary)]\">\n {title}\n </h1>\n )}\n {description && (\n <p className=\"mt-1 text-sm text-[var(--text-muted)]\">{description}</p>\n )}\n </div>\n )}\n\n {/* Step indicator */}\n <nav aria-label={l.steps}>\n <ol className=\"flex w-full items-center\">\n {wizard.steps.map((step, index) => {\n const status = wizard.stepStatus(index);\n const isLast = index === wizard.steps.length - 1;\n\n return (\n <li key={step.id} className={cn(\"flex items-center\", !isLast && \"flex-1\")}>\n <button\n type=\"button\"\n onClick={() => wizard.goToStep(index)}\n // `canGoToStep` adds the commit lock: once past a committing step,\n // the steps up to it are done with and cannot be reopened.\n disabled={status === \"upcoming\" || !wizard.canGoToStep(index)}\n aria-current={status === \"active\" ? \"step\" : undefined}\n className={cn(\n \"group flex flex-col items-center gap-1.5\",\n status === \"upcoming\" ? \"cursor-default\" : \"cursor-pointer\",\n )}\n >\n {/* Circle */}\n <span\n className={cn(\n \"flex size-8 shrink-0 items-center justify-center rounded-full border-2 text-xs font-semibold transition-colors\",\n status === \"completed\" &&\n \"border-[var(--brand)] bg-[var(--brand)] text-[var(--brand-contrast)]\",\n status === \"active\" &&\n \"border-[var(--brand)] bg-[var(--bg-surface)] text-[var(--brand)]\",\n status === \"upcoming\" &&\n \"border-[var(--border)] bg-[var(--bg-surface)] text-[var(--text-placeholder)]\",\n status === \"skipped\" &&\n \"border-[var(--border)] bg-[var(--bg-surface-2)] text-[var(--text-muted)]\",\n )}\n >\n {status === \"completed\" ? <Check className=\"size-4\" /> : index + 1}\n </span>\n {/* Label — hidden on mobile, where the line below carries it */}\n <span\n className={cn(\n \"hidden text-xs font-medium md:block\",\n status === \"active\" || status === \"completed\"\n ? \"text-[var(--text-primary)]\"\n : \"text-[var(--text-muted)]\",\n )}\n >\n {step.label}\n </span>\n </button>\n\n {/* Connector line */}\n {!isLast && (\n <div\n className={cn(\n \"mx-2 h-0.5 flex-1\",\n status === \"completed\" ? \"bg-[var(--brand)]\" : \"bg-[var(--border)]\",\n )}\n />\n )}\n </li>\n );\n })}\n </ol>\n {/* Mobile: the labels above are hidden, so name the current step here */}\n <p className=\"mt-2 text-center text-sm font-medium text-[var(--text-primary)] md:hidden\">\n {l.step(wizard.currentStepIndex + 1, wizard.steps.length)}\n {\" — \"}\n {wizard.currentStep.label}\n </p>\n </nav>\n\n {/* Step content */}\n <WizardContextProvider value={contextValue}>\n <div style={contentFloor > 0 ? { minHeight: contentFloor } : undefined}>\n <div ref={contentRef}>{children}</div>\n </div>\n </WizardContextProvider>\n\n {/* Submit error */}\n {wizard.error && (\n <div\n role=\"alert\"\n className={cn(\n \"text-sm text-[var(--danger)]\",\n alertFrameClass(\"danger\"),\n )}\n >\n {wizard.error}\n </div>\n )}\n\n {/* Navigation bar */}\n <div className=\"flex items-center justify-between border-t border-[var(--border)] pt-4\">\n {/* The empty span keeps the right-hand group on the right when Cancel is off. */}\n {wizard.canCancel ? (\n <Button variant=\"secondary\" onClick={wizard.cancel}>\n {l.cancel}\n </Button>\n ) : (\n <span />\n )}\n <div className=\"flex items-center gap-2\">\n {wizard.canGoBack && (\n // Disabled, not hidden, while the commit runs: it comes back if the commit\n // fails, and a button that vanishes mid-click is its own kind of surprise.\n <Button variant=\"secondary\" onClick={wizard.goBack} disabled={wizard.isSubmitting}>\n {l.back}\n </Button>\n )}\n {wizard.currentStep.optional && !wizard.isLastStep && !wizard.isCommitStep && (\n <Button\n variant=\"ghost\"\n onClick={wizard.skip}\n disabled={wizard.nextBlocked || wizard.isValidating}\n >\n {l.skip}\n </Button>\n )}\n {forwardButton}\n </div>\n </div>\n\n {/* Cancel confirmation */}\n {wizard.showCancelDialog && (\n <Modal onClose={wizard.dismissCancel} labelledBy={cancelTitleId} className=\"space-y-4\">\n <div className=\"space-y-1.5\">\n <h2 id={cancelTitleId} className=\"text-lg font-semibold text-[var(--text-primary)]\">\n {l.cancelTitle}\n </h2>\n <p className=\"text-sm text-[var(--text-muted)]\">{l.confirmCancel}</p>\n </div>\n <div className=\"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\">\n <Button variant=\"secondary\" onClick={wizard.dismissCancel}>\n {l.cancelDismissLabel}\n </Button>\n <Button variant=\"danger\" onClick={wizard.confirmCancel}>\n {l.cancelConfirmLabel}\n </Button>\n </div>\n </Modal>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
package/dist/wizard.d.ts CHANGED
@@ -6,7 +6,7 @@ export { WizardStep, WizardStepProps } from './wizard/wizard-step.js';
6
6
  export { WizardSummary } from './wizard/wizard-summary.js';
7
7
  export { StepperNav } from './wizard/stepper-nav.js';
8
8
  import 'react';
9
- import './kit-labels-Mz8zZHIF.js';
9
+ import './kit-labels-BHfRBbHs.js';
10
10
  import './components/data-table-labels.js';
11
11
  import './components/mini-calendar.js';
12
12
  import './components/calendar-heatmap.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eifi1/ui-kit",
3
- "version": "0.14.2",
3
+ "version": "0.15.1",
4
4
  "type": "module",
5
5
  "description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
6
6
  "_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
@@ -271,6 +271,13 @@ export function AlertBanner({
271
271
  inlineRow ? "inline-flex items-center" : strip ? "flex flex-wrap items-center gap-y-1" : "flex items-start",
272
272
  sm ? "gap-1.5 text-xs" : inline ? "gap-1.5 text-sm" : "gap-2 text-sm",
273
273
  );
274
+ // The strip's message claims 12rem before anything shrinks. `flex-1` is a 0 basis,
275
+ // which never makes a wrapping row wrap — the action and the × squeezed the message
276
+ // into a three-word column on a phone instead. With a real basis the action drops to
277
+ // a second line once message + action no longer fit, and the message keeps the width.
278
+ const grow = strip ? "flex-[1_1_12rem]" : "flex-1";
279
+ // Inside a whole-row banner's button or link, which then carries `grow` itself.
280
+ const nested = interactive && (onDismiss !== undefined || (action !== undefined && action !== null));
274
281
  const frame = inline
275
282
  ? ""
276
283
  : cn(
@@ -283,7 +290,7 @@ export function AlertBanner({
283
290
  const content = (
284
291
  <>
285
292
  {glyph}
286
- <span className={cn(!inlineRow && "flex-1", "min-w-0 text-start")}>{children}</span>
293
+ <span className={cn(!inlineRow && (nested ? "flex-1" : grow), "min-w-0 text-start")}>{children}</span>
287
294
  {interactive && (
288
295
  <ChevronRight
289
296
  aria-hidden
@@ -346,7 +353,8 @@ export function AlertBanner({
346
353
  const split = dismiss !== null || trailing !== null;
347
354
  const actionClass = split
348
355
  ? cn(
349
- "flex min-w-0 flex-1 text-start",
356
+ "flex min-w-0 text-start",
357
+ grow,
350
358
  sm ? "gap-1.5" : "gap-2",
351
359
  inlineRow || strip ? "items-center" : "items-start",
352
360
  "outline-none after:absolute after:inset-0 after:content-['']",
@@ -251,8 +251,17 @@ function AutocompleteInner<V extends string | number = string>(
251
251
  <span
252
252
  aria-hidden
253
253
  className={cn(
254
- "pointer-events-none absolute top-1/2 flex -translate-y-1/2 text-[var(--text-muted)]",
254
+ "pointer-events-none absolute flex text-[var(--text-muted)]",
255
255
  small ? "start-2 [&>svg]:size-3.5" : "start-2.5 [&>svg]:size-4",
256
+ // Labelled, the icon sits on the VALUE's line — the one line box below
257
+ // FIELD_FLOATING_PAD's `pt-4` and the 1px border — not on the middle of
258
+ // the box. Centred on the box it rose into the top strip and sat on the
259
+ // label's first letters, which float at the same start inset. On the
260
+ // value line it is beside what it describes, and the label keeps the
261
+ // start edge every other label in the form's column shares.
262
+ hasLabel
263
+ ? "top-[calc(1rem+1px)] h-5 items-center"
264
+ : "top-1/2 -translate-y-1/2",
256
265
  )}
257
266
  >
258
267
  {icon}
@@ -277,7 +277,8 @@ export function BulkActionBar({
277
277
  <X />
278
278
  </IconButton>
279
279
  ) : (
280
- <Button type="button" variant="secondary" size="sm" onClick={onClear}>
280
+ // `ms-auto`: when the actions wrap, the way out still sits at the bar's end.
281
+ <Button type="button" variant="secondary" size="sm" onClick={onClear} className="ms-auto">
281
282
  {labels.clear}
282
283
  </Button>
283
284
  );
@@ -296,7 +297,11 @@ export function BulkActionBar({
296
297
  "sticky top-0 z-20 border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--brand)_8%,var(--bg-surface))] backdrop-blur",
297
298
  shown ? "animate-overlay" : "animate-overlay-out",
298
299
  );
299
- const rowClass = cn("flex items-center", floating ? "gap-1 px-2 py-1" : "gap-2 px-3 py-2");
300
+ // `flex-wrap`: on a phone the count, three actions and a text "Clear selection" are
301
+ // wider than the screen. Without wrapping the count (the one shrinkable item)
302
+ // collapsed to a sliver and the rest ran out of the bar; wrapped, the actions that do
303
+ // not fit move to a second line and every label stays whole.
304
+ const rowClass = cn("flex flex-wrap items-center", floating ? "gap-1 px-2 py-1" : "gap-2 px-3 py-2");
300
305
  const surfaceStyle = floating ? { ...FLOATING_STYLE, ...style } : style;
301
306
 
302
307
  const toolbar = (
@@ -324,7 +329,11 @@ export function BulkActionBar({
324
329
  className={hasPanel ? rowClass : cn(rowClass, surfaceClass, className)}
325
330
  >
326
331
  {floating && clear}
327
- <span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--text-primary)]">
332
+ {/* `flex-auto`, not `flex-1`: a zero basis told the wrapping that the count needs
333
+ no room, so it was the item squeezed to nothing instead of a line breaking.
334
+ Its own width is its basis now; `truncate` is left for a label too long for a
335
+ line on its own. */}
336
+ <span className="min-w-0 flex-auto truncate text-sm font-medium text-[var(--text-primary)]">
328
337
  {labels.selected(shownCount)}
329
338
  </span>
330
339
  {/* `contents`, so the caller's controls are flex items of the bar itself. */}
@@ -354,14 +354,19 @@ export function CalendarHeatmap({
354
354
 
355
355
  const weekdayLabels = useMemo(() => {
356
356
  const ref = new Date(2024, 0, 7 + weekStart); // 7 January 2024 was a Sunday
357
- return Array.from({ length: 7 }, (_, i) => {
357
+ const week = Array.from({ length: 7 }, (_, i) => {
358
358
  const d = new Date(ref);
359
359
  d.setDate(ref.getDate() + i);
360
- return {
361
- short: d.toLocaleDateString(locale, { weekday: "short" }),
362
- long: d.toLocaleDateString(locale, { weekday: "long" }),
363
- };
360
+ return d;
364
361
  });
362
+ const name = (d: Date, weekday: "short" | "narrow" | "long") => d.toLocaleDateString(locale, { weekday });
363
+ // The label column is 2rem at 10px type, room for about four characters. Most
364
+ // locales' "short" is three or four ("Thu", "jeu.", "czw."), but some have no
365
+ // abbreviation at all — Arabic's short Thursday IS "الخميس", which overran the box
366
+ // and was cut. Such a locale takes the "narrow" form ("خ") for ALL seven, so the
367
+ // column never mixes two forms; the full name is on the aria-label either way.
368
+ const tooLong = week.some((d) => [...name(d, "short")].length > 4);
369
+ return week.map((d) => ({ short: name(d, tooLong ? "narrow" : "short"), long: name(d, "long") }));
365
370
  }, [locale, weekStart]);
366
371
 
367
372
  const lead = days.length ? weekdayIndex(days[0], weekStart) : 0;
@@ -163,9 +163,12 @@ export function FilterPopover<T>({
163
163
  const set = (from: string, to: string) => onChange({ type: "date", from, to });
164
164
  const presets = dateRangePresets();
165
165
  const isActivePreset = (p: { from: string; to: string }) => v.from === p.from && v.to === p.to;
166
+ // Side by side, the 7rem preset column leaves a phone's calendar and its two
167
+ // native date inputs ~180px, which clips "mm/dd/yyyy" mid-placeholder. Below `sm`
168
+ // the presets wrap as a row above the calendar, which then gets the panel's width.
166
169
  return (
167
- <div className="flex gap-3">
168
- <ul className="flex w-28 shrink-0 flex-col gap-0.5 text-xs">
170
+ <div className="flex flex-col gap-3 sm:flex-row">
171
+ <ul className="flex flex-wrap gap-0.5 text-xs sm:w-28 sm:shrink-0 sm:flex-col sm:flex-nowrap">
169
172
  {presets.map((p) => {
170
173
  const active = isActivePreset(p);
171
174
  return (
@@ -174,7 +177,7 @@ export function FilterPopover<T>({
174
177
  type="button"
175
178
  onClick={() => set(p.from, p.to)}
176
179
  className={cn(
177
- "block w-full rounded px-2 py-1 text-left transition-colors",
180
+ "block rounded px-2 py-1 text-start transition-colors sm:w-full",
178
181
  active
179
182
  ? "bg-[var(--brand-bg)] text-[var(--brand-muted)]"
180
183
  : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]",
@@ -247,9 +247,10 @@ export interface DataTableProps<T> {
247
247
  */
248
248
  urlSync?: TableUrlSync;
249
249
  /**
250
- * When true (desktop only), the table fills its parent's height and scrolls
251
- * INTERNALLY, with the header pinned — so the page itself doesn't add a second,
252
- * outer scrollbar. The parent must give it a bounded height (e.g. a flex
250
+ * When true, the table fills its parent's height and scrolls INTERNALLY, with the
251
+ * header pinned — so the page itself doesn't add a second, outer scrollbar. On a
252
+ * phone the card list is the scroller instead, with the filter button pinned above
253
+ * it; an unbounded parent simply lets either layout grow to its content. The parent must give it a bounded height (e.g. a flex
253
254
  * column inside the viewport-locked app shell). See feedback #207.
254
255
  */
255
256
  fillHeight?: boolean;
@@ -1634,7 +1635,10 @@ export function DataTable<T>({
1634
1635
  className={cn(
1635
1636
  "group/table",
1636
1637
  frame ? "overflow-clip" : "min-w-0",
1637
- fillHeight && "md:flex md:flex-1 md:flex-col md:min-h-0",
1638
+ // Both layouts, not just the table: the phone card list overflowing a bounded
1639
+ // pane drew itself over whatever followed it, and in a viewport-locked shell
1640
+ // the rows below the fold could not be reached at all.
1641
+ fillHeight && "flex flex-1 flex-col min-h-0",
1638
1642
  className,
1639
1643
  )}
1640
1644
  >
@@ -1658,7 +1662,7 @@ export function DataTable<T>({
1658
1662
  </div>
1659
1663
  )}
1660
1664
  {!isMdUp && (
1661
- <div ref={mobileRootRef}>
1665
+ <div ref={mobileRootRef} className={cn(fillHeight && "flex min-h-0 flex-1 flex-col")}>
1662
1666
  {/* Mobile filter access (feedback #299): the per-column filter popovers
1663
1667
  live in the desktop header, which the card list doesn't render — so
1664
1668
  expose the same filters through a bottom sheet here. */}
@@ -1679,6 +1683,11 @@ export function DataTable<T>({
1679
1683
  <ul
1680
1684
  className={cn(
1681
1685
  "divide-y divide-[var(--border)]",
1686
+ // Under `fillHeight` the list is the scroller, as the table body is on a
1687
+ // wide screen: the filter button stays above it and a server pager below.
1688
+ // The endless-scroll sentinel still fires in here — an
1689
+ // IntersectionObserver on the viewport sees through the clipping ancestor.
1690
+ fillHeight && "min-h-0 flex-1 overflow-y-auto overscroll-contain",
1682
1691
  // Same busy treatment as the desktop body; see `isLoading` below.
1683
1692
  isLoading && mobileSlice.length > 0 && "opacity-60 transition-opacity",
1684
1693
  )}
@@ -1711,7 +1720,8 @@ export function DataTable<T>({
1711
1720
  ref={loadMoreRef}
1712
1721
  className="px-4 py-4 text-center text-xs text-[var(--text-placeholder)]"
1713
1722
  >
1714
- {labels.loading}
1723
+ {/* Isolated for the reason spelled out on LoadingText. */}
1724
+ <bdi>{labels.loading}</bdi>
1715
1725
  </li>
1716
1726
  )}
1717
1727
  </ul>
@@ -2287,10 +2297,14 @@ function PlainRoot(props: React.HTMLAttributes<HTMLDivElement>) {
2287
2297
 
2288
2298
  /** The loading row's content: a spinner beside the word, the word for everyone. */
2289
2299
  function LoadingText({ label }: { label: string }) {
2300
+ // `<bdi>`: the label takes its direction from its own letters, not the table's. The
2301
+ // English default inside a `dir="rtl"` table otherwise had its trailing "…" — a
2302
+ // neutral with no letter after it — resolved right-to-left and drawn in front of
2303
+ // the word: "…Loading". An Arabic or Hebrew label still reads right-to-left.
2290
2304
  return (
2291
2305
  <span className="inline-flex items-center gap-2">
2292
2306
  <Spinner label={null} className="size-4" />
2293
- {label}
2307
+ <bdi>{label}</bdi>
2294
2308
  </span>
2295
2309
  );
2296
2310
  }
@@ -150,7 +150,10 @@ export function Field({
150
150
  <div
151
151
  data-slot="field"
152
152
  className={cn(
153
- "grid gap-1.5",
153
+ // `content-start`: in a grid row next to a taller item (one with a hint), a
154
+ // stretched Field would hand the extra height to its rows — the label grew,
155
+ // and the control sat lower than its neighbour's (showcase audit, #/forms).
156
+ "grid content-start gap-1.5",
154
157
  // A hidden label is `position: absolute`; `relative` keeps its containing
155
158
  // block here rather than the page's (see sr-only-containment.test).
156
159
  labelVisibility !== "visible" && "relative",
@@ -70,6 +70,18 @@ const ASPECT_CLASS = {
70
70
  auto: "aspect-[4/3]",
71
71
  } as const;
72
72
 
73
+ /**
74
+ * A file name as stem and extension, so a tile can cut the stem and keep the
75
+ * extension whole: "floor-plan-level-2.p / df" (a break inside the extension) told the
76
+ * reader less than "floor-plan-le….pdf" does. A leading dot (".env") or an
77
+ * "extension" longer than five characters is not treated as one.
78
+ */
79
+ export function splitFileName(name: string): [stem: string, ext: string] {
80
+ const dot = name.lastIndexOf(".");
81
+ if (dot <= 0 || name.length - dot > 6) return [name, ""];
82
+ return [name.slice(0, dot), name.slice(dot)];
83
+ }
84
+
73
85
  /**
74
86
  * A grid of thumbnails that open in a {@link Lightbox} — kastlan's room and defect
75
87
  * photos (room-inspector, unit-rooms-tab) and its floor plans (unit-floor-plan), each
@@ -134,7 +146,7 @@ export function ImageGrid({
134
146
  {kind === "file" ? (
135
147
  <span className="flex h-full w-full flex-col items-center justify-center gap-1 p-2 text-center text-xs text-[var(--text-secondary)]">
136
148
  <FileText aria-hidden className="size-6 shrink-0 text-[var(--text-muted)]" />
137
- <span aria-hidden className="line-clamp-2 break-all">{item.fileName ?? item.alt}</span>
149
+ <FileNameLabel name={item.fileName ?? item.alt ?? ""} />
138
150
  </span>
139
151
  ) : (
140
152
  <AuthedImage
@@ -153,7 +165,12 @@ export function ImageGrid({
153
165
  <div
154
166
  role="group"
155
167
  aria-label={labels.actions(name)}
156
- className="absolute end-1 top-1 flex gap-1"
168
+ // A backdrop behind the whole group, in the inverse token the overlay
169
+ // buttons use: a translucent disc alone took most of its colour from
170
+ // the picture, and on a pale photo or the light file tile the glyphs
171
+ // washed out. Two layers of the inverse keep them readable over any
172
+ // picture, in both themes.
173
+ className="absolute end-1 top-1 flex gap-1 rounded-full bg-[color-mix(in_srgb,var(--bg-inverse)_40%,transparent)] p-0.5 backdrop-blur-sm"
157
174
  >
158
175
  {actions}
159
176
  </div>
@@ -179,3 +196,14 @@ export function ImageGrid({
179
196
  </>
180
197
  );
181
198
  }
199
+
200
+ /** One line: the stem truncates, the extension never does. */
201
+ function FileNameLabel({ name }: { name: string }) {
202
+ const [stem, ext] = splitFileName(name);
203
+ return (
204
+ <span aria-hidden className="flex max-w-full min-w-0">
205
+ <span className="min-w-0 truncate">{stem}</span>
206
+ {ext && <span className="shrink-0">{ext}</span>}
207
+ </span>
208
+ );
209
+ }
@@ -771,7 +771,8 @@ function CellGrid({
771
771
  <Eraser aria-hidden className="size-3.5" /> {labels.clear}
772
772
  </Button>
773
773
  <span className="text-[11px] tabular-nums text-[var(--text-muted)]">
774
- {labels.points(filled.length)}
774
+ {/* bdi: an LTR label ("3 points") in an RTL grid would reorder to "points 3". */}
775
+ <bdi>{labels.points(filled.length)}</bdi>
775
776
  </span>
776
777
  {problems > 0 && (
777
778
  <span className="text-[11px] text-[var(--danger)]">{labels.problems(problems)}</span>
@@ -845,7 +846,7 @@ function TextView({ columns, cells, onCells, disabled, mark, labels, labelledBy,
845
846
  <p className="mt-1 px-1 text-[11px] text-[var(--text-muted)]">
846
847
  {columns.map((column) => column.label).join(" · ")}
847
848
  {" — "}
848
- {labels.points(lines)}
849
+ <bdi>{labels.points(lines)}</bdi>
849
850
  </p>
850
851
  </div>
851
852
  );
@@ -210,6 +210,9 @@ export function MultiSelect({
210
210
  label !== undefined && FIELD_FLOATING_PAD,
211
211
  // FIELD_TRIGGER's hover would still light a trigger nothing can open.
212
212
  "disabled:hover:bg-[var(--bg-surface-2)]",
213
+ // Dimmed like EntityCombobox's and MultiEntityCombobox's disabled triggers;
214
+ // only the label was, so a disabled MultiSelect read as live (showcase audit).
215
+ "disabled:cursor-not-allowed disabled:opacity-50",
213
216
  field.isInvalid && FIELD_INVALID,
214
217
  )}
215
218
  >
@@ -53,7 +53,10 @@ interface NumberInputProps {
53
53
  /** A {@link FieldHint} "?" on the label's own line, beside the label rather
54
54
  * than at the far end of the strip — the far end is where the calculator
55
55
  * lives, and a hint placed there landed on top of it (steering-design
56
- * feedback #48). */
56
+ * feedback #48). Plain TEXT (a string or a number) is a caption instead, UNDER
57
+ * the field and attached through `aria-describedby` after the caller's own —
58
+ * the same rule as {@link Select}'s `hint`: the label line has no room for a
59
+ * sentence, and one placed there ran over the label and into the value. */
57
60
  hint?: ReactNode;
58
61
  /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */
59
62
  invalid?: boolean;
@@ -176,6 +179,10 @@ export function NumberInput({
176
179
  }: NumberInputProps) {
177
180
  const generatedId = useId();
178
181
  const fieldId = id ?? generatedId;
182
+ const hintId = useId();
183
+ // Text is a caption under the field; a FieldHint rides the label line. See `hint`.
184
+ const textHint = (typeof hint === "string" && hint !== "") || typeof hint === "number";
185
+ const describedBy = textHint ? (ariaDescribedBy ? `${ariaDescribedBy} ${hintId}` : hintId) : ariaDescribedBy;
179
186
  const invalid = Boolean(invalidProp) || ariaInvalid === true || ariaInvalid === "true";
180
187
  const labelled = label !== undefined;
181
188
  // On phones we suppress the OS keyboard (inputMode="none" below) for our own
@@ -215,13 +222,13 @@ export function NumberInput({
215
222
  stepBy(count);
216
223
  };
217
224
 
218
- return (
225
+ const field = (
219
226
  <FloatingField
220
227
  className={cn("w-full", className)}
221
228
  htmlFor={fieldId}
222
229
  label={label}
223
230
  srOnlyLabel={asDisplay}
224
- hint={hint}
231
+ hint={textHint ? undefined : hint}
225
232
  >
226
233
  <input
227
234
  ref={inputRef}
@@ -248,7 +255,7 @@ export function NumberInput({
248
255
  }}
249
256
  onKeyDown={onKeyDown}
250
257
  aria-invalid={invalid || undefined}
251
- aria-describedby={ariaDescribedBy}
258
+ aria-describedby={describedBy}
252
259
  aria-required={ariaRequired}
253
260
  // The end padding sits last so it always wins over an inputClassName that
254
261
  // sets its own px. Both trailing controls can be on at once, so the room
@@ -301,4 +308,16 @@ export function NumberInput({
301
308
  )}
302
309
  </FloatingField>
303
310
  );
311
+ if (!textHint) return field;
312
+ // Outside the field's own `relative` box, as Select's caption is: the calculator
313
+ // and the unit are `inset-y-0` in it and would stretch down over a second line.
314
+ // `className` stays on the field, so adding a caption cannot change what it styles.
315
+ return (
316
+ <div>
317
+ {field}
318
+ <p id={hintId} className="mt-1 text-[11px] leading-tight text-[var(--text-muted)]">
319
+ {hint}
320
+ </p>
321
+ </div>
322
+ );
304
323
  }
@@ -0,0 +1,121 @@
1
+ // The axis budget of a series chart: which of its y axes are drawn, when there are more
2
+ // than the width can carry. Pure, and apart from the chart, so the rule is tested on
3
+ // numbers rather than on a jsdom that has no layout to measure.
4
+ //
5
+ // lenkbank's curve plot declares four visible axes — velocity, position and acceleration
6
+ // on the left, loads on the right — and every one reserves its band of ticks and title.
7
+ // At 1280 px that is a quarter of the card; at 390 px it is all of it, and the plot the
8
+ // axes are for is ten pixels wide. An axis over budget is drawn the way a `hide: true`
9
+ // one always was: it still scales its lines (and the zoom still refits it), but it draws
10
+ // no ticks, no title, and reserves no width.
11
+
12
+ /**
13
+ * How many y axes a chart draws. A number caps the TOTAL; `{ left, right }` caps each
14
+ * side (a side left out is not capped). Axes over the cap are hidden in declaration
15
+ * order — the first of each side is the last to go, see {@link budgetedAxes}.
16
+ */
17
+ export type SeriesChartAxisBudget = number | { left?: number; right?: number };
18
+
19
+ /** The part of an axis the budget reads. */
20
+ interface BudgetAxis {
21
+ id: string;
22
+ orientation?: "left" | "right";
23
+ hide?: boolean;
24
+ title?: string;
25
+ width?: number;
26
+ }
27
+
28
+ /**
29
+ * The narrowest the plot may get before the automatic budget steps in, in px.
30
+ *
31
+ * About a phone's plot with one axis a side (390 px viewport, card padding, two bands
32
+ * of 48 + 16), where a curve's shape still reads. It is the point below which the chart
33
+ * is already broken today — a plot the width of a thumb between four columns of numbers
34
+ * — so the rule changes nothing that was working.
35
+ */
36
+ export const MIN_PLOT_WIDTH = 160;
37
+
38
+ /** The budget the automatic rule applies: one axis a side. */
39
+ export const NARROW_AXIS_BUDGET: SeriesChartAxisBudget = { left: 1, right: 1 };
40
+
41
+ /** The plot's margin on a side no axis band covers — `SeriesPlot`'s own. */
42
+ const BARE_SIDE_MARGIN = 10;
43
+
44
+ const sideOf = (axis: BudgetAxis) => axis.orientation ?? "left";
45
+
46
+ /**
47
+ * The ids of the VISIBLE axes a budget hides — axes already `hide: true` are neither
48
+ * counted nor returned. Empty for no budget.
49
+ *
50
+ * Kept, in order of priority: the first visible axis of each side (in declaration order,
51
+ * so the axis a caller lists first — the one the grid hangs its rules off — always
52
+ * stays), then the rest in declaration order, until the cap. Per side, that is simply the
53
+ * first `left` left-hand axes and the first `right` right-hand ones.
54
+ */
55
+ export function budgetedAxes(
56
+ axes: readonly BudgetAxis[],
57
+ budget: SeriesChartAxisBudget | undefined,
58
+ ): string[] {
59
+ if (budget === undefined) return [];
60
+ const visible = axes.filter((axis) => !axis.hide);
61
+ if (typeof budget === "number") {
62
+ const cap = Math.max(0, Math.floor(budget));
63
+ const firsts = new Set<string>();
64
+ const seen = new Set<string>();
65
+ for (const axis of visible) {
66
+ if (!seen.has(sideOf(axis))) firsts.add(axis.id);
67
+ seen.add(sideOf(axis));
68
+ }
69
+ const priority = [
70
+ ...visible.filter((axis) => firsts.has(axis.id)),
71
+ ...visible.filter((axis) => !firsts.has(axis.id)),
72
+ ];
73
+ const kept = new Set(priority.slice(0, cap).map((axis) => axis.id));
74
+ return visible.filter((axis) => !kept.has(axis.id)).map((axis) => axis.id);
75
+ }
76
+ const used = { left: 0, right: 0 };
77
+ const out: string[] = [];
78
+ for (const axis of visible) {
79
+ const side = sideOf(axis);
80
+ const cap = budget[side];
81
+ if (cap !== undefined && used[side] >= Math.max(0, Math.floor(cap))) out.push(axis.id);
82
+ else used[side] += 1;
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * The automatic budget for a chart `width` px wide: {@link NARROW_AXIS_BUDGET} when the
89
+ * visible axes' bands would leave the plot under {@link MIN_PLOT_WIDTH} AND some side
90
+ * draws more than one axis — the only case one-a-side gives anything back. `undefined`
91
+ * otherwise, and for a width that is not known (no layout, no `ResizeObserver`).
92
+ *
93
+ * Decided on the CHART's width and the axes as declared, never on the plot the budget
94
+ * produced, so hiding an axis cannot widen the plot back over the line and bring it
95
+ * back: the rule has no feedback to oscillate on.
96
+ */
97
+ export function autoAxisBudget(
98
+ axes: readonly BudgetAxis[],
99
+ width: number | undefined,
100
+ bandWidth: (axis: BudgetAxis) => number,
101
+ ): SeriesChartAxisBudget | undefined {
102
+ if (width === undefined || !(width > 0)) return undefined;
103
+ const visible = axes.filter((axis) => !axis.hide);
104
+ const left = visible.filter((axis) => sideOf(axis) === "left");
105
+ const right = visible.filter((axis) => sideOf(axis) === "right");
106
+ if (left.length <= 1 && right.length <= 1) return undefined;
107
+ const bands = visible.reduce((sum, axis) => sum + bandWidth(axis), 0);
108
+ const margins = (left.length ? 0 : BARE_SIDE_MARGIN) + (right.length ? 0 : BARE_SIDE_MARGIN);
109
+ return width - bands - margins < MIN_PLOT_WIDTH ? NARROW_AXIS_BUDGET : undefined;
110
+ }
111
+
112
+ /**
113
+ * The unit an axis' series say when the axis is not drawn: its `unit`, or else the
114
+ * parenthesised tail of its title — "Load (N)" says "N", which is how the apps write
115
+ * every axis title. `undefined` when neither says one.
116
+ */
117
+ export function axisUnit(axis: { unit?: string; title?: string }): string | undefined {
118
+ if (axis.unit !== undefined) return axis.unit || undefined;
119
+ const tail = /\(([^()]+)\)\s*$/.exec(axis.title ?? "");
120
+ return tail?.[1].trim() || undefined;
121
+ }