@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
@@ -1,12 +1,13 @@
1
1
  "use client";
2
- import { cn } from "../lib/cn.js";
2
+ import { cn, cnAll } from "../lib/cn.js";
3
3
  import { SwapText } from "../lib/swap-text.js";
4
4
  import { AnimatedCounter } from "../animated-counter/animated-counter.js";
5
5
  import { Badge } from "../badge/badge.js";
6
+ import { skeletonVariants } from "../skeleton/skeleton.js";
6
7
  import { cva } from "class-variance-authority";
7
8
  import { forwardRef, useState } from "react";
8
9
  import { Button, Link, ToggleButton } from "react-aria-components";
9
- import { jsx, jsxs } from "react/jsx-runtime";
10
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
10
11
  //#region src/stat-card/stat-card.tsx
11
12
  /**
12
13
  * StatCard: one headline figure with its label and a line of context. A row
@@ -41,6 +42,12 @@ import { jsx, jsxs } from "react/jsx-runtime";
41
42
  * it), a button (`onPress`), or a filter toggle (`isSelected` + `onPress`, a
42
43
  * React Aria ToggleButton with `aria-pressed` and one shared selected look).
43
44
  *
45
+ * `isLoading` keeps the card's box while its figure is fetched: the label
46
+ * stays (or pulses as a placeholder when it is empty), the figure and the
47
+ * context line become placeholder blocks the height of the lines they stand
48
+ * in for, and the change pill pulses at its own size. So a row of loading cards is exactly as
49
+ * tall as the row that replaces it and nothing shifts when the data lands.
50
+ *
44
51
  * Every string in the cva blocks below is a STATIC literal. Tailwind scans
45
52
  * source text, so a class assembled at runtime is invisible to it and would
46
53
  * simply never be emitted.
@@ -92,6 +99,18 @@ tone: {
92
99
  /** An optional icon before the label. Decorative: the label names the figure. */
93
100
  const statCardIconVariants = cva("flex shrink-0 items-center text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0");
94
101
  const statCardSubVariants = cva("m-0 mt-4 text-sm leading-(--leading-normal) text-muted-foreground");
102
+ /**
103
+ * A placeholder standing in for one line of copy while the card loads. It sits
104
+ * inside the line it replaces and is one line-height tall (`1lh`), so the line
105
+ * box, and with it the card, keeps the height of the loaded copy.
106
+ */
107
+ const statCardSkeletonVariants = cva("inline-block h-[1lh] max-w-full align-top", { variants: {
108
+ /** Which line it stands in for: each has the rough width of its copy. */
109
+ part: {
110
+ label: "w-24",
111
+ value: "w-28",
112
+ sub: "w-36"
113
+ } } });
95
114
  /** The number in a piece of text, for the direction a swap moves: a larger one rises from below. */
96
115
  const amountIn = (text) => Number(text.replace(/,/g, "").match(/-?\d+(?:\.\d+)?/)?.[0] ?? NaN);
97
116
  /** 1 when the new text holds a number that did not shrink, -1 when it did. Updated during render. */
@@ -162,6 +181,8 @@ const BADGE_TONE = {
162
181
  negative: "danger",
163
182
  neutral: "neutral"
164
183
  };
184
+ /** The swap key of an empty label's placeholder: a key no label text can be. */
185
+ const LOADING_KEY = "\0loading";
165
186
  /** Only the `data-*` attributes, so nothing else in the rest object reaches the DOM. */
166
187
  const dataAttributes = (rest) => Object.fromEntries(Object.entries(rest).filter(([key]) => key.startsWith("data-")));
167
188
  /**
@@ -174,10 +195,17 @@ const dataAttributes = (rest) => Object.fromEntries(Object.entries(rest).filter(
174
195
  * <StatCard label="Collected" value="$9,800" tone="positive" href="/payments" />
175
196
  * </div>
176
197
  */
