@seekalfred/primitives 0.7.3 → 0.7.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 (91) hide show
  1. package/README.md +60 -1
  2. package/dist/alert/alert.d.ts +2 -2
  3. package/dist/alert-dialog/alert-dialog.d.ts +14 -0
  4. package/dist/alert-dialog/alert-dialog.js +51 -26
  5. package/dist/alert-dialog/alert-dialog.js.map +1 -1
  6. package/dist/announcement-bar/announcement-bar.d.ts +15 -4
  7. package/dist/announcement-bar/announcement-bar.js +31 -6
  8. package/dist/announcement-bar/announcement-bar.js.map +1 -1
  9. package/dist/avatar/avatar.d.ts +7 -0
  10. package/dist/avatar/avatar.js +33 -12
  11. package/dist/avatar/avatar.js.map +1 -1
  12. package/dist/badge/badge.d.ts +1 -1
  13. package/dist/bar-chart/bar-chart-series.d.ts +75 -0
  14. package/dist/bar-chart/bar-chart-series.js +401 -0
  15. package/dist/bar-chart/bar-chart-series.js.map +1 -0
  16. package/dist/bar-chart/bar-chart.d.ts +25 -1
  17. package/dist/bar-chart/bar-chart.js +26 -1
  18. package/dist/bar-chart/bar-chart.js.map +1 -1
  19. package/dist/bar-chart/index.d.ts +2 -1
  20. package/dist/button/button.d.ts +2 -2
  21. package/dist/chip-group/chip-group.d.ts +11 -0
  22. package/dist/chip-group/chip-group.js +50 -34
  23. package/dist/chip-group/chip-group.js.map +1 -1
  24. package/dist/chip-group/index.d.ts +2 -2
  25. package/dist/copy-button/copy-button.d.ts +19 -3
  26. package/dist/copy-button/copy-button.js +7 -4
  27. package/dist/copy-button/copy-button.js.map +1 -1
  28. package/dist/file-drop-zone/file-drop-zone.d.ts +29 -3
  29. package/dist/file-drop-zone/file-drop-zone.js +26 -8
  30. package/dist/file-drop-zone/file-drop-zone.js.map +1 -1
  31. package/dist/gauge/gauge.d.ts +18 -1
  32. package/dist/gauge/gauge.js +35 -21
  33. package/dist/gauge/gauge.js.map +1 -1
  34. package/dist/gauge/index.d.ts +2 -2
  35. package/dist/index.d.ts +8 -7
  36. package/dist/index.js +4 -4
  37. package/dist/input/input.d.ts +7 -0
  38. package/dist/input/input.js +3 -1
  39. package/dist/input/input.js.map +1 -1
  40. package/dist/lib/field-shell.js +2 -6
  41. package/dist/lib/field-shell.js.map +1 -1
  42. package/dist/lib/required-marker.js +20 -0
  43. package/dist/lib/required-marker.js.map +1 -0
  44. package/dist/line-chart/index.d.ts +2 -2
  45. package/dist/line-chart/line-chart.d.ts +105 -3
  46. package/dist/line-chart/line-chart.js +183 -44
  47. package/dist/line-chart/line-chart.js.map +1 -1
  48. package/dist/money-input/index.d.ts +2 -2
  49. package/dist/money-input/money-input.d.ts +14 -0
  50. package/dist/money-input/money-input.js +17 -7
  51. package/dist/money-input/money-input.js.map +1 -1
  52. package/dist/morph-select/morph-select.d.ts +8 -1
  53. package/dist/morph-select/morph-select.js +8 -5
  54. package/dist/morph-select/morph-select.js.map +1 -1
  55. package/dist/multi-select/multi-select.d.ts +31 -0
  56. package/dist/multi-select/multi-select.js +122 -5
  57. package/dist/multi-select/multi-select.js.map +1 -1
  58. package/dist/progress/index.d.ts +2 -2
  59. package/dist/progress/index.js +2 -2
  60. package/dist/progress/progress.d.ts +53 -2
  61. package/dist/progress/progress.js +146 -36
  62. package/dist/progress/progress.js.map +1 -1
  63. package/dist/radio-group/radio-group.d.ts +1 -1
  64. package/dist/scrub-input/scrub-input.d.ts +11 -1
  65. package/dist/scrub-input/scrub-input.js +22 -10
  66. package/dist/scrub-input/scrub-input.js.map +1 -1
  67. package/dist/select/select.d.ts +9 -0
  68. package/dist/select/select.js +3 -2
  69. package/dist/select/select.js.map +1 -1
  70. package/dist/sparkline/sparkline.d.ts +27 -2
  71. package/dist/sparkline/sparkline.js +115 -18
  72. package/dist/sparkline/sparkline.js.map +1 -1
  73. package/dist/stat-card/index.d.ts +2 -2
  74. package/dist/stat-card/index.js +2 -2
  75. package/dist/stat-card/stat-card.d.ts +32 -1
  76. package/dist/stat-card/stat-card.js +52 -9
  77. package/dist/stat-card/stat-card.js.map +1 -1
  78. package/dist/styles.css +1 -1
  79. package/dist/table/index.d.ts +2 -2
  80. package/dist/table/index.js +2 -2
  81. package/dist/table/table.d.ts +114 -4
  82. package/dist/table/table.js +374 -94
  83. package/dist/table/table.js.map +1 -1
  84. package/dist/text-shimmer/text-shimmer.d.ts +18 -2
  85. package/dist/text-shimmer/text-shimmer.js +5 -2
  86. package/dist/text-shimmer/text-shimmer.js.map +1 -1
  87. package/dist/textarea/textarea.d.ts +7 -0
  88. package/dist/textarea/textarea.js +3 -1
  89. package/dist/textarea/textarea.js.map +1 -1
  90. package/dist/toast/toast.d.ts +2 -2
  91. package/package.json +3 -3
@@ -2,9 +2,23 @@
2
2
  export interface CopyButtonProps {
3
3
  /** What goes on the clipboard. */
4
4
  value: string;
5
- /** The label at rest, and the button's accessible name. @default "Copy" */
5
+ /**
6
+ * The label at rest. Unless `aria-label` is set it is also the button's accessible name.
7
+ * @default "Copy"
8
+ */
6
9
  children?: string;
7
- /** Only the icon, a square button. The label is still its accessible name. */
10
+ /**
11
+ * An accessible name that differs from the visible label, for when the short label leans on its
12
+ * surroundings: visible "Copy" next to a code block, named "Copy code". It also prefixes the
13
+ * live-region announcement ("Copy code: Copied"). It stays constant while the visible label
14
+ * shows "Copied" or "Failed".
15
+ *
16
+ * Keep the visible label inside it, as written and ideally at the start ("Copy" → "Copy code",
17
+ * "Copy page" → "Copy page draft"), so a speech-input user can say what they see
18
+ * (WCAG 2.5.3 Label in Name). Unset or empty, the accessible name is `children`, as before.
19
+ */
20
+ "aria-label"?: string;
21
+ /** Only the icon, a square button. The label (or `aria-label`) is still its accessible name. */
8
22
  iconOnly?: boolean;
9
23
  /**
10
24
  * `plain` has no border until hovered. `primary` is the filled brand button, for a copy that is the
@@ -34,11 +48,13 @@ export interface CopyButtonProps {
34
48
  * its label cell reserves the widest of the three texts.
35
49
  *
36
50
  * It goes back to rest by itself after a moment, and a live region announces the
37
- * result. The accessible name is always the resting label.
51
+ * result. The accessible name is the resting label, or `aria-label` when the visible
52
+ * label is too short to stand alone; it never changes with the state.
38
53
  *
39
54
  * @example
40
55
  * <CopyButton value={token} />
41
56
  * <CopyButton value={url} iconOnly variant="plain" />
57
+ * <CopyButton value={code} aria-label="Copy code">Copy</CopyButton>
42
58
  * <CopyButton value={ticket} variant="primary" size="md" onError={() => toast("Copy failed")}>
43
59
  * Copy as ticket
44
60
  * </CopyButton>
@@ -82,17 +82,20 @@ function StateIcon({ state }) {
82
82
  * its label cell reserves the widest of the three texts.
83
83
  *
84
84
  * It goes back to rest by itself after a moment, and a live region announces the
85
- * result. The accessible name is always the resting label.
85
+ * result. The accessible name is the resting label, or `aria-label` when the visible
86
+ * label is too short to stand alone; it never changes with the state.
86
87
  *
87
88
  * @example
88
89
  * <CopyButton value={token} />
89
90
  * <CopyButton value={url} iconOnly variant="plain" />
91
+ * <CopyButton value={code} aria-label="Copy code">Copy</CopyButton>
90
92
  * <CopyButton value={ticket} variant="primary" size="md" onError={() => toast("Copy failed")}>
91
93
  * Copy as ticket
92
94
  * </CopyButton>
93
95
  */
94
- const CopyButton = forwardRef(function CopyButton({ value, children: label = "Copy", iconOnly = false, variant = "outline", size = "sm", isDisabled, onCopied, onError, className }, ref) {
96
+ const CopyButton = forwardRef(function CopyButton({ value, children: label = "Copy", "aria-label": ariaLabel, iconOnly = false, variant = "outline", size = "sm", isDisabled, onCopied, onError, className }, ref) {
95
97
  const { state, copy } = useCopyFeedback();
98
+ const name = ariaLabel || label;
96
99
  const text = state === "copied" ? "Copied" : state === "error" ? "Failed" : label;
97
100
  const pressStart = (event) => {
98
101
  event.target.style.setProperty("--press-scale", iconOnly ? "0.96" : "0.97");
@@ -106,7 +109,7 @@ const CopyButton = forwardRef(function CopyButton({ value, children: label = "Co
106
109
  ref,
107
110
  "data-slot": "button",
108
111
  "data-copy-state": state,
109
- "aria-label": label,
112
+ "aria-label": name,
110
113
  isDisabled,
111
114
  onPressStart: pressStart,
112
115
  onPress: () => {
@@ -146,7 +149,7 @@ const CopyButton = forwardRef(function CopyButton({ value, children: label = "Co
146
149
  className: "sr-only",
147
150
  role: "status",
148
151
  "aria-live": "polite",
149
- children: state === "idle" ? "" : state === "error" ? label + ": Could not copy" : label + ": Copied"
152
+ children: state === "idle" ? "" : state === "error" ? name + ": Could not copy" : name + ": Copied"
150
153
  })] });
151
154
  });
152
155
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"copy-button.js","names":["AriaButton"],"sources":["../../src/copy-button/copy-button.tsx"],"sourcesContent":["\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { Button as AriaButton, type PressEvent } from \"react-aria-components\";\n\nimport { buttonVariants } from \"../button/button.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { IconSwap } from \"../lib/icon-swap.js\";\nimport { MorphText } from \"../lib/morph-text.js\";\nimport { callSafely, useCopyFeedback, type CopyFeedbackState } from \"../lib/use-copy-feedback.js\";\n\nexport interface CopyButtonProps {\n /** What goes on the clipboard. */\n value: string;\n /** The label at rest, and the button's accessible name. @default \"Copy\" */\n children?: string;\n /** Only the icon, a square button. The label is still its accessible name. */\n iconOnly?: boolean;\n /**\n * `plain` has no border until hovered. `primary` is the filled brand button, for a copy that is the\n * main action of its row or dialog. @default \"outline\"\n */\n variant?: \"outline\" | \"plain\" | \"primary\";\n /**\n * Control height, from the density scale: `sm` sits in a row, `md` matches a default Button.\n * @default \"sm\"\n */\n size?: \"sm\" | \"md\";\n isDisabled?: boolean;\n /** Called once the text has reached the clipboard. */\n onCopied?: () => void;\n /**\n * Called when the clipboard refused the text, with what it rejected with. The button already shows\n * \"Failed\" and announces it; use this to keep a toast or log of your own.\n */\n onError?: (error: unknown) => void;\n /** Extra classes. Merged last, so they win conflicts with ours. */\n className?: string;\n}\n\nconst ICON = \"size-4\";\n\n/** CopyButton's variant names, mapped onto Button's. */\nconst BUTTON_VARIANT = {\n outline: \"outlineNeutral\",\n plain: \"ghost\",\n primary: \"primary\",\n} as const;\n\n/** An icon-only button is square: as wide as its rung is tall. */\nconst ICON_ONLY_WIDTH = {\n sm: \" w-(--density-control-h-sm) px-0\",\n md: \" w-(--density-control-h-md) px-0\",\n} as const;\n\n/** The result icon's tone on a page-surface button; at rest it takes the label's ink. */\nconst RESULT_TONE: Record<CopyFeedbackState, string | undefined> = {\n idle: undefined,\n copied: \"text-success-strong\",\n error: \"text-destructive-strong\",\n};\n\n/** The tick draws itself from its short stroke, the way a hand would write it: quick to start, then easing into place. */\nfunction DrawnCheck() {\n return (\n <svg\n className={ICON}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={1.75}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path\n d=\"M4 12l5 5L20 6\"\n pathLength={1}\n className=\"[stroke-dasharray:1] motion-safe:animate-check-draw-calm\"\n />\n </svg>\n );\n}\n\nfunction StateIcon({ state }: { state: CopyFeedbackState }) {\n if (state === \"copied\") return <DrawnCheck />;\n const common = {\n className: ICON,\n viewBox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n strokeWidth: 1.75,\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n \"aria-hidden\": true,\n } as const;\n if (state === \"error\") {\n return (\n <svg {...common}>\n <circle cx=\"12\" cy=\"12\" r=\"10\" />\n <path d=\"M12 8v4M12 16h.01\" />\n </svg>\n );\n }\n return (\n <svg {...common}>\n <rect x=\"8\" y=\"8\" width=\"14\" height=\"14\" rx=\"2\" />\n <path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\" />\n </svg>\n );\n}\n\n/**\n * Copies a value and says so, calmly. The icon trades places in one soft breath\n * (copy, then a tick that draws itself, or an alert if the clipboard refused)\n * while the label's changed letters rise in, and the button never changes width:\n * its label cell reserves the widest of the three texts.\n *\n * It goes back to rest by itself after a moment, and a live region announces the\n * result. The accessible name is always the resting label.\n *\n * @example\n * <CopyButton value={token} />\n * <CopyButton value={url} iconOnly variant=\"plain\" />\n * <CopyButton value={ticket} variant=\"primary\" size=\"md\" onError={() => toast(\"Copy failed\")}>\n * Copy as ticket\n * </CopyButton>\n */\nexport const CopyButton = forwardRef<HTMLButtonElement, CopyButtonProps>(function CopyButton(\n {\n value,\n children: label = \"Copy\",\n iconOnly = false,\n variant = \"outline\",\n size = \"sm\",\n isDisabled,\n onCopied,\n onError,\n className,\n },\n ref,\n) {\n const { state, copy } = useCopyFeedback();\n const text = state === \"copied\" ? \"Copied\" : state === \"error\" ? \"Failed\" : label;\n\n // Not width-based like Button's press: an icon button presses a little deeper, every other one the same.\n const pressStart = (event: PressEvent) => {\n (event.target as HTMLElement).style.setProperty(\"--press-scale\", iconOnly ? \"0.96\" : \"0.97\");\n };\n\n const classes = cn(\n buttonVariants({ variant: BUTTON_VARIANT[variant], size }) +\n \" [--release-duration:var(--duration-gentle)] [--release-ease:var(--ease-gentle)]\" +\n (iconOnly ? ICON_ONLY_WIDTH[size] : \"\"),\n className,\n );\n // On the brand fill the result icon keeps the label's ink: the success and destructive tones are set\n // against the page surface, not the orange.\n const onBrandFill = variant === \"primary\";\n\n return (\n <>\n <AriaButton\n ref={ref}\n data-slot=\"button\"\n data-copy-state={state}\n aria-label={label}\n isDisabled={isDisabled}\n onPressStart={pressStart}\n onPress={() => {\n void copy(value, onError).then((ok) => {\n if (ok) callSafely(() => onCopied?.());\n });\n }}\n className={classes}\n >\n <IconSwap iconKey={state} calm className={cn(ICON, \"relative\")}>\n <span className={onBrandFill ? undefined : RESULT_TONE[state]}>\n <StateIcon state={state} />\n </span>\n </IconSwap>\n {iconOnly ? null : (\n // The cell reserves the widest state, so nothing moves but the letters. The inline padding gives blurred\n // edge letters room before the horizontal clip; the negative margin keeps the box the same size.\n <span\n className=\"-mx-1 grid min-w-0 overflow-x-clip px-1 text-start whitespace-nowrap\"\n aria-hidden=\"true\"\n >\n {[label, \"Copied\", \"Failed\"].map((measure) => (\n <span key={measure} className=\"invisible col-start-1 row-start-1\">\n {measure}\n </span>\n ))}\n <span className=\"col-start-1 row-start-1 justify-self-start\">\n <MorphText text={text} morphWidth={false} calm />\n </span>\n </span>\n )}\n </AriaButton>\n <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n {state === \"idle\"\n ? \"\"\n : state === \"error\"\n ? label + \": Could not copy\"\n : label + \": Copied\"}\n </span>\n </>\n );\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"copy-button.js","names":["AriaButton"],"sources":["../../src/copy-button/copy-button.tsx"],"sourcesContent":["\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { Button as AriaButton, type PressEvent } from \"react-aria-components\";\n\nimport { buttonVariants } from \"../button/button.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { IconSwap } from \"../lib/icon-swap.js\";\nimport { MorphText } from \"../lib/morph-text.js\";\nimport { callSafely, useCopyFeedback, type CopyFeedbackState } from \"../lib/use-copy-feedback.js\";\n\nexport interface CopyButtonProps {\n /** What goes on the clipboard. */\n value: string;\n /**\n * The label at rest. Unless `aria-label` is set it is also the button's accessible name.\n * @default \"Copy\"\n */\n children?: string;\n /**\n * An accessible name that differs from the visible label, for when the short label leans on its\n * surroundings: visible \"Copy\" next to a code block, named \"Copy code\". It also prefixes the\n * live-region announcement (\"Copy code: Copied\"). It stays constant while the visible label\n * shows \"Copied\" or \"Failed\".\n *\n * Keep the visible label inside it, as written and ideally at the start (\"Copy\" → \"Copy code\",\n * \"Copy page\" → \"Copy page draft\"), so a speech-input user can say what they see\n * (WCAG 2.5.3 Label in Name). Unset or empty, the accessible name is `children`, as before.\n */\n \"aria-label\"?: string;\n /** Only the icon, a square button. The label (or `aria-label`) is still its accessible name. */\n iconOnly?: boolean;\n /**\n * `plain` has no border until hovered. `primary` is the filled brand button, for a copy that is the\n * main action of its row or dialog. @default \"outline\"\n */\n variant?: \"outline\" | \"plain\" | \"primary\";\n /**\n * Control height, from the density scale: `sm` sits in a row, `md` matches a default Button.\n * @default \"sm\"\n */\n size?: \"sm\" | \"md\";\n isDisabled?: boolean;\n /** Called once the text has reached the clipboard. */\n onCopied?: () => void;\n /**\n * Called when the clipboard refused the text, with what it rejected with. The button already shows\n * \"Failed\" and announces it; use this to keep a toast or log of your own.\n */\n onError?: (error: unknown) => void;\n /** Extra classes. Merged last, so they win conflicts with ours. */\n className?: string;\n}\n\nconst ICON = \"size-4\";\n\n/** CopyButton's variant names, mapped onto Button's. */\nconst BUTTON_VARIANT = {\n outline: \"outlineNeutral\",\n plain: \"ghost\",\n primary: \"primary\",\n} as const;\n\n/** An icon-only button is square: as wide as its rung is tall. */\nconst ICON_ONLY_WIDTH = {\n sm: \" w-(--density-control-h-sm) px-0\",\n md: \" w-(--density-control-h-md) px-0\",\n} as const;\n\n/** The result icon's tone on a page-surface button; at rest it takes the label's ink. */\nconst RESULT_TONE: Record<CopyFeedbackState, string | undefined> = {\n idle: undefined,\n copied: \"text-success-strong\",\n error: \"text-destructive-strong\",\n};\n\n/** The tick draws itself from its short stroke, the way a hand would write it: quick to start, then easing into place. */\nfunction DrawnCheck() {\n return (\n <svg\n className={ICON}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={1.75}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path\n d=\"M4 12l5 5L20 6\"\n pathLength={1}\n className=\"[stroke-dasharray:1] motion-safe:animate-check-draw-calm\"\n />\n </svg>\n );\n}\n\nfunction StateIcon({ state }: { state: CopyFeedbackState }) {\n if (state === \"copied\") return <DrawnCheck />;\n const common = {\n className: ICON,\n viewBox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n strokeWidth: 1.75,\n strokeLinecap: \"round\",\n strokeLinejoin: \"round\",\n \"aria-hidden\": true,\n } as const;\n if (state === \"error\") {\n return (\n <svg {...common}>\n <circle cx=\"12\" cy=\"12\" r=\"10\" />\n <path d=\"M12 8v4M12 16h.01\" />\n </svg>\n );\n }\n return (\n <svg {...common}>\n <rect x=\"8\" y=\"8\" width=\"14\" height=\"14\" rx=\"2\" />\n <path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\" />\n </svg>\n );\n}\n\n/**\n * Copies a value and says so, calmly. The icon trades places in one soft breath\n * (copy, then a tick that draws itself, or an alert if the clipboard refused)\n * while the label's changed letters rise in, and the button never changes width:\n * its label cell reserves the widest of the three texts.\n *\n * It goes back to rest by itself after a moment, and a live region announces the\n * result. The accessible name is the resting label, or `aria-label` when the visible\n * label is too short to stand alone; it never changes with the state.\n *\n * @example\n * <CopyButton value={token} />\n * <CopyButton value={url} iconOnly variant=\"plain\" />\n * <CopyButton value={code} aria-label=\"Copy code\">Copy</CopyButton>\n * <CopyButton value={ticket} variant=\"primary\" size=\"md\" onError={() => toast(\"Copy failed\")}>\n * Copy as ticket\n * </CopyButton>\n */\nexport const CopyButton = forwardRef<HTMLButtonElement, CopyButtonProps>(function CopyButton(\n {\n value,\n children: label = \"Copy\",\n \"aria-label\": ariaLabel,\n iconOnly = false,\n variant = \"outline\",\n size = \"sm\",\n isDisabled,\n onCopied,\n onError,\n className,\n },\n ref,\n) {\n const { state, copy } = useCopyFeedback();\n // Empty falls back too: an empty aria-label would leave the button with no name at all.\n const name = ariaLabel || label;\n const text = state === \"copied\" ? \"Copied\" : state === \"error\" ? \"Failed\" : label;\n\n // Not width-based like Button's press: an icon button presses a little deeper, every other one the same.\n const pressStart = (event: PressEvent) => {\n (event.target as HTMLElement).style.setProperty(\"--press-scale\", iconOnly ? \"0.96\" : \"0.97\");\n };\n\n const classes = cn(\n buttonVariants({ variant: BUTTON_VARIANT[variant], size }) +\n \" [--release-duration:var(--duration-gentle)] [--release-ease:var(--ease-gentle)]\" +\n (iconOnly ? ICON_ONLY_WIDTH[size] : \"\"),\n className,\n );\n // On the brand fill the result icon keeps the label's ink: the success and destructive tones are set\n // against the page surface, not the orange.\n const onBrandFill = variant === \"primary\";\n\n return (\n <>\n <AriaButton\n ref={ref}\n data-slot=\"button\"\n data-copy-state={state}\n aria-label={name}\n isDisabled={isDisabled}\n onPressStart={pressStart}\n onPress={() => {\n void copy(value, onError).then((ok) => {\n if (ok) callSafely(() => onCopied?.());\n });\n }}\n className={classes}\n >\n <IconSwap iconKey={state} calm className={cn(ICON, \"relative\")}>\n <span className={onBrandFill ? undefined : RESULT_TONE[state]}>\n <StateIcon state={state} />\n </span>\n </IconSwap>\n {iconOnly ? null : (\n // The cell reserves the widest state, so nothing moves but the letters. The inline padding gives blurred\n // edge letters room before the horizontal clip; the negative margin keeps the box the same size.\n <span\n className=\"-mx-1 grid min-w-0 overflow-x-clip px-1 text-start whitespace-nowrap\"\n aria-hidden=\"true\"\n >\n {[label, \"Copied\", \"Failed\"].map((measure) => (\n <span key={measure} className=\"invisible col-start-1 row-start-1\">\n {measure}\n </span>\n ))}\n <span className=\"col-start-1 row-start-1 justify-self-start\">\n <MorphText text={text} morphWidth={false} calm />\n </span>\n </span>\n )}\n </AriaButton>\n <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n {state === \"idle\" ? \"\" : state === \"error\" ? name + \": Could not copy\" : name + \": Copied\"}\n </span>\n </>\n );\n});\n"],"mappings":";;;;;;;;;;AAsDA,MAAM,OAAO;;AAGb,MAAM,iBAAiB;CACrB,SAAS;CACT,OAAO;CACP,SAAS;AACX;;AAGA,MAAM,kBAAkB;CACtB,IAAI;CACJ,IAAI;AACN;;AAGA,MAAM,cAA6D;CACjE,MAAM,KAAA;CACN,QAAQ;CACR,OAAO;AACT;;AAGA,SAAS,aAAa;CACpB,OACE,oBAAC,OAAD;EACE,WAAW;EACX,SAAQ;EACR,MAAK;EACL,QAAO;EACP,aAAa;EACb,eAAc;EACd,gBAAe;EACf,eAAY;EAEZ,UAAA,oBAAC,QAAD;GACE,GAAE;GACF,YAAY;GACZ,WAAU;EACX,CAAA;CACE,CAAA;AAET;AAEA,SAAS,UAAU,EAAE,SAAuC;CAC1D,IAAI,UAAU,UAAU,OAAO,oBAAC,YAAD,CAAa,CAAA;CAC5C,MAAM,SAAS;EACb,WAAW;EACX,SAAS;EACT,MAAM;EACN,QAAQ;EACR,aAAa;EACb,eAAe;EACf,gBAAgB;EAChB,eAAe;CACjB;CACA,IAAI,UAAU,SACZ,OACE,qBAAC,OAAD;EAAK,GAAI;EAAT,UAAA,CACE,oBAAC,UAAD;GAAQ,IAAG;GAAK,IAAG;GAAK,GAAE;EAAM,CAAA,GAChC,oBAAC,QAAD,EAAM,GAAE,oBAAqB,CAAA,CAC1B;;CAGT,OACE,qBAAC,OAAD;EAAK,GAAI;EAAT,UAAA,CACE,oBAAC,QAAD;GAAM,GAAE;GAAI,GAAE;GAAI,OAAM;GAAK,QAAO;GAAK,IAAG;EAAK,CAAA,GACjD,oBAAC,QAAD,EAAM,GAAE,0DAA2D,CAAA,CAChE;;AAET;;;;;;;;;;;;;;;;;;;AAoBA,MAAa,aAAa,WAA+C,SAAS,WAChF,EACE,OACA,UAAU,QAAQ,QAClB,cAAc,WACd,WAAW,OACX,UAAU,WACV,OAAO,MACP,YACA,UACA,SACA,aAEF,KACA;CACA,MAAM,EAAE,OAAO,SAAS,gBAAgB;CAExC,MAAM,OAAO,aAAa;CAC1B,MAAM,OAAO,UAAU,WAAW,WAAW,UAAU,UAAU,WAAW;CAG5E,MAAM,cAAc,UAAsB;EACxC,MAAO,OAAuB,MAAM,YAAY,iBAAiB,WAAW,SAAS,MAAM;CAC7F;CAEA,MAAM,UAAU,GACd,eAAe;EAAE,SAAS,eAAe;EAAU;CAAK,CAAC,IACvD,sFACC,WAAW,gBAAgB,QAAQ,KACtC,SACF;CAGA,MAAM,cAAc,YAAY;CAEhC,OACE,qBAAA,YAAA,EAAA,UAAA,CACE,qBAACA,QAAD;EACO;EACL,aAAU;EACV,mBAAiB;EACjB,cAAY;EACA;EACZ,cAAc;EACd,eAAe;GACb,KAAU,OAAO,OAAO,CAAC,CAAC,MAAM,OAAO;IACrC,IAAI,IAAI,iBAAiB,WAAW,CAAC;GACvC,CAAC;EACH;EACA,WAAW;EAZb,UAAA,CAcE,oBAAC,UAAD;GAAU,SAAS;GAAO,MAAA;GAAK,WAAW,GAAG,MAAM,UAAU;GAC3D,UAAA,oBAAC,QAAD;IAAM,WAAW,cAAc,KAAA,IAAY,YAAY;IACrD,UAAA,oBAAC,WAAD,EAAkB,MAAQ,CAAA;GACtB,CAAA;EACE,CAAA,GACT,WAAW,OAGV,qBAAC,QAAD;GACE,WAAU;GACV,eAAY;GAFd,UAAA,CAIG;IAAC;IAAO;IAAU;GAAQ,CAAC,CAAC,KAAK,YAChC,oBAAC,QAAD;IAAoB,WAAU;IAC3B,UAAA;GACG,GAFK,OAEL,CACP,GACD,oBAAC,QAAD;IAAM,WAAU;IACd,UAAA,oBAAC,WAAD;KAAiB;KAAM,YAAY;KAAO,MAAA;IAAM,CAAA;GAC5C,CAAA,CACF;EAEE,CAAA,CAAA;CACZ,CAAA,GAAA,oBAAC,QAAD;EAAM,WAAU;EAAU,MAAK;EAAS,aAAU;EAC/C,UAAA,UAAU,SAAS,KAAK,UAAU,UAAU,OAAO,qBAAqB,OAAO;CAC5E,CAAA,CACN,EAAA,CAAA;AAEN,CAAC"}
@@ -18,14 +18,30 @@ import { ReactNode } from "react";
18
18
  * source text, so a class assembled at runtime is invisible to it and would
19
19
  * simply never be emitted.
20
20
  */
21
- export declare const fileDropZoneVariants: (props?: import("class-variance-authority/types").ClassProp | undefined) => string;
22
- export declare const fileDropZonePromptVariants: (props?: import("class-variance-authority/types").ClassProp | undefined) => string;
23
- export declare const fileDropZoneHintVariants: (props?: import("class-variance-authority/types").ClassProp | undefined) => string;
21
+ export declare const fileDropZoneVariants: (props?: ({
22
+ size?: "sm" | "md" | null | undefined;
23
+ } & import("class-variance-authority/types").ClassProp) | undefined) => string;
24
+ export declare const fileDropZonePromptVariants: (props?: ({
25
+ size?: "sm" | "md" | null | undefined;
26
+ } & import("class-variance-authority/types").ClassProp) | undefined) => string;
27
+ export declare const fileDropZoneHintVariants: (props?: ({
28
+ size?: "sm" | "md" | null | undefined;
29
+ } & import("class-variance-authority/types").ClassProp) | undefined) => string;
24
30
  /**
25
31
  * A place to drop or choose files.
26
32
  *
27
33
  * @example
28
34
  * <FileDropZone
35
+ * size="sm"
36
+ * label="Company logo"
37
+ * prompt="Drop a logo here"
38
+ * hint="PNG, JPG or SVG, up to 5 MB."
39
+ * acceptedFileTypes={["image/*"]}
40
+ * onFiles={(files) => upload(files[0])}
41
+ * />
42
+ *
43
+ * @example
44
+ * <FileDropZone
29
45
  * label="Contract"
30
46
  * prompt="Drop a contract here"
31
47
  * hint="PDF only, up to 20 MB."
@@ -63,6 +79,16 @@ export interface FileDropZoneProps {
63
79
  buttonLabel?: string;
64
80
  /** Take no file: the button is disabled and a drop is refused. */
65
81
  isDisabled?: boolean;
82
+ /**
83
+ * How much room the zone takes. `md` is the stacked zone (prompt, button and
84
+ * hint centred in a tall dashed area). `sm` is one row as tall as a medium
85
+ * field, for a form row such as a logo upload: the prompt, the button and the
86
+ * hint sit side by side, and when the row is narrow the hint truncates first.
87
+ * Same parts, same order, same keyboard path at both sizes.
88
+ *
89
+ * @default "md"
90
+ */
91
+ size?: "sm" | "md";
66
92
  /** Extra classes for the zone. Merged last, so they win conflicts with ours. */
67
93
  className?: string;
68
94
  }
@@ -27,7 +27,7 @@ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
27
27
  * simply never be emitted.
28
28
  */
29
29
  const fileDropZoneVariants = cva([
30
- "flex flex-col items-center gap-2 px-4 py-8 text-center",
30
+ "flex items-center",
31
31
  "rounded-panel border-(length:--border-width) border-dashed border-border",
32
32
  "box-border font-sans text-foreground",
33
33
  "outline-none",
@@ -39,14 +39,32 @@ const fileDropZoneVariants = cva([
39
39
  "data-[focus-visible]:outline-offset-(--focus-ring-offset)",
40
40
  "data-[focus-visible]:outline-(--color-ring)",
41
41
  "data-[disabled]:cursor-not-allowed"
42
- ]);
43
- const fileDropZonePromptVariants = cva("m-0 text-sm leading-(--leading-normal)");
44
- const fileDropZoneHintVariants = cva("m-0 text-xs leading-(--leading-normal) text-muted-foreground");
42
+ ], {
43
+ variants: { size: {
44
+ md: "flex-col gap-2 px-4 py-8 text-center",
45
+ sm: "min-h-(--density-control-h-md) flex-row justify-center gap-2 px-3 py-0.5 text-start"
46
+ } },
47
+ defaultVariants: { size: "md" }
48
+ });
49
+ const fileDropZonePromptVariants = cva("m-0 text-sm leading-(--leading-normal)", {
50
+ variants: { size: {
51
+ md: "",
52
+ sm: "min-w-0 truncate"
53
+ } },
54
+ defaultVariants: { size: "md" }
55
+ });
56
+ const fileDropZoneHintVariants = cva("m-0 text-xs leading-(--leading-normal) text-muted-foreground", {
57
+ variants: { size: {
58
+ md: "",
59
+ sm: "min-w-0 flex-1 basis-0 truncate"
60
+ } },
61
+ defaultVariants: { size: "md" }
62
+ });
45
63
  async function filesOf(event) {
46
64
  const items = event.items.filter((item) => item.kind === "file");
47
65
  return Promise.all(items.map((item) => item.getFile()));
48
66
  }
49
- const FileDropZone = forwardRef(function FileDropZone({ label, prompt, dropPrompt, hint, acceptedFileTypes, allowsMultiple = false, onFiles, buttonLabel, isDisabled = false, className }, ref) {
67
+ const FileDropZone = forwardRef(function FileDropZone({ label, prompt, dropPrompt, hint, acceptedFileTypes, allowsMultiple = false, onFiles, buttonLabel, isDisabled = false, size = "md", className }, ref) {
50
68
  const hand = (files) => {
51
69
  if (isDisabled || files.length === 0) return;
52
70
  onFiles(allowsMultiple ? files : files.slice(0, 1));
@@ -64,7 +82,7 @@ const FileDropZone = forwardRef(function FileDropZone({ label, prompt, dropPromp
64
82
  "aria-label": label,
65
83
  "data-slot": "file-drop-zone",
66
84
  isDisabled,
67
- className: cn(fileDropZoneVariants(), className),
85
+ className: cn(fileDropZoneVariants({ size }), className),
68
86
  onDropEnter: (event) => lean(event.x, event.y),
69
87
  onDropMove: (event) => lean(event.x, event.y),
70
88
  onDrop: (event) => {
@@ -83,7 +101,7 @@ const FileDropZone = forwardRef(function FileDropZone({ label, prompt, dropPromp
83
101
  }),
84
102
  /* @__PURE__ */ jsx("p", {
85
103
  "data-slot": "file-drop-zone-prompt",
86
- className: cn(fileDropZonePromptVariants(), "relative"),
104
+ className: cn(fileDropZonePromptVariants({ size }), "relative"),
87
105
  children: dropPrompt == null ? prompt : /* @__PURE__ */ jsx(SwapText, {
88
106
  swapKey: isDropTarget ? "drop" : "prompt",
89
107
  className: "block",
@@ -104,7 +122,7 @@ const FileDropZone = forwardRef(function FileDropZone({ label, prompt, dropPromp
104
122
  }),
105
123
  hint == null ? null : /* @__PURE__ */ jsx("p", {
106
124
  "data-slot": "file-drop-zone-hint",
107
- className: cn(fileDropZoneHintVariants(), "relative"),
125
+ className: cn(fileDropZoneHintVariants({ size }), "relative"),
108
126
  children: hint
109
127
  })
110
128
  ] })
@@ -1 +1 @@
1
- {"version":3,"file":"file-drop-zone.js","names":["AriaDropZone","AriaFileTrigger","Button"],"sources":["../../src/file-drop-zone/file-drop-zone.tsx"],"sourcesContent":["\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { forwardRef, useMemo, useRef, type ReactNode } from \"react\";\nimport {\n DropZone as AriaDropZone,\n FileTrigger as AriaFileTrigger,\n type DropZoneProps as AriaDropZoneProps,\n type FileDropItem,\n} from \"react-aria-components\";\n\nimport { Button } from \"../button/button.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { mergeRefs } from \"../lib/merge-refs.js\";\nimport { SwapText } from \"../lib/swap-text.js\";\n\n/**\n * FileDropZone: a dashed area a file can be dropped on, with a \"Choose file\"\n * button that opens the system's file dialog.\n *\n * Built on React Aria's `DropZone` and `FileTrigger`. The browser's own\n * `<input type=\"file\">` (\"Choose Files / No file chosen\") is never shown: its\n * look differs on every browser and it cannot be styled. FileTrigger keeps a\n * real file input, visually hidden and out of the Tab order, and opens it when\n * the button is pressed, so the keyboard path is Tab to the button, then Enter\n * or Space.\n *\n * The zone states what it takes before a file is chosen: `hint` is where the\n * accepted types and the size limit go.\n *\n * Every string in the cva blocks below is a STATIC literal. Tailwind scans\n * source text, so a class assembled at runtime is invisible to it and would\n * simply never be emitted.\n */\nexport const fileDropZoneVariants = cva([\n \"flex flex-col items-center gap-2 px-4 py-8 text-center\",\n \"rounded-panel border-(length:--border-width) border-dashed border-border\",\n // The reset in @seekalfred/styles is opt-in, so a consumer may load none.\n \"box-border font-sans text-foreground\",\n \"outline-none\",\n // Fill and edge ease, a file over it breathes and light follows the pointer (see drop-zone-motion).\n \"drop-zone-motion\",\n \"data-[hovered]:bg-muted\",\n \"data-[drop-target]:border-primary data-[drop-target]:bg-muted\",\n \"data-[focus-visible]:outline-solid\",\n \"data-[focus-visible]:outline-(length:--focus-ring-width)\",\n \"data-[focus-visible]:outline-offset-(--focus-ring-offset)\",\n \"data-[focus-visible]:outline-(--color-ring)\",\n // Disabled dims only the button (its own disabled look). Dimming the whole\n // zone took the prompt and hint below the 4.5:1 contrast floor.\n \"data-[disabled]:cursor-not-allowed\",\n]);\n\nexport const fileDropZonePromptVariants = cva(\"m-0 text-sm leading-(--leading-normal)\");\n\nexport const fileDropZoneHintVariants = cva(\n \"m-0 text-xs leading-(--leading-normal) text-muted-foreground\",\n);\n\n/**\n * A place to drop or choose files.\n *\n * @example\n * <FileDropZone\n * label=\"Contract\"\n * prompt=\"Drop a contract here\"\n * hint=\"PDF only, up to 20 MB.\"\n * acceptedFileTypes={[\"application/pdf\"]}\n * onFiles={(files) => upload(files[0])}\n * />\n */\nexport interface FileDropZoneProps {\n /** What the file is. The drop zone's accessible name. */\n label: string;\n /** The zone's main line: what to drop here, or the chosen file's name. */\n prompt: ReactNode;\n /**\n * What the prompt says while a file is over the zone. When it is given the\n * prompt swaps to it (the new text rises in, the old lifts away) and back again.\n */\n dropPrompt?: ReactNode;\n /** The types and size accepted, and anything else to know before choosing. */\n hint?: ReactNode;\n /**\n * MIME types or extensions the file dialog offers, as for a native\n * `accept`. A drop is not filtered: what was dropped is handed over and the\n * consumer decides what it can read.\n */\n acceptedFileTypes?: string[];\n /** Take several files at once. Otherwise only the first is handed over. */\n allowsMultiple?: boolean;\n /** Called with the chosen or dropped files. */\n onFiles: (files: File[]) => void;\n /**\n * The button's text.\n *\n * @default \"Choose file\", or \"Choose files\" with `allowsMultiple`\n */\n buttonLabel?: string;\n /** Take no file: the button is disabled and a drop is refused. */\n isDisabled?: boolean;\n /** Extra classes for the zone. Merged last, so they win conflicts with ours. */\n className?: string;\n}\n\ntype DropEvent = Parameters<NonNullable<AriaDropZoneProps[\"onDrop\"]>>[0];\n\nasync function filesOf(event: DropEvent): Promise<File[]> {\n const items = event.items.filter((item): item is FileDropItem => item.kind === \"file\");\n return Promise.all(items.map((item) => item.getFile()));\n}\n\nexport const FileDropZone = forwardRef<HTMLDivElement, FileDropZoneProps>(function FileDropZone(\n {\n label,\n prompt,\n dropPrompt,\n hint,\n acceptedFileTypes,\n allowsMultiple = false,\n onFiles,\n buttonLabel,\n isDisabled = false,\n className,\n },\n ref,\n) {\n const hand = (files: File[]) => {\n if (isDisabled || files.length === 0) return;\n onFiles(allowsMultiple ? files : files.slice(0, 1));\n };\n const zone = useRef<HTMLDivElement | null>(null);\n const refs = useMemo(() => mergeRefs(ref, zone), [ref]);\n // Where the file is over the zone, as percentages, for the light that follows it.\n const lean = (x: number, y: number) => {\n const el = zone.current;\n if (!el) return;\n el.style.setProperty(\"--drop-x\", `${(x / Math.max(1, el.offsetWidth)) * 100}%`);\n el.style.setProperty(\"--drop-y\", `${(y / Math.max(1, el.offsetHeight)) * 100}%`);\n };\n return (\n <AriaDropZone\n ref={refs}\n aria-label={label}\n data-slot=\"file-drop-zone\"\n isDisabled={isDisabled}\n className={cn(fileDropZoneVariants(), className)}\n onDropEnter={(event) => lean(event.x, event.y)}\n onDropMove={(event) => lean(event.x, event.y)}\n onDrop={(event) => {\n void filesOf(event).then(hand);\n }}\n >\n {({ isDropTarget }) => (\n <>\n {/* The wash and the edge light sit under the content and follow the file. */}\n <span aria-hidden=\"true\" data-slot=\"file-drop-zone-wash\" className=\"drop-wash\" />\n <span aria-hidden=\"true\" data-slot=\"file-drop-zone-glow\" className=\"drop-glow\" />\n <p data-slot=\"file-drop-zone-prompt\" className={cn(fileDropZonePromptVariants(), \"relative\")}>\n {dropPrompt == null ? (\n prompt\n ) : (\n <SwapText swapKey={isDropTarget ? \"drop\" : \"prompt\"} className=\"block\">\n {isDropTarget ? dropPrompt : prompt}\n </SwapText>\n )}\n </p>\n <AriaFileTrigger\n acceptedFileTypes={acceptedFileTypes}\n allowsMultiple={allowsMultiple}\n onSelect={(list) => hand(Array.from(list ?? []))}\n >\n <Button variant=\"outlineNeutral\" size=\"sm\" isDisabled={isDisabled} className=\"relative\">\n {buttonLabel ?? (allowsMultiple ? \"Choose files\" : \"Choose file\")}\n </Button>\n </AriaFileTrigger>\n {hint == null ? null : (\n <p\n data-slot=\"file-drop-zone-hint\"\n className={cn(fileDropZoneHintVariants(), \"relative\")}\n >\n {hint}\n </p>\n )}\n </>\n )}\n </AriaDropZone>\n );\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCA,MAAa,uBAAuB,IAAI;CACtC;CACA;CAEA;CACA;CAEA;CACA;CACA;CACA;CACA;CACA;CACA;CAGA;AACF,CAAC;AAED,MAAa,6BAA6B,IAAI,wCAAwC;AAEtF,MAAa,2BAA2B,IACtC,8DACF;AAkDA,eAAe,QAAQ,OAAmC;CACxD,MAAM,QAAQ,MAAM,MAAM,QAAQ,SAA+B,KAAK,SAAS,MAAM;CACrF,OAAO,QAAQ,IAAI,MAAM,KAAK,SAAS,KAAK,QAAQ,CAAC,CAAC;AACxD;AAEA,MAAa,eAAe,WAA8C,SAAS,aACjF,EACE,OACA,QACA,YACA,MACA,mBACA,iBAAiB,OACjB,SACA,aACA,aAAa,OACb,aAEF,KACA;CACA,MAAM,QAAQ,UAAkB;EAC9B,IAAI,cAAc,MAAM,WAAW,GAAG;EACtC,QAAQ,iBAAiB,QAAQ,MAAM,MAAM,GAAG,CAAC,CAAC;CACpD;CACA,MAAM,OAAO,OAA8B,IAAI;CAC/C,MAAM,OAAO,cAAc,UAAU,KAAK,IAAI,GAAG,CAAC,GAAG,CAAC;CAEtD,MAAM,QAAQ,GAAW,MAAc;EACrC,MAAM,KAAK,KAAK;EAChB,IAAI,CAAC,IAAI;EACT,GAAG,MAAM,YAAY,YAAY,GAAI,IAAI,KAAK,IAAI,GAAG,GAAG,WAAW,IAAK,IAAI,EAAE;EAC9E,GAAG,MAAM,YAAY,YAAY,GAAI,IAAI,KAAK,IAAI,GAAG,GAAG,YAAY,IAAK,IAAI,EAAE;CACjF;CACA,OACE,oBAACA,UAAD;EACE,KAAK;EACL,cAAY;EACZ,aAAU;EACE;EACZ,WAAW,GAAG,qBAAqB,GAAG,SAAS;EAC/C,cAAc,UAAU,KAAK,MAAM,GAAG,MAAM,CAAC;EAC7C,aAAa,UAAU,KAAK,MAAM,GAAG,MAAM,CAAC;EAC5C,SAAS,UAAU;GACjB,QAAa,KAAK,CAAC,CAAC,KAAK,IAAI;EAC/B;EAEE,WAAA,EAAE,mBACF,qBAAA,YAAA,EAAA,UAAA;GAEE,oBAAC,QAAD;IAAM,eAAY;IAAO,aAAU;IAAsB,WAAU;GAAa,CAAA;GAChF,oBAAC,QAAD;IAAM,eAAY;IAAO,aAAU;IAAsB,WAAU;GAAa,CAAA;GAChF,oBAAC,KAAD;IAAG,aAAU;IAAwB,WAAW,GAAG,2BAA2B,GAAG,UAAU;IACxF,UAAA,cAAc,OACb,SAEA,oBAAC,UAAD;KAAU,SAAS,eAAe,SAAS;KAAU,WAAU;KAC5D,UAAA,eAAe,aAAa;IACrB,CAAA;GAEX,CAAA;GACH,oBAACC,aAAD;IACqB;IACH;IAChB,WAAW,SAAS,KAAK,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC;IAE/C,UAAA,oBAACC,UAAD;KAAQ,SAAQ;KAAiB,MAAK;KAAiB;KAAY,WAAU;KAC1E,UAAA,gBAAgB,iBAAiB,iBAAiB;IAC7C,CAAA;GACO,CAAA;GAChB,QAAQ,OAAO,OACd,oBAAC,KAAD;IACE,aAAU;IACV,WAAW,GAAG,yBAAyB,GAAG,UAAU;IAEnD,UAAA;GACA,CAAA;EAEL,EAAA,CAAA;CAEQ,CAAA;AAElB,CAAC"}
1
+ {"version":3,"file":"file-drop-zone.js","names":["AriaDropZone","AriaFileTrigger","Button"],"sources":["../../src/file-drop-zone/file-drop-zone.tsx"],"sourcesContent":["\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { forwardRef, useMemo, useRef, type ReactNode } from \"react\";\nimport {\n DropZone as AriaDropZone,\n FileTrigger as AriaFileTrigger,\n type DropZoneProps as AriaDropZoneProps,\n type FileDropItem,\n} from \"react-aria-components\";\n\nimport { Button } from \"../button/button.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { mergeRefs } from \"../lib/merge-refs.js\";\nimport { SwapText } from \"../lib/swap-text.js\";\n\n/**\n * FileDropZone: a dashed area a file can be dropped on, with a \"Choose file\"\n * button that opens the system's file dialog.\n *\n * Built on React Aria's `DropZone` and `FileTrigger`. The browser's own\n * `<input type=\"file\">` (\"Choose Files / No file chosen\") is never shown: its\n * look differs on every browser and it cannot be styled. FileTrigger keeps a\n * real file input, visually hidden and out of the Tab order, and opens it when\n * the button is pressed, so the keyboard path is Tab to the button, then Enter\n * or Space.\n *\n * The zone states what it takes before a file is chosen: `hint` is where the\n * accepted types and the size limit go.\n *\n * Every string in the cva blocks below is a STATIC literal. Tailwind scans\n * source text, so a class assembled at runtime is invisible to it and would\n * simply never be emitted.\n */\nexport const fileDropZoneVariants = cva(\n [\n \"flex items-center\",\n \"rounded-panel border-(length:--border-width) border-dashed border-border\",\n // The reset in @seekalfred/styles is opt-in, so a consumer may load none.\n \"box-border font-sans text-foreground\",\n \"outline-none\",\n // Fill and edge ease, a file over it breathes and light follows the pointer (see drop-zone-motion).\n \"drop-zone-motion\",\n \"data-[hovered]:bg-muted\",\n \"data-[drop-target]:border-primary data-[drop-target]:bg-muted\",\n \"data-[focus-visible]:outline-solid\",\n \"data-[focus-visible]:outline-(length:--focus-ring-width)\",\n \"data-[focus-visible]:outline-offset-(--focus-ring-offset)\",\n \"data-[focus-visible]:outline-(--color-ring)\",\n // Disabled dims only the button (its own disabled look). Dimming the whole\n // zone took the prompt and hint below the 4.5:1 contrast floor.\n \"data-[disabled]:cursor-not-allowed\",\n ],\n {\n variants: {\n size: {\n // A stacked zone: prompt, button, hint, centred.\n md: \"flex-col gap-2 px-4 py-8 text-center\",\n // One row as tall as a medium field (the sm button plus a hairline of\n // padding), for a form row such as a logo upload. Nothing wraps: the\n // hint gives way first, then the prompt; the button (shrink-0) stays whole.\n sm: \"min-h-(--density-control-h-md) flex-row justify-center gap-2 px-3 py-0.5 text-start\",\n },\n },\n defaultVariants: { size: \"md\" },\n },\n);\n\nexport const fileDropZonePromptVariants = cva(\"m-0 text-sm leading-(--leading-normal)\", {\n variants: {\n size: {\n md: \"\",\n sm: \"min-w-0 truncate\",\n },\n },\n defaultVariants: { size: \"md\" },\n});\n\nexport const fileDropZoneHintVariants = cva(\n \"m-0 text-xs leading-(--leading-normal) text-muted-foreground\",\n {\n variants: {\n size: {\n md: \"\",\n // Takes only the room left over, so it is the first to truncate.\n sm: \"min-w-0 flex-1 basis-0 truncate\",\n },\n },\n defaultVariants: { size: \"md\" },\n },\n);\n\n/**\n * A place to drop or choose files.\n *\n * @example\n * <FileDropZone\n * size=\"sm\"\n * label=\"Company logo\"\n * prompt=\"Drop a logo here\"\n * hint=\"PNG, JPG or SVG, up to 5 MB.\"\n * acceptedFileTypes={[\"image/*\"]}\n * onFiles={(files) => upload(files[0])}\n * />\n *\n * @example\n * <FileDropZone\n * label=\"Contract\"\n * prompt=\"Drop a contract here\"\n * hint=\"PDF only, up to 20 MB.\"\n * acceptedFileTypes={[\"application/pdf\"]}\n * onFiles={(files) => upload(files[0])}\n * />\n */\nexport interface FileDropZoneProps {\n /** What the file is. The drop zone's accessible name. */\n label: string;\n /** The zone's main line: what to drop here, or the chosen file's name. */\n prompt: ReactNode;\n /**\n * What the prompt says while a file is over the zone. When it is given the\n * prompt swaps to it (the new text rises in, the old lifts away) and back again.\n */\n dropPrompt?: ReactNode;\n /** The types and size accepted, and anything else to know before choosing. */\n hint?: ReactNode;\n /**\n * MIME types or extensions the file dialog offers, as for a native\n * `accept`. A drop is not filtered: what was dropped is handed over and the\n * consumer decides what it can read.\n */\n acceptedFileTypes?: string[];\n /** Take several files at once. Otherwise only the first is handed over. */\n allowsMultiple?: boolean;\n /** Called with the chosen or dropped files. */\n onFiles: (files: File[]) => void;\n /**\n * The button's text.\n *\n * @default \"Choose file\", or \"Choose files\" with `allowsMultiple`\n */\n buttonLabel?: string;\n /** Take no file: the button is disabled and a drop is refused. */\n isDisabled?: boolean;\n /**\n * How much room the zone takes. `md` is the stacked zone (prompt, button and\n * hint centred in a tall dashed area). `sm` is one row as tall as a medium\n * field, for a form row such as a logo upload: the prompt, the button and the\n * hint sit side by side, and when the row is narrow the hint truncates first.\n * Same parts, same order, same keyboard path at both sizes.\n *\n * @default \"md\"\n */\n size?: \"sm\" | \"md\";\n /** Extra classes for the zone. Merged last, so they win conflicts with ours. */\n className?: string;\n}\n\ntype DropEvent = Parameters<NonNullable<AriaDropZoneProps[\"onDrop\"]>>[0];\n\nasync function filesOf(event: DropEvent): Promise<File[]> {\n const items = event.items.filter((item): item is FileDropItem => item.kind === \"file\");\n return Promise.all(items.map((item) => item.getFile()));\n}\n\nexport const FileDropZone = forwardRef<HTMLDivElement, FileDropZoneProps>(function FileDropZone(\n {\n label,\n prompt,\n dropPrompt,\n hint,\n acceptedFileTypes,\n allowsMultiple = false,\n onFiles,\n buttonLabel,\n isDisabled = false,\n size = \"md\",\n className,\n },\n ref,\n) {\n const hand = (files: File[]) => {\n if (isDisabled || files.length === 0) return;\n onFiles(allowsMultiple ? files : files.slice(0, 1));\n };\n const zone = useRef<HTMLDivElement | null>(null);\n const refs = useMemo(() => mergeRefs(ref, zone), [ref]);\n // Where the file is over the zone, as percentages, for the light that follows it.\n const lean = (x: number, y: number) => {\n const el = zone.current;\n if (!el) return;\n el.style.setProperty(\"--drop-x\", `${(x / Math.max(1, el.offsetWidth)) * 100}%`);\n el.style.setProperty(\"--drop-y\", `${(y / Math.max(1, el.offsetHeight)) * 100}%`);\n };\n return (\n <AriaDropZone\n ref={refs}\n aria-label={label}\n data-slot=\"file-drop-zone\"\n isDisabled={isDisabled}\n className={cn(fileDropZoneVariants({ size }), className)}\n onDropEnter={(event) => lean(event.x, event.y)}\n onDropMove={(event) => lean(event.x, event.y)}\n onDrop={(event) => {\n void filesOf(event).then(hand);\n }}\n >\n {({ isDropTarget }) => (\n <>\n {/* The wash and the edge light sit under the content and follow the file. */}\n <span aria-hidden=\"true\" data-slot=\"file-drop-zone-wash\" className=\"drop-wash\" />\n <span aria-hidden=\"true\" data-slot=\"file-drop-zone-glow\" className=\"drop-glow\" />\n <p\n data-slot=\"file-drop-zone-prompt\"\n className={cn(fileDropZonePromptVariants({ size }), \"relative\")}\n >\n {dropPrompt == null ? (\n prompt\n ) : (\n <SwapText swapKey={isDropTarget ? \"drop\" : \"prompt\"} className=\"block\">\n {isDropTarget ? dropPrompt : prompt}\n </SwapText>\n )}\n </p>\n <AriaFileTrigger\n acceptedFileTypes={acceptedFileTypes}\n allowsMultiple={allowsMultiple}\n onSelect={(list) => hand(Array.from(list ?? []))}\n >\n <Button variant=\"outlineNeutral\" size=\"sm\" isDisabled={isDisabled} className=\"relative\">\n {buttonLabel ?? (allowsMultiple ? \"Choose files\" : \"Choose file\")}\n </Button>\n </AriaFileTrigger>\n {hint == null ? null : (\n <p\n data-slot=\"file-drop-zone-hint\"\n className={cn(fileDropZoneHintVariants({ size }), \"relative\")}\n >\n {hint}\n </p>\n )}\n </>\n )}\n </AriaDropZone>\n );\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCA,MAAa,uBAAuB,IAClC;CACE;CACA;CAEA;CACA;CAEA;CACA;CACA;CACA;CACA;CACA;CACA;CAGA;AACF,GACA;CACE,UAAU,EACR,MAAM;EAEJ,IAAI;EAIJ,IAAI;CACN,EACF;CACA,iBAAiB,EAAE,MAAM,KAAK;AAChC,CACF;AAEA,MAAa,6BAA6B,IAAI,0CAA0C;CACtF,UAAU,EACR,MAAM;EACJ,IAAI;EACJ,IAAI;CACN,EACF;CACA,iBAAiB,EAAE,MAAM,KAAK;AAChC,CAAC;AAED,MAAa,2BAA2B,IACtC,gEACA;CACE,UAAU,EACR,MAAM;EACJ,IAAI;EAEJ,IAAI;CACN,EACF;CACA,iBAAiB,EAAE,MAAM,KAAK;AAChC,CACF;AAsEA,eAAe,QAAQ,OAAmC;CACxD,MAAM,QAAQ,MAAM,MAAM,QAAQ,SAA+B,KAAK,SAAS,MAAM;CACrF,OAAO,QAAQ,IAAI,MAAM,KAAK,SAAS,KAAK,QAAQ,CAAC,CAAC;AACxD;AAEA,MAAa,eAAe,WAA8C,SAAS,aACjF,EACE,OACA,QACA,YACA,MACA,mBACA,iBAAiB,OACjB,SACA,aACA,aAAa,OACb,OAAO,MACP,aAEF,KACA;CACA,MAAM,QAAQ,UAAkB;EAC9B,IAAI,cAAc,MAAM,WAAW,GAAG;EACtC,QAAQ,iBAAiB,QAAQ,MAAM,MAAM,GAAG,CAAC,CAAC;CACpD;CACA,MAAM,OAAO,OAA8B,IAAI;CAC/C,MAAM,OAAO,cAAc,UAAU,KAAK,IAAI,GAAG,CAAC,GAAG,CAAC;CAEtD,MAAM,QAAQ,GAAW,MAAc;EACrC,MAAM,KAAK,KAAK;EAChB,IAAI,CAAC,IAAI;EACT,GAAG,MAAM,YAAY,YAAY,GAAI,IAAI,KAAK,IAAI,GAAG,GAAG,WAAW,IAAK,IAAI,EAAE;EAC9E,GAAG,MAAM,YAAY,YAAY,GAAI,IAAI,KAAK,IAAI,GAAG,GAAG,YAAY,IAAK,IAAI,EAAE;CACjF;CACA,OACE,oBAACA,UAAD;EACE,KAAK;EACL,cAAY;EACZ,aAAU;EACE;EACZ,WAAW,GAAG,qBAAqB,EAAE,KAAK,CAAC,GAAG,SAAS;EACvD,cAAc,UAAU,KAAK,MAAM,GAAG,MAAM,CAAC;EAC7C,aAAa,UAAU,KAAK,MAAM,GAAG,MAAM,CAAC;EAC5C,SAAS,UAAU;GACjB,QAAa,KAAK,CAAC,CAAC,KAAK,IAAI;EAC/B;EAEE,WAAA,EAAE,mBACF,qBAAA,YAAA,EAAA,UAAA;GAEE,oBAAC,QAAD;IAAM,eAAY;IAAO,aAAU;IAAsB,WAAU;GAAa,CAAA;GAChF,oBAAC,QAAD;IAAM,eAAY;IAAO,aAAU;IAAsB,WAAU;GAAa,CAAA;GAChF,oBAAC,KAAD;IACE,aAAU;IACV,WAAW,GAAG,2BAA2B,EAAE,KAAK,CAAC,GAAG,UAAU;IAE7D,UAAA,cAAc,OACb,SAEA,oBAAC,UAAD;KAAU,SAAS,eAAe,SAAS;KAAU,WAAU;KAC5D,UAAA,eAAe,aAAa;IACrB,CAAA;GAEX,CAAA;GACH,oBAACC,aAAD;IACqB;IACH;IAChB,WAAW,SAAS,KAAK,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC;IAE/C,UAAA,oBAACC,UAAD;KAAQ,SAAQ;KAAiB,MAAK;KAAiB;KAAY,WAAU;KAC1E,UAAA,gBAAgB,iBAAiB,iBAAiB;IAC7C,CAAA;GACO,CAAA;GAChB,QAAQ,OAAO,OACd,oBAAC,KAAD;IACE,aAAU;IACV,WAAW,GAAG,yBAAyB,EAAE,KAAK,CAAC,GAAG,UAAU;IAE3D,UAAA;GACA,CAAA;EAEL,EAAA,CAAA;CAEQ,CAAA;AAElB,CAAC"}
@@ -14,10 +14,17 @@ import { ComponentPropsWithoutRef } from "react";
14
14
  * status and detail rise in when they change, from the side the value moved
15
15
  * toward.
16
16
  *
17
+ * A missing value (`null`) shows an empty ring, a dash and "No data", and is
18
+ * read as an image named "<label>: No data" rather than a meter, since there
19
+ * is nothing to measure. Size `sm` is the number alone in a small ring, for a
20
+ * table cell or a card corner; the label and detail stay for assistive
21
+ * technology.
22
+ *
17
23
  * Plain SVG and CSS. One small spring tween writes the ring and the count
18
24
  * straight to the DOM. The ring is a `meter` for assistive technology.
19
25
  */
20
26
  export type GaugeTone = "primary" | "success" | "warning" | "danger";
27
+ export type GaugeSize = "sm" | "md";
21
28
  export interface GaugeThreshold {
22
29
  /** The band starts at this value (in the same unit as `value`). */
23
30
  from: number;
@@ -26,7 +33,8 @@ export interface GaugeThreshold {
26
33
  label: string;
27
34
  }
28
35
  export interface GaugeProps extends Omit<ComponentPropsWithoutRef<"figure">, "children"> {
29
- value: number;
36
+ /** The value. `null` (or a value that is not a finite number) is no data: an empty ring that reads `missingLabel`. */
37
+ value: number | null;
30
38
  /** @default 0 */
31
39
  min?: number;
32
40
  /** @default 100 */
@@ -39,6 +47,15 @@ export interface GaugeProps extends Omit<ComponentPropsWithoutRef<"figure">, "ch
39
47
  tone?: GaugeTone;
40
48
  /** Bands that set the tone and name the state. */
41
49
  thresholds?: GaugeThreshold[];
50
+ /**
51
+ * `"sm"` is the number alone in a small ring (48px; set `--gauge-size` to
52
+ * change it), coloured by the tone. The percent sign, the status line and
53
+ * the caption are not drawn; the label, band and detail are still read.
54
+ * @default "md"
55
+ */
56
+ size?: GaugeSize;
57
+ /** Shown and read in place of the value when it is missing. @default "No data" */
58
+ missingLabel?: string;
42
59
  }
43
60
  export declare const Gauge: import("react").ForwardRefExoticComponent<GaugeProps & import("react").RefAttributes<HTMLElement>>;
44
61
  //#endregion
@@ -18,13 +18,16 @@ function ringFor(sweep) {
18
18
  return `M ${at(START)} A ${RADIUS} ${RADIUS} 0 ${end - START > 180 ? 1 : 0} 1 ${at(end)}`;
19
19
  }
20
20
  const TRACK = ringFor(1);
21
- const Gauge = forwardRef(function Gauge({ value, min = 0, max = 100, label, detail, tone = "primary", thresholds, className, ...props }, ref) {
21
+ const Gauge = forwardRef(function Gauge({ value: given, min = 0, max = 100, label, detail, tone = "primary", thresholds, size = "md", missingLabel = "No data", className, ...props }, ref) {
22
+ const missing = typeof given !== "number" || !Number.isFinite(given);
23
+ const value = missing ? min : given;
24
+ const small = size === "sm";
22
25
  const safeMax = max > min ? max : min + 1;
23
26
  const percentage = clamp((value - min) / (safeMax - min), 0, 1);
24
27
  const displayValue = Math.round(percentage * 100);
25
28
  const bands = [...thresholds ?? []].sort((a, b) => a.from - b.from);
26
29
  const bandAt = (at) => bands.filter((band) => at >= band.from - 1e-6).pop();
27
- const band = bandAt(value);
30
+ const band = missing ? void 0 : bandAt(value);
28
31
  const figure = useRef(null);
29
32
  const refs = useMemo(() => mergeRefs(ref, figure), [ref]);
30
33
  const ring = useRef(null);
@@ -47,14 +50,16 @@ const Gauge = forwardRef(function Gauge({ value, min = 0, max = 100, label, deta
47
50
  bandAt,
48
51
  min,
49
52
  span: safeMax - min,
50
- filled
53
+ filled,
54
+ missing
51
55
  });
52
56
  useIsomorphicLayoutEffect(() => {
53
57
  latest.current = {
54
58
  bandAt,
55
59
  min,
56
60
  span: safeMax - min,
57
- filled
61
+ filled,
62
+ missing
58
63
  };
59
64
  });
60
65
  const shownRef = useRef(shownBand);
@@ -67,9 +72,9 @@ const Gauge = forwardRef(function Gauge({ value, min = 0, max = 100, label, deta
67
72
  ring.current?.setAttribute("d", ringFor(to));
68
73
  if (ring.current) ring.current.style.opacity = String(clamp(to / .02, 0, 1));
69
74
  const shown = clamp(count.current.value, 0, 100);
70
- if (digits.current) digits.current.textContent = String(Math.round(shown));
71
- const { bandAt: pick, min: lo, span, filled: done } = latest.current;
72
- if (done) {
75
+ const { bandAt: pick, min: lo, span, filled: done, missing: empty } = latest.current;
76
+ if (digits.current) digits.current.textContent = empty ? "–" : String(Math.round(shown));
77
+ if (done && !empty) {
73
78
  const next = pick(lo + shown / 100 * span);
74
79
  const current = shownRef.current;
75
80
  if (next?.from !== current?.from || next?.label !== current?.label) {
@@ -111,6 +116,7 @@ const Gauge = forwardRef(function Gauge({ value, min = 0, max = 100, label, deta
111
116
  seen,
112
117
  paint
113
118
  ]);
119
+ useIsomorphicLayoutEffect(paint, [missing, paint]);
114
120
  useEffect(() => {
115
121
  const sweeping = sweep.current;
116
122
  const number = count.current;
@@ -131,23 +137,31 @@ const Gauge = forwardRef(function Gauge({ value, min = 0, max = 100, label, deta
131
137
  observer.observe(node);
132
138
  return () => observer.disconnect();
133
139
  }, []);
134
- const shown = filled ? shownBand : band;
135
- const activeTone = (filled ? shownBand : band)?.tone ?? tone;
140
+ const shown = missing ? void 0 : filled ? shownBand : band;
141
+ const activeTone = shown?.tone ?? tone;
136
142
  const clampedValue = clamp(value, min, safeMax);
143
+ const reading = missing ? {
144
+ role: "img",
145
+ "aria-label": `${label}: ${missingLabel}`
146
+ } : {
147
+ role: "meter",
148
+ "aria-label": label,
149
+ "aria-valuemin": min,
150
+ "aria-valuemax": safeMax,
151
+ "aria-valuenow": clampedValue,
152
+ "aria-valuetext": `${displayValue}%${band ? `, ${band.label}` : ""}`
153
+ };
137
154
  return /* @__PURE__ */ jsxs("figure", {
138
155
  ref: refs,
139
156
  ...props,
140
157
  "data-slot": "gauge",
141
158
  "data-tone": activeTone,
159
+ "data-size": small ? size : void 0,
160
+ "data-missing": missing || void 0,
142
161
  className: cn("gauge", className),
143
162
  children: [/* @__PURE__ */ jsxs("div", {
144
163
  "data-slot": "gauge-visual",
145
- role: "meter",
146
- "aria-label": label,
147
- "aria-valuemin": min,
148
- "aria-valuemax": safeMax,
149
- "aria-valuenow": clampedValue,
150
- "aria-valuetext": `${displayValue}%${band ? `, ${band.label}` : ""}`,
164
+ ...reading,
151
165
  className: "gauge-visual",
152
166
  children: [/* @__PURE__ */ jsxs("svg", {
153
167
  viewBox: "0 0 100 90",
@@ -172,28 +186,28 @@ const Gauge = forwardRef(function Gauge({ value, min = 0, max = 100, label, deta
172
186
  ref: digits,
173
187
  className: "gauge-digits"
174
188
  }),
175
- /* @__PURE__ */ jsx("span", {
189
+ small || missing ? null : /* @__PURE__ */ jsx("span", {
176
190
  className: "gauge-unit",
177
191
  children: "%"
178
192
  }),
179
193
  /* @__PURE__ */ jsx("span", {
180
194
  ref: sizer,
181
195
  className: "gauge-sizer",
182
- children: displayValue
196
+ children: missing ? "–" : displayValue
183
197
  })
184
198
  ]
185
- }), thresholds ? /* @__PURE__ */ jsx("span", {
199
+ }), !small && (thresholds || missing) ? /* @__PURE__ */ jsx("span", {
186
200
  className: "gauge-status",
187
201
  children: /* @__PURE__ */ jsx(SwapText, {
188
- swapKey: filled ? shown?.label ?? "" : "",
202
+ swapKey: missing ? missingLabel : filled ? shown?.label ?? "" : "",
189
203
  dir: moved.direction,
190
204
  className: "block",
191
- children: shown?.label
205
+ children: missing ? missingLabel : shown?.label
192
206
  })
193
207
  }) : null]
194
208
  })]
195
209
  }), /* @__PURE__ */ jsxs("figcaption", {
196
- className: "grid min-w-0 gap-0.5",
210
+ className: small ? "sr-only" : "grid min-w-0 gap-0.5",
197
211
  children: [/* @__PURE__ */ jsx("strong", {
198
212
  className: "text-sm font-medium text-foreground",
199
213
  children: /* @__PURE__ */ jsx(SwapText, {
@@ -1 +1 @@
1
- {"version":3,"file":"gauge.js","names":[],"sources":["../../src/gauge/gauge.tsx"],"sourcesContent":["\"use client\";\n\nimport {\n forwardRef,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ComponentPropsWithoutRef,\n} from \"react\";\n\nimport { cn } from \"../lib/cn.js\";\nimport { mergeRefs } from \"../lib/merge-refs.js\";\nimport { prefersReducedMotion, useIsomorphicLayoutEffect } from \"../lib/motion.js\";\nimport { live, MORPH, retarget, REVEAL, SMOOTH } from \"../lib/spring-tween.js\";\nimport { SwapText } from \"../lib/swap-text.js\";\n\n/**\n * Gauge: one value against a range, as a ring open at the bottom with the\n * number at its centre. Use it for a score or a fill level; use Progress for a\n * task that finishes.\n *\n * The ring fills the first time it is seen, slowly and without overshoot, like\n * a ring closing. After that it springs to each new value with a little life\n * while the count follows without overshooting. A band that starts at\n * `from` sets the colour and names the state (\"Healthy\", \"At risk\"): the\n * highest band the value reaches wins, and it changes exactly as the count\n * crosses the threshold, so state never rests on colour alone. The label,\n * status and detail rise in when they change, from the side the value moved\n * toward.\n *\n * Plain SVG and CSS. One small spring tween writes the ring and the count\n * straight to the DOM. The ring is a `meter` for assistive technology.\n */\nexport type GaugeTone = \"primary\" | \"success\" | \"warning\" | \"danger\";\n\nexport interface GaugeThreshold {\n /** The band starts at this value (in the same unit as `value`). */\n from: number;\n tone: GaugeTone;\n /** Names the state. Shown under the number and announced with the value. */\n label: string;\n}\n\nexport interface GaugeProps extends Omit<ComponentPropsWithoutRef<\"figure\">, \"children\"> {\n value: number;\n /** @default 0 */\n min?: number;\n /** @default 100 */\n max?: number;\n /** What is measured. Names the meter. */\n label: string;\n /** A line under the label, such as \"Up 4 from last week\". */\n detail?: string;\n /** The colour when no band applies. @default \"primary\" */\n tone?: GaugeTone;\n /** Bands that set the tone and name the state. */\n thresholds?: GaugeThreshold[];\n}\n\n/** A 270 degree ring open at the bottom, drawn clockwise from its lower left end. Redrawn from the sweep every frame so the round cap stays exact. */\nconst RADIUS = 42;\nconst START = -135;\nconst SPAN = 270;\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\n\nfunction ringFor(sweep: number) {\n const end = START + SPAN * clamp(sweep, 0, 1);\n const at = (angle: number) =>\n `${(50 + RADIUS * Math.sin((angle * Math.PI) / 180)).toFixed(3)} ${(50 - RADIUS * Math.cos((angle * Math.PI) / 180)).toFixed(3)}`;\n return `M ${at(START)} A ${RADIUS} ${RADIUS} 0 ${end - START > 180 ? 1 : 0} 1 ${at(end)}`;\n}\nconst TRACK = ringFor(1);\n\nexport const Gauge = forwardRef<HTMLElement, GaugeProps>(function Gauge(\n { value, min = 0, max = 100, label, detail, tone = \"primary\", thresholds, className, ...props },\n ref,\n) {\n const safeMax = max > min ? max : min + 1;\n const percentage = clamp((value - min) / (safeMax - min), 0, 1);\n const displayValue = Math.round(percentage * 100);\n const bands = [...(thresholds ?? [])].sort((a, b) => a.from - b.from);\n const bandAt = (at: number) => bands.filter((band) => at >= band.from - 1e-6).pop();\n const band = bandAt(value);\n\n const figure = useRef<HTMLElement | null>(null);\n const refs = useMemo(() => mergeRefs(ref, figure), [ref]);\n const ring = useRef<SVGPathElement>(null);\n const digits = useRef<HTMLSpanElement>(null);\n const sizer = useRef<HTMLSpanElement>(null);\n const sweep = useRef(live(0));\n const count = useRef(live(0));\n\n const [seen, setSeen] = useState(false);\n const [filled, setFilled] = useState(false);\n // The band on screen: after the first fill it follows the number as it counts.\n const [shownBand, setShownBand] = useState(band);\n const [moved, setMoved] = useState({ value: displayValue, direction: 1 });\n if (moved.value !== displayValue) {\n setMoved({ value: displayValue, direction: displayValue < moved.value ? -1 : 1 });\n }\n\n const latest = useRef({ bandAt, min, span: safeMax - min, filled });\n useIsomorphicLayoutEffect(() => {\n latest.current = { bandAt, min, span: safeMax - min, filled };\n });\n const shownRef = useRef(shownBand);\n useIsomorphicLayoutEffect(() => {\n shownRef.current = shownBand;\n });\n\n /** One writer: the ring, its fade-in over the first sliver, and the count. */\n const paint = useCallback(() => {\n const to = clamp(sweep.current.value, 0, 1);\n ring.current?.setAttribute(\"d\", ringFor(to));\n if (ring.current) ring.current.style.opacity = String(clamp(to / 0.02, 0, 1));\n const shown = clamp(count.current.value, 0, 100);\n if (digits.current) digits.current.textContent = String(Math.round(shown));\n // After the first fill the state follows the number on screen, so the colour and label change as the count crosses a threshold.\n const { bandAt: pick, min: lo, span, filled: done } = latest.current;\n if (done) {\n const next = pick(lo + (shown / 100) * span);\n const current = shownRef.current;\n if (next?.from !== current?.from || next?.label !== current?.label) {\n shownRef.current = next;\n setShownBand(next);\n }\n }\n }, []);\n\n // The ring fills once half of it is in view.\n useEffect(() => {\n const node = figure.current;\n if (!node || prefersReducedMotion() || typeof IntersectionObserver === \"undefined\") {\n setSeen(true);\n return;\n }\n const observer = new IntersectionObserver(\n ([entry]) => {\n if (!entry?.isIntersecting) return;\n observer.disconnect();\n setSeen(true);\n },\n { threshold: 0.5 },\n );\n observer.observe(node);\n return () => observer.disconnect();\n }, []);\n\n // The first fill is slower and never overshoots; later values spring to place, the count a beat calmer.\n const bandRef = useRef(band);\n useIsomorphicLayoutEffect(() => {\n bandRef.current = band;\n });\n useEffect(() => {\n if (!seen) return;\n const first = !latest.current.filled;\n retarget(sweep.current, percentage, first ? REVEAL : MORPH, paint);\n retarget(\n count.current,\n percentage * 100,\n first ? REVEAL : SMOOTH,\n paint,\n 0,\n first\n ? () => {\n latest.current.filled = true;\n shownRef.current = bandRef.current;\n setShownBand(bandRef.current);\n setFilled(true);\n }\n : undefined,\n );\n }, [percentage, seen, paint]);\n useEffect(() => {\n const sweeping = sweep.current;\n const number = count.current;\n return () => {\n sweeping.stop();\n number.stop();\n };\n }, []);\n useIsomorphicLayoutEffect(paint, [paint]);\n\n // The digits sit right aligned in a box sized to the target number, so the ones column never jumps while counting and the group recentres when the number gains or loses a digit.\n useEffect(() => {\n const node = sizer.current;\n const box = digits.current;\n if (!node || !box || typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(([entry]) => {\n if (!entry) return;\n box.style.width = `${entry.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth}px`;\n });\n observer.observe(node);\n return () => observer.disconnect();\n }, []);\n\n const shown = filled ? shownBand : band;\n const activeTone = (filled ? shownBand : band)?.tone ?? tone;\n const clampedValue = clamp(value, min, safeMax);\n\n return (\n <figure\n ref={refs}\n {...props}\n data-slot=\"gauge\"\n data-tone={activeTone}\n className={cn(\"gauge\", className)}\n >\n <div\n data-slot=\"gauge-visual\"\n role=\"meter\"\n aria-label={label}\n aria-valuemin={min}\n aria-valuemax={safeMax}\n aria-valuenow={clampedValue}\n aria-valuetext={`${displayValue}%${band ? `, ${band.label}` : \"\"}`}\n className=\"gauge-visual\"\n >\n <svg\n viewBox=\"0 0 100 90\"\n aria-hidden=\"true\"\n focusable=\"false\"\n className=\"block size-full overflow-visible\"\n >\n <path d={TRACK} className=\"gauge-track\" />\n <path ref={ring} className=\"gauge-fill\" style={{ opacity: 0 }} />\n </svg>\n <div aria-hidden=\"true\" className=\"gauge-readout\">\n <span className=\"gauge-number\">\n <span ref={digits} className=\"gauge-digits\" />\n <span className=\"gauge-unit\">%</span>\n <span ref={sizer} className=\"gauge-sizer\">\n {displayValue}\n </span>\n </span>\n {thresholds ? (\n <span className=\"gauge-status\">\n <SwapText\n swapKey={filled ? (shown?.label ?? \"\") : \"\"}\n dir={moved.direction}\n className=\"block\"\n >\n {shown?.label}\n </SwapText>\n </span>\n ) : null}\n </div>\n </div>\n <figcaption className=\"grid min-w-0 gap-0.5\">\n <strong className=\"text-sm font-medium text-foreground\">\n <SwapText swapKey={label} className=\"block\">\n {label}\n </SwapText>\n </strong>\n {detail ? (\n <span className=\"text-xs tabular-nums text-text-secondary\">\n <SwapText swapKey={detail} dir={moved.direction} className=\"block\">\n {detail}\n </SwapText>\n </span>\n ) : null}\n </figcaption>\n </figure>\n );\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"gauge.js","names":[],"sources":["../../src/gauge/gauge.tsx"],"sourcesContent":["\"use client\";\n\nimport {\n forwardRef,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ComponentPropsWithoutRef,\n} from \"react\";\n\nimport { cn } from \"../lib/cn.js\";\nimport { mergeRefs } from \"../lib/merge-refs.js\";\nimport { prefersReducedMotion, useIsomorphicLayoutEffect } from \"../lib/motion.js\";\nimport { live, MORPH, retarget, REVEAL, SMOOTH } from \"../lib/spring-tween.js\";\nimport { SwapText } from \"../lib/swap-text.js\";\n\n/**\n * Gauge: one value against a range, as a ring open at the bottom with the\n * number at its centre. Use it for a score or a fill level; use Progress for a\n * task that finishes.\n *\n * The ring fills the first time it is seen, slowly and without overshoot, like\n * a ring closing. After that it springs to each new value with a little life\n * while the count follows without overshooting. A band that starts at\n * `from` sets the colour and names the state (\"Healthy\", \"At risk\"): the\n * highest band the value reaches wins, and it changes exactly as the count\n * crosses the threshold, so state never rests on colour alone. The label,\n * status and detail rise in when they change, from the side the value moved\n * toward.\n *\n * A missing value (`null`) shows an empty ring, a dash and \"No data\", and is\n * read as an image named \"<label>: No data\" rather than a meter, since there\n * is nothing to measure. Size `sm` is the number alone in a small ring, for a\n * table cell or a card corner; the label and detail stay for assistive\n * technology.\n *\n * Plain SVG and CSS. One small spring tween writes the ring and the count\n * straight to the DOM. The ring is a `meter` for assistive technology.\n */\nexport type GaugeTone = \"primary\" | \"success\" | \"warning\" | \"danger\";\n\nexport type GaugeSize = \"sm\" | \"md\";\n\nexport interface GaugeThreshold {\n /** The band starts at this value (in the same unit as `value`). */\n from: number;\n tone: GaugeTone;\n /** Names the state. Shown under the number and announced with the value. */\n label: string;\n}\n\nexport interface GaugeProps extends Omit<ComponentPropsWithoutRef<\"figure\">, \"children\"> {\n /** The value. `null` (or a value that is not a finite number) is no data: an empty ring that reads `missingLabel`. */\n value: number | null;\n /** @default 0 */\n min?: number;\n /** @default 100 */\n max?: number;\n /** What is measured. Names the meter. */\n label: string;\n /** A line under the label, such as \"Up 4 from last week\". */\n detail?: string;\n /** The colour when no band applies. @default \"primary\" */\n tone?: GaugeTone;\n /** Bands that set the tone and name the state. */\n thresholds?: GaugeThreshold[];\n /**\n * `\"sm\"` is the number alone in a small ring (48px; set `--gauge-size` to\n * change it), coloured by the tone. The percent sign, the status line and\n * the caption are not drawn; the label, band and detail are still read.\n * @default \"md\"\n */\n size?: GaugeSize;\n /** Shown and read in place of the value when it is missing. @default \"No data\" */\n missingLabel?: string;\n}\n\n/** A 270 degree ring open at the bottom, drawn clockwise from its lower left end. Redrawn from the sweep every frame so the round cap stays exact. */\nconst RADIUS = 42;\nconst START = -135;\nconst SPAN = 270;\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\n\nfunction ringFor(sweep: number) {\n const end = START + SPAN * clamp(sweep, 0, 1);\n const at = (angle: number) =>\n `${(50 + RADIUS * Math.sin((angle * Math.PI) / 180)).toFixed(3)} ${(50 - RADIUS * Math.cos((angle * Math.PI) / 180)).toFixed(3)}`;\n return `M ${at(START)} A ${RADIUS} ${RADIUS} 0 ${end - START > 180 ? 1 : 0} 1 ${at(end)}`;\n}\nconst TRACK = ringFor(1);\n\nexport const Gauge = forwardRef<HTMLElement, GaugeProps>(function Gauge(\n {\n value: given,\n min = 0,\n max = 100,\n label,\n detail,\n tone = \"primary\",\n thresholds,\n size = \"md\",\n missingLabel = \"No data\",\n className,\n ...props\n },\n ref,\n) {\n const missing = typeof given !== \"number\" || !Number.isFinite(given);\n const value = missing ? min : given;\n const small = size === \"sm\";\n const safeMax = max > min ? max : min + 1;\n const percentage = clamp((value - min) / (safeMax - min), 0, 1);\n const displayValue = Math.round(percentage * 100);\n const bands = [...(thresholds ?? [])].sort((a, b) => a.from - b.from);\n const bandAt = (at: number) => bands.filter((band) => at >= band.from - 1e-6).pop();\n const band = missing ? undefined : bandAt(value);\n\n const figure = useRef<HTMLElement | null>(null);\n const refs = useMemo(() => mergeRefs(ref, figure), [ref]);\n const ring = useRef<SVGPathElement>(null);\n const digits = useRef<HTMLSpanElement>(null);\n const sizer = useRef<HTMLSpanElement>(null);\n const sweep = useRef(live(0));\n const count = useRef(live(0));\n\n const [seen, setSeen] = useState(false);\n const [filled, setFilled] = useState(false);\n // The band on screen: after the first fill it follows the number as it counts.\n const [shownBand, setShownBand] = useState(band);\n const [moved, setMoved] = useState({ value: displayValue, direction: 1 });\n if (moved.value !== displayValue) {\n setMoved({ value: displayValue, direction: displayValue < moved.value ? -1 : 1 });\n }\n\n const latest = useRef({ bandAt, min, span: safeMax - min, filled, missing });\n useIsomorphicLayoutEffect(() => {\n latest.current = { bandAt, min, span: safeMax - min, filled, missing };\n });\n const shownRef = useRef(shownBand);\n useIsomorphicLayoutEffect(() => {\n shownRef.current = shownBand;\n });\n\n /** One writer: the ring, its fade-in over the first sliver, and the count. */\n const paint = useCallback(() => {\n const to = clamp(sweep.current.value, 0, 1);\n ring.current?.setAttribute(\"d\", ringFor(to));\n if (ring.current) ring.current.style.opacity = String(clamp(to / 0.02, 0, 1));\n const shown = clamp(count.current.value, 0, 100);\n const { bandAt: pick, min: lo, span, filled: done, missing: empty } = latest.current;\n if (digits.current) digits.current.textContent = empty ? \"–\" : String(Math.round(shown));\n // After the first fill the state follows the number on screen, so the colour and label change as the count crosses a threshold.\n if (done && !empty) {\n const next = pick(lo + (shown / 100) * span);\n const current = shownRef.current;\n if (next?.from !== current?.from || next?.label !== current?.label) {\n shownRef.current = next;\n setShownBand(next);\n }\n }\n }, []);\n\n // The ring fills once half of it is in view.\n useEffect(() => {\n const node = figure.current;\n if (!node || prefersReducedMotion() || typeof IntersectionObserver === \"undefined\") {\n setSeen(true);\n return;\n }\n const observer = new IntersectionObserver(\n ([entry]) => {\n if (!entry?.isIntersecting) return;\n observer.disconnect();\n setSeen(true);\n },\n { threshold: 0.5 },\n );\n observer.observe(node);\n return () => observer.disconnect();\n }, []);\n\n // The first fill is slower and never overshoots; later values spring to place, the count a beat calmer.\n const bandRef = useRef(band);\n useIsomorphicLayoutEffect(() => {\n bandRef.current = band;\n });\n useEffect(() => {\n if (!seen) return;\n const first = !latest.current.filled;\n retarget(sweep.current, percentage, first ? REVEAL : MORPH, paint);\n retarget(\n count.current,\n percentage * 100,\n first ? REVEAL : SMOOTH,\n paint,\n 0,\n first\n ? () => {\n latest.current.filled = true;\n shownRef.current = bandRef.current;\n setShownBand(bandRef.current);\n setFilled(true);\n }\n : undefined,\n );\n }, [percentage, seen, paint]);\n // A value that goes missing (or comes back) at the same percentage still repaints the digits.\n useIsomorphicLayoutEffect(paint, [missing, paint]);\n useEffect(() => {\n const sweeping = sweep.current;\n const number = count.current;\n return () => {\n sweeping.stop();\n number.stop();\n };\n }, []);\n useIsomorphicLayoutEffect(paint, [paint]);\n\n // The digits sit right aligned in a box sized to the target number, so the ones column never jumps while counting and the group recentres when the number gains or loses a digit.\n useEffect(() => {\n const node = sizer.current;\n const box = digits.current;\n if (!node || !box || typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(([entry]) => {\n if (!entry) return;\n box.style.width = `${entry.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth}px`;\n });\n observer.observe(node);\n return () => observer.disconnect();\n }, []);\n\n const shown = missing ? undefined : filled ? shownBand : band;\n const activeTone = shown?.tone ?? tone;\n const clampedValue = clamp(value, min, safeMax);\n const reading = missing\n ? { role: \"img\", \"aria-label\": `${label}: ${missingLabel}` }\n : {\n role: \"meter\",\n \"aria-label\": label,\n \"aria-valuemin\": min,\n \"aria-valuemax\": safeMax,\n \"aria-valuenow\": clampedValue,\n \"aria-valuetext\": `${displayValue}%${band ? `, ${band.label}` : \"\"}`,\n };\n\n return (\n <figure\n ref={refs}\n {...props}\n data-slot=\"gauge\"\n data-tone={activeTone}\n data-size={small ? size : undefined}\n data-missing={missing || undefined}\n className={cn(\"gauge\", className)}\n >\n <div data-slot=\"gauge-visual\" {...reading} className=\"gauge-visual\">\n <svg\n viewBox=\"0 0 100 90\"\n aria-hidden=\"true\"\n focusable=\"false\"\n className=\"block size-full overflow-visible\"\n >\n <path d={TRACK} className=\"gauge-track\" />\n <path ref={ring} className=\"gauge-fill\" style={{ opacity: 0 }} />\n </svg>\n <div aria-hidden=\"true\" className=\"gauge-readout\">\n <span className=\"gauge-number\">\n <span ref={digits} className=\"gauge-digits\" />\n {small || missing ? null : <span className=\"gauge-unit\">%</span>}\n <span ref={sizer} className=\"gauge-sizer\">\n {missing ? \"–\" : displayValue}\n </span>\n </span>\n {!small && (thresholds || missing) ? (\n <span className=\"gauge-status\">\n <SwapText\n swapKey={missing ? missingLabel : filled ? (shown?.label ?? \"\") : \"\"}\n dir={moved.direction}\n className=\"block\"\n >\n {missing ? missingLabel : shown?.label}\n </SwapText>\n </span>\n ) : null}\n </div>\n </div>\n <figcaption className={small ? \"sr-only\" : \"grid min-w-0 gap-0.5\"}>\n <strong className=\"text-sm font-medium text-foreground\">\n <SwapText swapKey={label} className=\"block\">\n {label}\n </SwapText>\n </strong>\n {detail ? (\n <span className=\"text-xs tabular-nums text-text-secondary\">\n <SwapText swapKey={detail} dir={moved.direction} className=\"block\">\n {detail}\n </SwapText>\n </span>\n ) : null}\n </figcaption>\n </figure>\n );\n});\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- import { Gauge, GaugeProps, GaugeThreshold, GaugeTone } from "./gauge.js";
2
- export { Gauge, type GaugeProps, type GaugeThreshold, type GaugeTone };
1
+ import { Gauge, GaugeProps, GaugeSize, GaugeThreshold, GaugeTone } from "./gauge.js";
2
+ export { Gauge, type GaugeProps, type GaugeSize, type GaugeThreshold, type GaugeTone };