177
- const StatCard = forwardRef(function StatCard({ label, value, sub, change, trend = "normal", changeTone, icon, tone, isSelected, href, onPress, description, children, className, ...rest }, ref) {
198
+ const StatCard = forwardRef(function StatCard({ label, value, sub, change, trend = "normal", changeTone, icon, tone, isSelected, href, onPress, description, isLoading = false, loadingLabel = "Loading", children, className, ...rest }, ref) {
178
199
  const figure = figureOf(value);
200
+ const placeholder = (part) => /* @__PURE__ */ jsx("span", {
201
+ "data-slot": "stat-card-skeleton",
202
+ "aria-hidden": "true",
203
+ className: cnAll(skeletonVariants(), statCardSkeletonVariants({ part }))
204
+ });
179
205
  const meaning = change ? changeMeaning(change, trend, changeTone) : "neutral";
180
206
  const body = /* @__PURE__ */ jsxs("span", {
207
+ "data-slot": "stat-card-body",
208
+ "aria-busy": isLoading || void 0,
181
209
  className: "flex min-w-0 flex-1 flex-col",
182
210
  title: description,
183
211
  children: [
@@ -194,16 +222,27 @@ const StatCard = forwardRef(function StatCard({ label, value, sub, change, trend
194
222
  "data-slot": "stat-card-label",
195
223
  className: cn(statCardLabelVariants(), "block"),
196
224
  children: /* @__PURE__ */ jsx(Swap, {
197
- text: label,
225
+ text: isLoading && label === "" ? LOADING_KEY : label,
198
226
  className: "*:max-w-full",
199
227
  children: /* @__PURE__ */ jsx("span", {
200
228
  className: "block truncate",
201
229
  title: label,
202
- children: label
230
+ children: isLoading && label === "" ? placeholder("label") : label
203
231
  })
204
232
  })
205
233
  }),
206
- change ? /* @__PURE__ */ jsx(Badge, {
234
+ change && isLoading ? /* @__PURE__ */ jsx("span", {
235
+ "data-slot": "stat-card-skeleton",
236
+ "aria-hidden": "true",
237
+ className: "box-border inline-flex shrink-0 rounded-pill bg-muted motion-safe:animate-skeleton-pulse",
238
+ children: /* @__PURE__ */ jsx(Badge, {
239
+ shape: "pill",
240
+ tone: "neutral",
241
+ appearance: "outline",
242
+ className: "invisible tabular-nums",
243
+ children: change
244
+ })
245
+ }) : change ? /* @__PURE__ */ jsx(Badge, {
207
246
  shape: "pill",
208
247
  tone: BADGE_TONE[meaning],
209
248
  appearance: meaning === "neutral" ? "outline" : "soft",
@@ -216,7 +255,10 @@ const StatCard = forwardRef(function StatCard({ label, value, sub, change, trend
216
255
  "data-slot": "stat-card-value",
217
256
  "data-tone": tone ?? "neutral",
218
257
  className: cn(statCardValueVariants({ tone }), "block"),
219
- children: figure ? /* @__PURE__ */ jsx(AnimatedCounter, {
258
+ children: isLoading ? /* @__PURE__ */ jsxs(Fragment$1, { children: [placeholder("value"), /* @__PURE__ */ jsx("span", {
259
+ className: "sr-only",
260
+ children: loadingLabel
261
+ })] }) : figure ? /* @__PURE__ */ jsx(AnimatedCounter, {
220
262
  value: figure.value,
221
263
  decimals: figure.decimals,
222
264
  prefix: figure.prefix,
@@ -227,7 +269,7 @@ const StatCard = forwardRef(function StatCard({ label, value, sub, change, trend
227
269
  sub === void 0 ? null : /* @__PURE__ */ jsx("span", {
228
270
  "data-slot": "stat-card-sub",
229
271
  className: cn(statCardSubVariants(), "block"),
230
- children: typeof sub === "string" ? /* @__PURE__ */ jsx(Swap, {
272
+ children: isLoading ? placeholder("sub") : typeof sub === "string" ? /* @__PURE__ */ jsx(Swap, {
231
273
  text: sub,
232
274
  children: sub
233
275
  }) : sub
@@ -242,6 +284,7 @@ const StatCard = forwardRef(function StatCard({ label, value, sub, change, trend
242
284
  const common = {
243
285
  ...dataAttributes(rest),
244
286
  "data-slot": "stat-card",
287
+ "data-loading": isLoading || void 0,
245
288
  "aria-label": rest["aria-label"]
246
289
  };
247
290
  if (isSelected !== void 0) return /* @__PURE__ */ jsx(ToggleButton, {
@@ -274,6 +317,6 @@ const StatCard = forwardRef(function StatCard({ label, value, sub, change, trend
274
317
  });
275
318
  });
276
319
  //#endregion
277
- export { StatCard, statCardHeaderVariants, statCardIconVariants, statCardLabelVariants, statCardSubVariants, statCardValueVariants, statCardVariants };
320
+ export { StatCard, statCardHeaderVariants, statCardIconVariants, statCardLabelVariants, statCardSkeletonVariants, statCardSubVariants, statCardValueVariants, statCardVariants };
278
321
 
279
322
  //# sourceMappingURL=stat-card.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"stat-card.js","names":["AriaToggleButton","AriaButton","AriaLink"],"sources":["../../src/stat-card/stat-card.tsx"],"sourcesContent":["\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { forwardRef, useState, type ReactNode, type Ref } from \"react\";\nimport {\n Button as AriaButton,\n Link as AriaLink,\n ToggleButton as AriaToggleButton,\n} from \"react-aria-components\";\n\nimport { AnimatedCounter } from \"../animated-counter/animated-counter.js\";\nimport { Badge } from \"../badge/badge.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { SwapText } from \"../lib/swap-text.js\";\n\n/**\n * StatCard: one headline figure with its label and a line of context. A row\n * of them is a dashboard's summary strip.\n *\n * It exists because a summary strip is on almost every overview screen, and\n * without a shared card each screen picks its own label case, number size,\n * colour and height, so the same figure looks different on two pages.\n *\n * The figure is a counter: a number rolls up to its value the first time the\n * card scrolls into view, and when `value` changes the digits that changed roll\n * to their new faces, the way the number moved. A label, a line of context or\n * a change that is replaced rises in while the old copy lifts away. Reduced\n * motion jumps and fades instead. A value that is not a plain number\n * (a date, \"N/A\", a node) is shown as it is.\n *\n * Rules it holds:\n *\n * - The label is ONE line. A long label is cut with an ellipsis and the full\n * text is on hover, so a row of cards keeps its numbers on one baseline\n * instead of one card's label wrapping and pushing its number down.\n * - The number's colour comes from `tone`, a fixed set of meanings, never a\n * colour. `neutral` is the default and is right for any figure without a\n * verdict in it, including every zero.\n * - Cards in a row are the same height (`h-full` in a grid or flex row).\n * - The change pill's colour is what the change MEANS. By default a rise is\n * good; `trend=\"inverse\"` reads a rise as bad (bounce rate, cost, keywords\n * that dropped out), `trend=\"neutral\"` gives no verdict, and `changeTone`\n * names the meaning outright when the text carries no sign.\n *\n * A card can be a link (`href`, React Aria Link, so a router provider routes\n * it), a button (`onPress`), or a filter toggle (`isSelected` + `onPress`, a\n * React Aria ToggleButton with `aria-pressed` and one shared selected look).\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 statCardVariants = cva(\n [\n \"flex h-full min-w-0 flex-col p-6 text-start\",\n \"rounded-panel border-(length:--border-width) bg-surface text-foreground shadow-resting\",\n // box-border and font-sans are not boilerplate: the reset in\n // @seekalfred/styles is opt-in, so a consumer may load none.\n \"box-border font-sans\",\n ],\n {\n variants: {\n /** A filter card that is the one applied. */\n selected: {\n true: \"border-primary-strong\",\n false: \"border-border\",\n },\n /** Whether the card is a link or a button (hover and focus states). */\n interactive: {\n true: [\n \"cursor-pointer outline-none\",\n // A border, not a fill: a muted fill under the muted label fails contrast.\n \"data-[hovered]:not-data-[selected]:border-muted-foreground\",\n // outline-solid on the focus variant is load-bearing: outline-none\n // sets the outline style the width utility resolves from.\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 ],\n false: \"\",\n },\n },\n defaultVariants: { selected: false, interactive: false },\n },\n);\n\n/** The top row: the label on the left, the change pill on the right. */\nexport const statCardHeaderVariants = cva(\"mb-8 flex min-h-6 items-center justify-between gap-3\");\n\nexport const statCardLabelVariants = cva([\n \"m-0 min-w-0 flex-1 text-sm leading-(--leading-normal) font-normal\",\n \"text-text-secondary\",\n]);\n\nexport const statCardValueVariants = cva(\n // The counter sets its own colour; this makes it follow the tone instead.\n // leading-* AFTER text-*: tailwind-merge treats a later text-* as owning\n // line-height and silently strips a leading-* that precedes it.\n \"m-0 font-display text-h2 leading-none font-semibold tabular-nums break-words **:data-[slot=animated-counter]:text-inherit\",\n {\n variants: {\n /** What the number MEANS. Colour follows from it; it is never picked directly. */\n tone: {\n neutral: \"text-foreground\",\n positive: \"text-success-strong\",\n negative: \"text-destructive-strong\",\n warning: \"text-warning-strong\",\n attention: \"text-primary-strong\",\n },\n },\n defaultVariants: { tone: \"neutral\" },\n },\n);\n\n/** An optional icon before the label. Decorative: the label names the figure. */\nexport const statCardIconVariants = cva(\n \"flex shrink-0 items-center text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0\",\n);\n\nexport const statCardSubVariants = cva(\n \"m-0 mt-4 text-sm leading-(--leading-normal) text-muted-foreground\",\n);\n\nexport type StatCardTone = \"neutral\" | \"positive\" | \"negative\" | \"warning\" | \"attention\";\n\n/** Which way is good for this figure: up (`normal`), down (`inverse`), or neither (`neutral`). */\nexport type StatCardTrend = \"normal\" | \"inverse\" | \"neutral\";\n\n/** What a change means, whatever its sign. */\nexport type StatCardChangeTone = \"positive\" | \"negative\" | \"neutral\";\n\nexport interface StatCardProps {\n /** What the figure is. One line; cut with an ellipsis, full text on hover. */\n label: string;\n /**\n * The figure itself, already formatted. A plain number (`12400`), or a string\n * made of a number with text around it (`\"$12,400\"`, `\"42 days\"`, `\"98.5%\"`),\n * counts up on first view and rolls to each new value. Anything else is\n * shown as it is.\n */\n value: ReactNode;\n /** One line of context under the figure. A string that changes swaps in place. */\n sub?: ReactNode;\n /**\n * A change shown as a pill beside the label, such as `\"+12.4%\"`. A leading `+`\n * reads green and a leading `-` red; no sign is neutral. A new value swaps in\n * place and the pill follows its width.\n */\n change?: string;\n /**\n * Which direction of `change` is good. `normal` (default): a leading `+`\n * reads green and a leading `-` red. `inverse`: a rise reads red and a fall\n * green, for a figure where more is worse (bounce rate, cost per click,\n * keywords that fell out of the top 100). `neutral`: no colour either way.\n * @default \"normal\"\n */\n trend?: StatCardTrend;\n /**\n * The meaning of `change`, named outright. Wins over `trend` and the sign;\n * use it when the text has no `+` or `-` (`\"▲4\"`) or the verdict comes from\n * elsewhere. It colours only the change pill, never the value (that is\n * `tone`).\n */\n changeTone?: StatCardChangeTone;\n /**\n * An icon before the label. Decorative (hidden from assistive technology).\n * An SVG is drawn at 16px, whatever size it is given. A falsy value\n * (`false`, `null`, `\"\"`) renders nothing, so `icon={cond && <Icon />}` works.\n */\n icon?: ReactNode;\n /**\n * What the figure means: `neutral` (default, and right for every zero),\n * `positive`, `negative`, `warning` (part-way or waiting) or `attention`\n * (a person has to look).\n */\n tone?: StatCardTone;\n /**\n * The card is a filter, and this says whether it is the one applied.\n * Renders a toggle button with `aria-pressed`. Pair with `onPress`.\n */\n isSelected?: boolean;\n /** Renders the card as a link to this URL. */\n href?: string;\n /** Renders the card as a button (or, with `isSelected`, a toggle). */\n onPress?: () => void;\n /** Hover text for the whole card. */\n description?: string;\n /** Anything under the context line. */\n children?: ReactNode;\n /** Layout classes for the card (margins, grid placement). */\n className?: string;\n \"aria-label\"?: string;\n /** `data-*` attributes land on the card's root (test ids, analytics hooks). `data-slot` stays the card's own. */\n [dataAttribute: `data-${string}`]: string | number | boolean | undefined;\n}\n\n/** The number in a piece of text, for the direction a swap moves: a larger one rises from below. */\nconst amountIn = (text: string) => Number(text.replace(/,/g, \"\").match(/-?\\d+(?:\\.\\d+)?/)?.[0] ?? NaN);\n\n/** 1 when the new text holds a number that did not shrink, -1 when it did. Updated during render. */\nfunction useSwapDirection(text: string | undefined) {\n const [shown, setShown] = useState({ text, dir: 1 });\n if (shown.text !== text) {\n setShown({ text, dir: amountIn(text ?? \"\") < amountIn(shown.text ?? \"\") ? -1 : 1 });\n }\n return shown.dir;\n}\n\n/** Copy that is replaced rises in from the side it moved toward and the old copy lifts away. */\nfunction Swap({\n text,\n className,\n children,\n}: {\n text: string;\n className?: string;\n children: ReactNode;\n}) {\n const dir = useSwapDirection(text);\n return (\n <SwapText swapKey={text} dir={dir} className={cn(\"block min-w-0\", className)}>\n {children}\n </SwapText>\n );\n}\n\ninterface Figure {\n prefix: string;\n value: number;\n decimals: number;\n suffix: string;\n}\n\nconst FORMAT: Record<number, Intl.NumberFormat> = {};\nconst format = (decimals: number) =>\n (FORMAT[decimals] ??= new Intl.NumberFormat(\"en-US\", {\n minimumFractionDigits: decimals,\n maximumFractionDigits: decimals,\n }));\n\n/**\n * A number with text around it, or null. Only a figure that the counter would\n * draw back as the very same text qualifies (\"12400\" would come back as\n * \"12,400\", so it is shown as written rather than quietly reformatted).\n */\nfunction figureOf(value: ReactNode): Figure | null {\n if (typeof value === \"number\") {\n if (!Number.isFinite(value)) return null;\n const decimals = Math.min(4, (String(value).split(\".\")[1] ?? \"\").length);\n return { prefix: \"\", value, decimals, suffix: \"\" };\n }\n if (typeof value !== \"string\") return null;\n const match = /^(\\D*?)(-?\\d[\\d,]*(?:\\.\\d+)?)(\\D*)$/.exec(value);\n if (!match) return null;\n const [, prefix = \"\", digits = \"\", suffix = \"\"] = match;\n const decimals = (digits.split(\".\")[1] ?? \"\").length;\n const number = Number(digits.replace(/,/g, \"\"));\n if (!Number.isFinite(number) || `${prefix}${format(decimals).format(number)}${suffix}` !== value) {\n return null;\n }\n return { prefix, value: number, decimals, suffix };\n}\n\nconst signOf = (change: string) => (/^\\+/.test(change) ? 1 : /^[-−]/.test(change) ? -1 : 0);\n\n/** What a change means: the explicit tone, else its sign read the way `trend` says. */\nfunction changeMeaning(\n change: string,\n trend: StatCardTrend,\n explicit: StatCardChangeTone | undefined,\n): StatCardChangeTone {\n if (explicit) return explicit;\n const sign = trend === \"neutral\" ? 0 : signOf(change) * (trend === \"inverse\" ? -1 : 1);\n return sign > 0 ? \"positive\" : sign < 0 ? \"negative\" : \"neutral\";\n}\n\nconst BADGE_TONE = { positive: \"success\", negative: \"danger\", neutral: \"neutral\" } as const;\n\n/** Only the `data-*` attributes, so nothing else in the rest object reaches the DOM. */\nconst dataAttributes = (rest: Record<string, unknown>) =>\n Object.fromEntries(Object.entries(rest).filter(([key]) => key.startsWith(\"data-\")));\n\n/**\n * One headline figure.\n *\n * @example\n * <div className=\"grid gap-4 sm:grid-cols-3\">\n * <StatCard label=\"Open\" value=\"$12,400\" sub=\"18 invoices\" change=\"+4.2%\" />\n * <StatCard label=\"Overdue\" value=\"$3,100\" sub=\"4 invoices\" tone=\"negative\" />\n * <StatCard label=\"Collected\" value=\"$9,800\" tone=\"positive\" href=\"/payments\" />\n * </div>\n */\nexport const StatCard = forwardRef<HTMLElement, StatCardProps>(function StatCard(\n {\n label,\n value,\n sub,\n change,\n trend = \"normal\",\n changeTone,\n icon,\n tone,\n isSelected,\n href,\n onPress,\n description,\n children,\n className,\n ...rest\n },\n ref,\n) {\n const figure = figureOf(value);\n const meaning = change ? changeMeaning(change, trend, changeTone) : \"neutral\";\n const body = (\n // The hover text sits on a box that fills the card: React Aria's Button\n // does not pass a `title` through to the element it renders.\n <span className=\"flex min-w-0 flex-1 flex-col\" title={description}>\n <span className={statCardHeaderVariants()}>\n {icon ? (\n <span data-slot=\"stat-card-icon\" aria-hidden=\"true\" className={statCardIconVariants()}>\n {icon}\n </span>\n ) : null}\n <span data-slot=\"stat-card-label\" className={cn(statCardLabelVariants(), \"block\")}>\n {/* *:max-w-full: the arriving copy is an inline-block, whose shrink-to-fit width is the whole unwrapped label; capped, the label truncates. */}\n <Swap text={label} className=\"*:max-w-full\">\n <span className=\"block truncate\" title={label}>\n {label}\n </span>\n </Swap>\n </span>\n {change ? (\n <Badge\n shape=\"pill\"\n tone={BADGE_TONE[meaning]}\n appearance={meaning === \"neutral\" ? \"outline\" : \"soft\"}\n className=\"tabular-nums\"\n >\n {change}\n </Badge>\n ) : null}\n </span>\n <span\n data-slot=\"stat-card-value\"\n data-tone={tone ?? \"neutral\"}\n className={cn(statCardValueVariants({ tone }), \"block\")}\n >\n {figure ? (\n <AnimatedCounter\n value={figure.value}\n decimals={figure.decimals}\n prefix={figure.prefix}\n suffix={figure.suffix}\n animateOnView\n />\n ) : (\n value\n )}\n </span>\n {sub === undefined ? null : (\n <span data-slot=\"stat-card-sub\" className={cn(statCardSubVariants(), \"block\")}>\n {typeof sub === \"string\" ? <Swap text={sub}>{sub}</Swap> : sub}\n </span>\n )}\n {children}\n </span>\n );\n const interactive = isSelected !== undefined || onPress !== undefined || href !== undefined;\n const classes = cn(statCardVariants({ selected: isSelected === true, interactive }), className);\n const common = {\n ...dataAttributes(rest),\n \"data-slot\": \"stat-card\",\n \"aria-label\": rest[\"aria-label\"],\n };\n\n if (isSelected !== undefined) {\n return (\n <AriaToggleButton\n ref={ref as Ref<HTMLButtonElement>}\n {...common}\n isSelected={isSelected}\n onChange={() => onPress?.()}\n className={classes}\n >\n {body}\n </AriaToggleButton>\n );\n }\n if (onPress) {\n return (\n <AriaButton\n ref={ref as Ref<HTMLButtonElement>}\n {...common}\n onPress={onPress}\n className={classes}\n >\n {body}\n </AriaButton>\n );\n }\n if (href !== undefined) {\n return (\n <AriaLink ref={ref as Ref<HTMLAnchorElement>} {...common} href={href} className={classes}>\n {body}\n </AriaLink>\n );\n }\n return (\n <div ref={ref as Ref<HTMLDivElement>} {...common} className={classes}>\n {body}\n </div>\n );\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"stat-card.js","names":["AriaToggleButton","AriaButton","AriaLink"],"sources":["../../src/stat-card/stat-card.tsx"],"sourcesContent":["\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { forwardRef, useState, type ReactNode, type Ref } from \"react\";\nimport {\n Button as AriaButton,\n Link as AriaLink,\n ToggleButton as AriaToggleButton,\n} from \"react-aria-components\";\n\nimport { AnimatedCounter } from \"../animated-counter/animated-counter.js\";\nimport { Badge } from \"../badge/badge.js\";\nimport { cn, cnAll } from \"../lib/cn.js\";\nimport { skeletonVariants } from \"../skeleton/skeleton.js\";\nimport { SwapText } from \"../lib/swap-text.js\";\n\n/**\n * StatCard: one headline figure with its label and a line of context. A row\n * of them is a dashboard's summary strip.\n *\n * It exists because a summary strip is on almost every overview screen, and\n * without a shared card each screen picks its own label case, number size,\n * colour and height, so the same figure looks different on two pages.\n *\n * The figure is a counter: a number rolls up to its value the first time the\n * card scrolls into view, and when `value` changes the digits that changed roll\n * to their new faces, the way the number moved. A label, a line of context or\n * a change that is replaced rises in while the old copy lifts away. Reduced\n * motion jumps and fades instead. A value that is not a plain number\n * (a date, \"N/A\", a node) is shown as it is.\n *\n * Rules it holds:\n *\n * - The label is ONE line. A long label is cut with an ellipsis and the full\n * text is on hover, so a row of cards keeps its numbers on one baseline\n * instead of one card's label wrapping and pushing its number down.\n * - The number's colour comes from `tone`, a fixed set of meanings, never a\n * colour. `neutral` is the default and is right for any figure without a\n * verdict in it, including every zero.\n * - Cards in a row are the same height (`h-full` in a grid or flex row).\n * - The change pill's colour is what the change MEANS. By default a rise is\n * good; `trend=\"inverse\"` reads a rise as bad (bounce rate, cost, keywords\n * that dropped out), `trend=\"neutral\"` gives no verdict, and `changeTone`\n * names the meaning outright when the text carries no sign.\n *\n * A card can be a link (`href`, React Aria Link, so a router provider routes\n * it), a button (`onPress`), or a filter toggle (`isSelected` + `onPress`, a\n * React Aria ToggleButton with `aria-pressed` and one shared selected look).\n *\n * `isLoading` keeps the card's box while its figure is fetched: the label\n * stays (or pulses as a placeholder when it is empty), the figure and the\n * context line become placeholder blocks the height of the lines they stand\n * in for, and the change pill pulses at its own size. So a row of loading cards is exactly as\n * tall as the row that replaces it and nothing shifts when the data lands.\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 statCardVariants = cva(\n [\n \"flex h-full min-w-0 flex-col p-6 text-start\",\n \"rounded-panel border-(length:--border-width) bg-surface text-foreground shadow-resting\",\n // box-border and font-sans are not boilerplate: the reset in\n // @seekalfred/styles is opt-in, so a consumer may load none.\n \"box-border font-sans\",\n ],\n {\n variants: {\n /** A filter card that is the one applied. */\n selected: {\n true: \"border-primary-strong\",\n false: \"border-border\",\n },\n /** Whether the card is a link or a button (hover and focus states). */\n interactive: {\n true: [\n \"cursor-pointer outline-none\",\n // A border, not a fill: a muted fill under the muted label fails contrast.\n \"data-[hovered]:not-data-[selected]:border-muted-foreground\",\n // outline-solid on the focus variant is load-bearing: outline-none\n // sets the outline style the width utility resolves from.\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 ],\n false: \"\",\n },\n },\n defaultVariants: { selected: false, interactive: false },\n },\n);\n\n/** The top row: the label on the left, the change pill on the right. */\nexport const statCardHeaderVariants = cva(\"mb-8 flex min-h-6 items-center justify-between gap-3\");\n\nexport const statCardLabelVariants = cva([\n \"m-0 min-w-0 flex-1 text-sm leading-(--leading-normal) font-normal\",\n \"text-text-secondary\",\n]);\n\nexport const statCardValueVariants = cva(\n // The counter sets its own colour; this makes it follow the tone instead.\n // leading-* AFTER text-*: tailwind-merge treats a later text-* as owning\n // line-height and silently strips a leading-* that precedes it.\n \"m-0 font-display text-h2 leading-none font-semibold tabular-nums break-words **:data-[slot=animated-counter]:text-inherit\",\n {\n variants: {\n /** What the number MEANS. Colour follows from it; it is never picked directly. */\n tone: {\n neutral: \"text-foreground\",\n positive: \"text-success-strong\",\n negative: \"text-destructive-strong\",\n warning: \"text-warning-strong\",\n attention: \"text-primary-strong\",\n },\n },\n defaultVariants: { tone: \"neutral\" },\n },\n);\n\n/** An optional icon before the label. Decorative: the label names the figure. */\nexport const statCardIconVariants = cva(\n \"flex shrink-0 items-center text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0\",\n);\n\nexport const statCardSubVariants = cva(\n \"m-0 mt-4 text-sm leading-(--leading-normal) text-muted-foreground\",\n);\n\n/**\n * A placeholder standing in for one line of copy while the card loads. It sits\n * inside the line it replaces and is one line-height tall (`1lh`), so the line\n * box, and with it the card, keeps the height of the loaded copy.\n */\nexport const statCardSkeletonVariants = cva(\"inline-block h-[1lh] max-w-full align-top\", {\n variants: {\n /** Which line it stands in for: each has the rough width of its copy. */\n part: {\n label: \"w-24\",\n value: \"w-28\",\n sub: \"w-36\",\n },\n },\n});\n\nexport type StatCardTone = \"neutral\" | \"positive\" | \"negative\" | \"warning\" | \"attention\";\n\n/** Which way is good for this figure: up (`normal`), down (`inverse`), or neither (`neutral`). */\nexport type StatCardTrend = \"normal\" | \"inverse\" | \"neutral\";\n\n/** What a change means, whatever its sign. */\nexport type StatCardChangeTone = \"positive\" | \"negative\" | \"neutral\";\n\nexport interface StatCardProps {\n /** What the figure is. One line; cut with an ellipsis, full text on hover. */\n label: string;\n /**\n * The figure itself, already formatted. A plain number (`12400`), or a string\n * made of a number with text around it (`\"$12,400\"`, `\"42 days\"`, `\"98.5%\"`),\n * counts up on first view and rolls to each new value. Anything else is\n * shown as it is.\n */\n value: ReactNode;\n /** One line of context under the figure. A string that changes swaps in place. */\n sub?: ReactNode;\n /**\n * A change shown as a pill beside the label, such as `\"+12.4%\"`. A leading `+`\n * reads green and a leading `-` red; no sign is neutral. A new value swaps in\n * place and the pill follows its width.\n */\n change?: string;\n /**\n * Which direction of `change` is good. `normal` (default): a leading `+`\n * reads green and a leading `-` red. `inverse`: a rise reads red and a fall\n * green, for a figure where more is worse (bounce rate, cost per click,\n * keywords that fell out of the top 100). `neutral`: no colour either way.\n * @default \"normal\"\n */\n trend?: StatCardTrend;\n /**\n * The meaning of `change`, named outright. Wins over `trend` and the sign;\n * use it when the text has no `+` or `-` (`\"▲4\"`) or the verdict comes from\n * elsewhere. It colours only the change pill, never the value (that is\n * `tone`).\n */\n changeTone?: StatCardChangeTone;\n /**\n * An icon before the label. Decorative (hidden from assistive technology).\n * An SVG is drawn at 16px, whatever size it is given. A falsy value\n * (`false`, `null`, `\"\"`) renders nothing, so `icon={cond && <Icon />}` works.\n */\n icon?: ReactNode;\n /**\n * What the figure means: `neutral` (default, and right for every zero),\n * `positive`, `negative`, `warning` (part-way or waiting) or `attention`\n * (a person has to look).\n */\n tone?: StatCardTone;\n /**\n * The card is a filter, and this says whether it is the one applied.\n * Renders a toggle button with `aria-pressed`. Pair with `onPress`.\n */\n isSelected?: boolean;\n /** Renders the card as a link to this URL. */\n href?: string;\n /** Renders the card as a button (or, with `isSelected`, a toggle). */\n onPress?: () => void;\n /** Hover text for the whole card. */\n description?: string;\n /**\n * The figure is still loading. The card keeps its box: the label stays (a\n * placeholder pulses in its place when it is `\"\"`), the value and, when\n * `sub` is given, the context line become placeholder blocks the height of\n * those lines, and the change pill becomes a placeholder of its size. The card's content carries\n * `aria-busy`, the root `data-loading`, and assistive technology reads\n * `loadingLabel` in place of the figure. A link, button or toggle card stays\n * operable. Pass `sub` (any value, even `\"\"`) while loading to keep the\n * context line's height.\n * @default false\n */\n isLoading?: boolean;\n /**\n * Read by assistive technology in place of the figure while `isLoading`.\n * @default \"Loading\"\n */\n loadingLabel?: string;\n /** Anything under the context line. */\n children?: ReactNode;\n /** Layout classes for the card (margins, grid placement). */\n className?: string;\n \"aria-label\"?: string;\n /** `data-*` attributes land on the card's root (test ids, analytics hooks). `data-slot` stays the card's own. */\n [dataAttribute: `data-${string}`]: string | number | boolean | undefined;\n}\n\n/** The number in a piece of text, for the direction a swap moves: a larger one rises from below. */\nconst amountIn = (text: string) => Number(text.replace(/,/g, \"\").match(/-?\\d+(?:\\.\\d+)?/)?.[0] ?? NaN);\n\n/** 1 when the new text holds a number that did not shrink, -1 when it did. Updated during render. */\nfunction useSwapDirection(text: string | undefined) {\n const [shown, setShown] = useState({ text, dir: 1 });\n if (shown.text !== text) {\n setShown({ text, dir: amountIn(text ?? \"\") < amountIn(shown.text ?? \"\") ? -1 : 1 });\n }\n return shown.dir;\n}\n\n/** Copy that is replaced rises in from the side it moved toward and the old copy lifts away. */\nfunction Swap({\n text,\n className,\n children,\n}: {\n text: string;\n className?: string;\n children: ReactNode;\n}) {\n const dir = useSwapDirection(text);\n return (\n <SwapText swapKey={text} dir={dir} className={cn(\"block min-w-0\", className)}>\n {children}\n </SwapText>\n );\n}\n\ninterface Figure {\n prefix: string;\n value: number;\n decimals: number;\n suffix: string;\n}\n\nconst FORMAT: Record<number, Intl.NumberFormat> = {};\nconst format = (decimals: number) =>\n (FORMAT[decimals] ??= new Intl.NumberFormat(\"en-US\", {\n minimumFractionDigits: decimals,\n maximumFractionDigits: decimals,\n }));\n\n/**\n * A number with text around it, or null. Only a figure that the counter would\n * draw back as the very same text qualifies (\"12400\" would come back as\n * \"12,400\", so it is shown as written rather than quietly reformatted).\n */\nfunction figureOf(value: ReactNode): Figure | null {\n if (typeof value === \"number\") {\n if (!Number.isFinite(value)) return null;\n const decimals = Math.min(4, (String(value).split(\".\")[1] ?? \"\").length);\n return { prefix: \"\", value, decimals, suffix: \"\" };\n }\n if (typeof value !== \"string\") return null;\n const match = /^(\\D*?)(-?\\d[\\d,]*(?:\\.\\d+)?)(\\D*)$/.exec(value);\n if (!match) return null;\n const [, prefix = \"\", digits = \"\", suffix = \"\"] = match;\n const decimals = (digits.split(\".\")[1] ?? \"\").length;\n const number = Number(digits.replace(/,/g, \"\"));\n if (!Number.isFinite(number) || `${prefix}${format(decimals).format(number)}${suffix}` !== value) {\n return null;\n }\n return { prefix, value: number, decimals, suffix };\n}\n\nconst signOf = (change: string) => (/^\\+/.test(change) ? 1 : /^[-−]/.test(change) ? -1 : 0);\n\n/** What a change means: the explicit tone, else its sign read the way `trend` says. */\nfunction changeMeaning(\n change: string,\n trend: StatCardTrend,\n explicit: StatCardChangeTone | undefined,\n): StatCardChangeTone {\n if (explicit) return explicit;\n const sign = trend === \"neutral\" ? 0 : signOf(change) * (trend === \"inverse\" ? -1 : 1);\n return sign > 0 ? \"positive\" : sign < 0 ? \"negative\" : \"neutral\";\n}\n\nconst BADGE_TONE = { positive: \"success\", negative: \"danger\", neutral: \"neutral\" } as const;\n\n/** The swap key of an empty label's placeholder: a key no label text can be. */\nconst LOADING_KEY = \"\\u0000loading\";\n\n/** Only the `data-*` attributes, so nothing else in the rest object reaches the DOM. */\nconst dataAttributes = (rest: Record<string, unknown>) =>\n Object.fromEntries(Object.entries(rest).filter(([key]) => key.startsWith(\"data-\")));\n\n/**\n * One headline figure.\n *\n * @example\n * <div className=\"grid gap-4 sm:grid-cols-3\">\n * <StatCard label=\"Open\" value=\"$12,400\" sub=\"18 invoices\" change=\"+4.2%\" />\n * <StatCard label=\"Overdue\" value=\"$3,100\" sub=\"4 invoices\" tone=\"negative\" />\n * <StatCard label=\"Collected\" value=\"$9,800\" tone=\"positive\" href=\"/payments\" />\n * </div>\n */\nexport const StatCard = forwardRef<HTMLElement, StatCardProps>(function StatCard(\n {\n label,\n value,\n sub,\n change,\n trend = \"normal\",\n changeTone,\n icon,\n tone,\n isSelected,\n href,\n onPress,\n description,\n isLoading = false,\n loadingLabel = \"Loading\",\n children,\n className,\n ...rest\n },\n ref,\n) {\n const figure = figureOf(value);\n const placeholder = (part: \"label\" | \"value\" | \"sub\") => (\n <span\n data-slot=\"stat-card-skeleton\"\n aria-hidden=\"true\"\n className={cnAll(skeletonVariants(), statCardSkeletonVariants({ part }))}\n />\n );\n const meaning = change ? changeMeaning(change, trend, changeTone) : \"neutral\";\n const body = (\n // The hover text sits on a box that fills the card: React Aria's Button\n // does not pass a `title` through to the element it renders.\n // aria-busy sits here, on the content being replaced, because React Aria's Button, ToggleButton and Link drop it from their props.\n <span\n data-slot=\"stat-card-body\"\n aria-busy={isLoading || undefined}\n className=\"flex min-w-0 flex-1 flex-col\"\n title={description}\n >\n <span className={statCardHeaderVariants()}>\n {icon ? (\n <span data-slot=\"stat-card-icon\" aria-hidden=\"true\" className={statCardIconVariants()}>\n {icon}\n </span>\n ) : null}\n <span data-slot=\"stat-card-label\" className={cn(statCardLabelVariants(), \"block\")}>\n {/* *:max-w-full: the arriving copy is an inline-block, whose shrink-to-fit width is the whole unwrapped label; capped, the label truncates. */}\n {/* The placeholder sits in the label's own line (a swap with an empty key renders nothing), so the header keeps its height. */}\n <Swap text={isLoading && label === \"\" ? LOADING_KEY : label} className=\"*:max-w-full\">\n <span className=\"block truncate\" title={label}>\n {isLoading && label === \"\" ? placeholder(\"label\") : label}\n </span>\n </Swap>\n </span>\n {change && isLoading ? (\n // The pill's own box, invisible inside a pulsing placeholder, so the header keeps its height.\n <span\n data-slot=\"stat-card-skeleton\"\n aria-hidden=\"true\"\n // Skeleton's surface and pulse, in the pill's shape (tailwind-merge does not know rounded-pill replaces rounded-md).\n className=\"box-border inline-flex shrink-0 rounded-pill bg-muted motion-safe:animate-skeleton-pulse\"\n >\n <Badge\n shape=\"pill\"\n tone=\"neutral\"\n appearance=\"outline\"\n className=\"invisible tabular-nums\"\n >\n {change}\n </Badge>\n </span>\n ) : change ? (\n <Badge\n shape=\"pill\"\n tone={BADGE_TONE[meaning]}\n appearance={meaning === \"neutral\" ? \"outline\" : \"soft\"}\n className=\"tabular-nums\"\n >\n {change}\n </Badge>\n ) : null}\n </span>\n <span\n data-slot=\"stat-card-value\"\n data-tone={tone ?? \"neutral\"}\n className={cn(statCardValueVariants({ tone }), \"block\")}\n >\n {isLoading ? (\n <>\n {placeholder(\"value\")}\n <span className=\"sr-only\">{loadingLabel}</span>\n </>\n ) : figure ? (\n <AnimatedCounter\n value={figure.value}\n decimals={figure.decimals}\n prefix={figure.prefix}\n suffix={figure.suffix}\n animateOnView\n />\n ) : (\n value\n )}\n </span>\n {sub === undefined ? null : (\n <span data-slot=\"stat-card-sub\" className={cn(statCardSubVariants(), \"block\")}>\n {isLoading ? (\n placeholder(\"sub\")\n ) : typeof sub === \"string\" ? (\n <Swap text={sub}>{sub}</Swap>\n ) : (\n sub\n )}\n </span>\n )}\n {children}\n </span>\n );\n const interactive = isSelected !== undefined || onPress !== undefined || href !== undefined;\n const classes = cn(statCardVariants({ selected: isSelected === true, interactive }), className);\n const common = {\n ...dataAttributes(rest),\n \"data-slot\": \"stat-card\",\n \"data-loading\": isLoading || undefined,\n \"aria-label\": rest[\"aria-label\"],\n };\n\n if (isSelected !== undefined) {\n return (\n <AriaToggleButton\n ref={ref as Ref<HTMLButtonElement>}\n {...common}\n isSelected={isSelected}\n onChange={() => onPress?.()}\n className={classes}\n >\n {body}\n </AriaToggleButton>\n );\n }\n if (onPress) {\n return (\n <AriaButton\n ref={ref as Ref<HTMLButtonElement>}\n {...common}\n onPress={onPress}\n className={classes}\n >\n {body}\n </AriaButton>\n );\n }\n if (href !== undefined) {\n return (\n <AriaLink ref={ref as Ref<HTMLAnchorElement>} {...common} href={href} className={classes}>\n {body}\n </AriaLink>\n );\n }\n return (\n <div ref={ref as Ref<HTMLDivElement>} {...common} className={classes}>\n {body}\n </div>\n );\n});\n"],"mappings":"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"}