@seekalfred/primitives 0.7.1 → 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 (147) hide show
  1. package/README.md +255 -5
  2. package/dist/alert/alert.d.ts +49 -4
  3. package/dist/alert/alert.js +66 -38
  4. package/dist/alert/alert.js.map +1 -1
  5. package/dist/alert/index.d.ts +2 -2
  6. package/dist/alert/index.js +2 -2
  7. package/dist/alert-dialog/alert-dialog.d.ts +31 -4
  8. package/dist/alert-dialog/alert-dialog.js +75 -26
  9. package/dist/alert-dialog/alert-dialog.js.map +1 -1
  10. package/dist/announcement-bar/announcement-bar.d.ts +15 -4
  11. package/dist/announcement-bar/announcement-bar.js +31 -6
  12. package/dist/announcement-bar/announcement-bar.js.map +1 -1
  13. package/dist/avatar/avatar.d.ts +7 -0
  14. package/dist/avatar/avatar.js +33 -12
  15. package/dist/avatar/avatar.js.map +1 -1
  16. package/dist/badge/badge.d.ts +1 -1
  17. package/dist/bar-chart/bar-chart-series.d.ts +75 -0
  18. package/dist/bar-chart/bar-chart-series.js +401 -0
  19. package/dist/bar-chart/bar-chart-series.js.map +1 -0
  20. package/dist/bar-chart/bar-chart.d.ts +25 -1
  21. package/dist/bar-chart/bar-chart.js +26 -1
  22. package/dist/bar-chart/bar-chart.js.map +1 -1
  23. package/dist/bar-chart/index.d.ts +2 -1
  24. package/dist/button/button.d.ts +1 -1
  25. package/dist/calendar/calendar.d.ts +1 -1
  26. package/dist/card/card.d.ts +22 -1
  27. package/dist/card/card.js +60 -7
  28. package/dist/card/card.js.map +1 -1
  29. package/dist/carousel/carousel.d.ts +21 -0
  30. package/dist/carousel/carousel.js +34 -8
  31. package/dist/carousel/carousel.js.map +1 -1
  32. package/dist/chip-group/chip-group.d.ts +11 -0
  33. package/dist/chip-group/chip-group.js +50 -34
  34. package/dist/chip-group/chip-group.js.map +1 -1
  35. package/dist/chip-group/index.d.ts +2 -2
  36. package/dist/copy-button/copy-button.d.ts +37 -5
  37. package/dist/copy-button/copy-button.js +35 -11
  38. package/dist/copy-button/copy-button.js.map +1 -1
  39. package/dist/donut-chart/donut-chart.d.ts +22 -0
  40. package/dist/donut-chart/donut-chart.js +35 -18
  41. package/dist/donut-chart/donut-chart.js.map +1 -1
  42. package/dist/donut-chart/index.d.ts +2 -2
  43. package/dist/empty-state/empty-state.d.ts +21 -0
  44. package/dist/empty-state/empty-state.js +16 -4
  45. package/dist/empty-state/empty-state.js.map +1 -1
  46. package/dist/empty-state/index.d.ts +2 -2
  47. package/dist/empty-state/index.js +2 -2
  48. package/dist/file-drop-zone/file-drop-zone.d.ts +29 -3
  49. package/dist/file-drop-zone/file-drop-zone.js +26 -8
  50. package/dist/file-drop-zone/file-drop-zone.js.map +1 -1
  51. package/dist/filter-bar/filter-bar.d.ts +4 -1
  52. package/dist/filter-bar/filter-bar.js +53 -9
  53. package/dist/filter-bar/filter-bar.js.map +1 -1
  54. package/dist/gauge/gauge.d.ts +18 -1
  55. package/dist/gauge/gauge.js +35 -21
  56. package/dist/gauge/gauge.js.map +1 -1
  57. package/dist/gauge/index.d.ts +2 -2
  58. package/dist/index.d.ts +15 -14
  59. package/dist/index.js +9 -9
  60. package/dist/input/input.d.ts +7 -0
  61. package/dist/input/input.js +7 -5
  62. package/dist/input/input.js.map +1 -1
  63. package/dist/lib/field-shell.js +6 -3
  64. package/dist/lib/field-shell.js.map +1 -1
  65. package/dist/lib/field-surface.js +22 -1
  66. package/dist/lib/field-surface.js.map +1 -1
  67. package/dist/lib/required-marker.js +20 -0
  68. package/dist/lib/required-marker.js.map +1 -0
  69. package/dist/lib/use-copy-feedback.js +17 -3
  70. package/dist/lib/use-copy-feedback.js.map +1 -1
  71. package/dist/line-chart/index.d.ts +2 -2
  72. package/dist/line-chart/line-chart.d.ts +144 -2
  73. package/dist/line-chart/line-chart.js +314 -46
  74. package/dist/line-chart/line-chart.js.map +1 -1
  75. package/dist/money-input/index.d.ts +2 -2
  76. package/dist/money-input/money-input.d.ts +36 -1
  77. package/dist/money-input/money-input.js +30 -14
  78. package/dist/money-input/money-input.js.map +1 -1
  79. package/dist/morph-select/morph-select.d.ts +26 -1
  80. package/dist/morph-select/morph-select.js +28 -6
  81. package/dist/morph-select/morph-select.js.map +1 -1
  82. package/dist/multi-select/index.d.ts +2 -2
  83. package/dist/multi-select/index.js +2 -2
  84. package/dist/multi-select/multi-select.d.ts +74 -2
  85. package/dist/multi-select/multi-select.js +213 -39
  86. package/dist/multi-select/multi-select.js.map +1 -1
  87. package/dist/password-field/password-field.d.ts +16 -0
  88. package/dist/password-field/password-field.js +37 -8
  89. package/dist/password-field/password-field.js.map +1 -1
  90. package/dist/popover/index.d.ts +2 -2
  91. package/dist/popover/index.js +2 -2
  92. package/dist/popover/popover.d.ts +39 -2
  93. package/dist/popover/popover.js +29 -2
  94. package/dist/popover/popover.js.map +1 -1
  95. package/dist/progress/index.d.ts +2 -2
  96. package/dist/progress/index.js +2 -2
  97. package/dist/progress/progress.d.ts +68 -3
  98. package/dist/progress/progress.js +156 -39
  99. package/dist/progress/progress.js.map +1 -1
  100. package/dist/radio-group/index.d.ts +2 -2
  101. package/dist/radio-group/index.js +2 -2
  102. package/dist/radio-group/radio-group.d.ts +33 -3
  103. package/dist/radio-group/radio-group.js +109 -9
  104. package/dist/radio-group/radio-group.js.map +1 -1
  105. package/dist/scroll-area/scroll-area.js +4 -3
  106. package/dist/scroll-area/scroll-area.js.map +1 -1
  107. package/dist/scrub-input/scrub-input.d.ts +11 -1
  108. package/dist/scrub-input/scrub-input.js +22 -10
  109. package/dist/scrub-input/scrub-input.js.map +1 -1
  110. package/dist/search-field/search-field.d.ts +22 -1
  111. package/dist/search-field/search-field.js +22 -7
  112. package/dist/search-field/search-field.js.map +1 -1
  113. package/dist/select/select.d.ts +10 -1
  114. package/dist/select/select.js +11 -8
  115. package/dist/select/select.js.map +1 -1
  116. package/dist/separator/separator.d.ts +21 -9
  117. package/dist/separator/separator.js +33 -11
  118. package/dist/separator/separator.js.map +1 -1
  119. package/dist/sparkline/sparkline.d.ts +27 -2
  120. package/dist/sparkline/sparkline.js +115 -18
  121. package/dist/sparkline/sparkline.js.map +1 -1
  122. package/dist/stat-card/index.d.ts +2 -2
  123. package/dist/stat-card/index.js +2 -2
  124. package/dist/stat-card/stat-card.d.ts +65 -1
  125. package/dist/stat-card/stat-card.js +98 -25
  126. package/dist/stat-card/stat-card.js.map +1 -1
  127. package/dist/styles.css +1 -1
  128. package/dist/table/index.d.ts +2 -2
  129. package/dist/table/index.js +2 -2
  130. package/dist/table/table.d.ts +115 -5
  131. package/dist/table/table.js +374 -94
  132. package/dist/table/table.js.map +1 -1
  133. package/dist/text-shimmer/text-shimmer.d.ts +18 -2
  134. package/dist/text-shimmer/text-shimmer.js +5 -2
  135. package/dist/text-shimmer/text-shimmer.js.map +1 -1
  136. package/dist/textarea/textarea.d.ts +7 -0
  137. package/dist/textarea/textarea.js +3 -1
  138. package/dist/textarea/textarea.js.map +1 -1
  139. package/dist/time-picker/time-picker.d.ts +36 -1
  140. package/dist/time-picker/time-picker.js +33 -5
  141. package/dist/time-picker/time-picker.js.map +1 -1
  142. package/dist/toast/toast.d.ts +2 -2
  143. package/dist/usage-meter/index.d.ts +2 -2
  144. package/dist/usage-meter/usage-meter.d.ts +30 -0
  145. package/dist/usage-meter/usage-meter.js +56 -38
  146. package/dist/usage-meter/usage-meter.js.map +1 -1
  147. package/package.json +3 -3
@@ -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":"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"}
@@ -33,7 +33,10 @@ import { Key } from "react-aria-components";
33
33
  * Accessibility comes from the parts: the panel is a dialog named by its
34
34
  * trigger, the trigger carries `aria-expanded`, focus moves into the panel and
35
35
  * is contained there, Escape closes it and focus returns to the trigger, and
36
- * each list is a real menu with arrow keys and type-ahead.
36
+ * each list is a real menu with arrow keys and type-ahead. Removing a chip, by
37
+ * keyboard or pointer, moves focus to the chip that takes its place (the one
38
+ * before it when it was last); removing the last chip, or Clear all, moves it to
39
+ * the Add filter button.
37
40
  */
38
41
  /** The toolbar: one pill with a light border on the surface. */
39
42
  export declare const filterBarVariants: (props?: import("class-variance-authority/types").ClassProp | undefined) => string;
@@ -45,7 +45,10 @@ import { jsx, jsxs } from "react/jsx-runtime";
45
45
  * Accessibility comes from the parts: the panel is a dialog named by its
46
46
  * trigger, the trigger carries `aria-expanded`, focus moves into the panel and
47
47
  * is contained there, Escape closes it and focus returns to the trigger, and
48
- * each list is a real menu with arrow keys and type-ahead.
48
+ * each list is a real menu with arrow keys and type-ahead. Removing a chip, by
49
+ * keyboard or pointer, moves focus to the chip that takes its place (the one
50
+ * before it when it was last); removing the last chip, or Clear all, moves it to
51
+ * the Add filter button.
49
52
  */
50
53
  /** The toolbar: one pill with a light border on the surface. */
51
54
  const filterBarVariants = cva([
@@ -140,6 +143,23 @@ function Slot({ leaving, ready, onDone, id, children }) {
140
143
  children
141
144
  });
142
145
  }
146
+ /**
147
+ * A chip's remove button. It registers itself with the bar under its chip's id
148
+ * through a ref callback that stays the same across renders, so React does not
149
+ * detach and re-attach it on every commit.
150
+ */
151
+ function ChipRemove({ id, label, register, onPress }) {
152
+ const ref = useCallback((node) => register(id, node), [register, id]);
153
+ return /* @__PURE__ */ jsx(Button$1, {
154
+ ref,
155
+ variant: "ghost",
156
+ size: "sm",
157
+ "aria-label": label,
158
+ onPress,
159
+ className: "h-6 rounded-full px-1 data-[focus-visible]:-outline-offset-(--focus-ring-width)",
160
+ children: /* @__PURE__ */ jsx(CrossGlyph, {})
161
+ });
162
+ }
143
163
  /** A chip's text: a changed value swaps (the new text rises in) and the chip's width springs to it. */
144
164
  function ChipText({ text }) {
145
165
  const content = useRef(null);
@@ -321,8 +341,30 @@ const FilterBar = forwardRef(function FilterBar({ inline, end, filters, onClearA
321
341
  }] : []]);
322
342
  const frame = useRef(null);
323
343
  const row = useRef(null);
324
- useHeightFollow(frame, row, chips.map((chip) => chip.id).join());
344
+ const chipKey = chips.map((chip) => chip.id).join();
345
+ useHeightFollow(frame, row, chipKey);
346
+ const removeButtons = useRef(/* @__PURE__ */ new Map());
347
+ const registerRemove = useCallback((id, node) => {
348
+ if (node) removeButtons.current.set(id, node);
349
+ else removeButtons.current.delete(id);
350
+ }, []);
351
+ const addButton = useRef(null);
352
+ const focusAfter = useRef(null);
353
+ const noteFocusAfter = (after) => {
354
+ focusAfter.current = after;
355
+ requestAnimationFrame(() => {
356
+ if (focusAfter.current === after) focusAfter.current = null;
357
+ });
358
+ };
359
+ useIsomorphicLayoutEffect(() => {
360
+ const after = focusAfter.current;
361
+ if (after === null) return;
362
+ focusAfter.current = null;
363
+ const next = after === "add" || chips.length === 0 ? null : chips[Math.min(after, chips.length - 1)];
364
+ (next ? removeButtons.current.get(next.id) : addButton.current)?.focus();
365
+ }, [chipKey]);
325
366
  const clearAll = () => {
367
+ noteFocusAfter("add");
326
368
  if (onClearAll) onClearAll();
327
369
  else for (const c of chips) c.clear();
328
370
  };
@@ -355,13 +397,14 @@ const FilterBar = forwardRef(function FilterBar({ inline, end, filters, onClearA
355
397
  children: /* @__PURE__ */ jsxs("span", {
356
398
  "data-slot": "filter-bar-chip",
357
399
  className: filterBarChipVariants(),
358
- children: [/* @__PURE__ */ jsx(ChipText, { text: item.chip.text }), /* @__PURE__ */ jsx(Button$1, {
359
- variant: "ghost",
360
- size: "sm",
361
- "aria-label": removeLabel(item.chip.text),
362
- onPress: item.chip.clear,
363
- className: "h-6 px-1",
364
- children: /* @__PURE__ */ jsx(CrossGlyph, {})
400
+ children: [/* @__PURE__ */ jsx(ChipText, { text: item.chip.text }), /* @__PURE__ */ jsx(ChipRemove, {
401
+ id: item.id,
402
+ label: removeLabel(item.chip.text),
403
+ register: registerRemove,
404
+ onPress: () => {
405
+ noteFocusAfter(chips.findIndex((chip) => chip.id === item.id));
406
+ item.chip.clear();
407
+ }
365
408
  })]
366
409
  })
367
410
  }, item.id) : /* @__PURE__ */ jsx(Slot, {
@@ -389,6 +432,7 @@ const FilterBar = forwardRef(function FilterBar({ inline, end, filters, onClearA
389
432
  isOpen: open,
390
433
  onOpenChange: setOpen,
391
434
  children: [/* @__PURE__ */ jsx(Button$1, {
435
+ ref: addButton,
392
436
  variant: "outlineNeutral",
393
437
  size: "sm",
394
438
  startIcon: /* @__PURE__ */ jsx(PlusGlyph, {}),
@@ -1 +1 @@
1
- {"version":3,"file":"filter-bar.js","names":["Button","AriaMenu","MenuItem","Popover"],"sources":["../../src/filter-bar/filter-bar.tsx"],"sourcesContent":["\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { forwardRef, useCallback, useEffect, useRef, useState, type ReactNode } from \"react\";\nimport { Menu as AriaMenu, type Key } from \"react-aria-components\";\n\nimport { Button } from \"../button/button.js\";\nimport { cn, cnAll } from \"../lib/cn.js\";\nimport { useHeightFollow } from \"../lib/height-follow.js\";\nimport { useLeavingList } from \"../lib/leaving-list.js\";\nimport { useMenuHighlight } from \"../lib/menu-highlight.js\";\nimport { prefersReducedMotion, useExitBackstop, useIsomorphicLayoutEffect, useMorphWidth } from \"../lib/motion.js\";\nimport { SwapText } from \"../lib/swap-text.js\";\nimport { MenuItem, menuVariants } from \"../menu/menu.js\";\nimport { Popover, PopoverTrigger } from \"../popover/popover.js\";\n\n/**\n * FilterBar -- a filter toolbar: one pill that shows what is filtered as\n * removable chips (\"No filters applied\" while nothing is), with an \"Add filter\"\n * button that opens a menu of the filters.\n *\n * Why it exists: list screens grew a row of four to seven dropdowns beside\n * the search box and the status tabs. At desktop width that row wraps onto a\n * second line; on a phone it scrolls sideways. The bar keeps the row to one\n * line and makes what IS filtered visible as chips instead of as the\n * resting text of a dropdown.\n *\n * Three regions, all inside the one pill:\n *\n * - `inline`: always in the pill, at its start (status tabs, a search box).\n * - the chips, then \"Clear all\" once any filter is set; or the empty note.\n * - the Add filter button, then `end`: always in the pill, at its end (a\n * count, Export, a view toggle).\n *\n * The button opens a menu of the filters: each row is the filter's icon and\n * name, the value it holds now, and a chevron. A row opens that filter's own\n * options in the same panel (a back button returns to the list); choosing an\n * option sets the filter and closes the menu. A custom filter shows its own\n * control in that step.\n *\n * The bar holds NO filter state. Every value and every change belongs to the\n * caller: removing a chip hands the filter's `emptyValue` back to its\n * `onChange`; Clear all does that for each set filter, or calls\n * `onClearAll` when the caller resets its filters in one step.\n *\n * Accessibility comes from the parts: the panel is a dialog named by its\n * trigger, the trigger carries `aria-expanded`, focus moves into the panel and\n * is contained there, Escape closes it and focus returns to the trigger, and\n * each list is a real menu with arrow keys and type-ahead.\n */\n\n/** The toolbar: one pill with a light border on the surface. */\nexport const filterBarVariants = cva([\n \"flex flex-wrap items-center gap-x-3 gap-y-2 rounded-full\",\n \"border-(length:--border-width) border-border bg-surface\",\n \"px-4 py-2 box-border font-sans\",\n]);\n\n/** The group at the pill's end: the Add filter button and the caller's `end`. */\nexport const filterBarRowVariants = cva([\"ms-auto flex flex-wrap items-center gap-2\"]);\n\nexport const filterBarChipVariants = cva([\n \"inline-flex items-center gap-1 rounded-full\",\n \"border-(length:--border-width) border-border bg-muted\",\n \"ps-3 pe-1 text-xs leading-(--leading-snug) text-foreground\",\n \"box-border font-sans\",\n]);\n\n/** One option of a dropdown filter. */\nexport interface FilterBarOption {\n /** The option's value, handed to `onChange`. */\n id: Key;\n /** The option's text: in the list, on the trigger and on the chip. */\n label: string;\n}\n\n/** A dropdown filter: a row in the Add filter menu that opens its options. */\nexport interface FilterBarSelectField {\n /** Stable identity, used as the React key. */\n id: string;\n /** The field's name: its row in the menu and the start of its chip. */\n label: string;\n /** An icon before the name in the menu row. Mark it `aria-hidden`. */\n icon?: ReactNode;\n /** The current value. `null` reads as not set. */\n value: Key | null;\n /** The value that means \"not filtered\" (the \"All\" option's id). */\n emptyValue: Key;\n /** The options, the \"All\" option included. */\n options: readonly FilterBarOption[];\n /** Told the chosen option's id, or `emptyValue` when the chip is removed. */\n onChange: (value: Key) => void;\n}\n\n/**\n * A filter that is not a plain dropdown (a date range, a searchable picker):\n * the caller renders the control and says what its chip reads.\n */\nexport interface FilterBarCustomField {\n /** Stable identity, used as the React key. */\n id: string;\n /** The start of its chip, and its row in the menu. The control rendered by `render` names itself. */\n label: string;\n /** An icon before the name in the menu row. Mark it `aria-hidden`. */\n icon?: ReactNode;\n /** The control, rendered in the menu's step for this filter. */\n render: () => ReactNode;\n /** The chip's value text while the filter is set, or `null` while it is not. */\n activeLabel: string | null;\n /** Unsets the filter: the chip's remove button and Clear all call it. */\n onClear: () => void;\n}\n\nexport type FilterBarField = FilterBarSelectField | FilterBarCustomField;\n\nexport interface FilterBarProps {\n /** Always in the pill, at its start: status tabs and the search box. */\n inline?: ReactNode;\n /** Always in the pill, at its end: a count, Export, a view toggle. */\n end?: ReactNode;\n /** The filters in the Add filter menu. With none, no button renders. */\n filters: readonly FilterBarField[];\n /** Resets every filter in one step. Without it, each set filter is handed its `emptyValue`. */\n onClearAll?: () => void;\n /**\n * The button's text, and the menu's name.\n *\n * @default \"Add filter\"\n */\n buttonLabel?: string;\n /**\n * The text of Clear all.\n *\n * @default \"Clear all\"\n */\n clearAllLabel?: string;\n /**\n * Ignored: the menu has no Done button, because choosing an option closes it.\n *\n * @deprecated Kept so existing callers still compile.\n */\n doneLabel?: string;\n /**\n * The note shown while no filter is set.\n *\n * @default \"No filters applied\"\n */\n emptyLabel?: string;\n /**\n * The accessible name of the list of chips.\n *\n * @default \"Filters set\"\n */\n chipsLabel?: string;\n /**\n * The accessible name of a chip's remove button, given the chip's text.\n *\n * @default (text) => `Remove filter ${text}`\n */\n removeLabel?: (chipText: string) => string;\n /**\n * The accessible name of the menu's back button.\n *\n * @default \"Back to filters\"\n */\n backLabel?: string;\n /** Extra classes on the root. Merged last, so they win conflicts with ours. */\n className?: string;\n}\n\nconst isCustom = (f: FilterBarField): f is FilterBarCustomField => \"render\" in f;\n\ninterface Chip {\n id: string;\n text: string;\n clear: () => void;\n}\n\n/** What a filter holds now, for its menu row and its chip: `null` while it is not set. */\nfunction valueOf(f: FilterBarField): string | null {\n if (isCustom(f)) return f.activeLabel;\n if (f.value == null || f.value === f.emptyValue) return null;\n const option = f.options.find((o) => o.id === f.value);\n return option ? option.label : String(f.value);\n}\n\n/** The set filters, each with its chip text and how to unset it. */\nfunction chipsFor(filters: readonly FilterBarField[]): Chip[] {\n const chips: Chip[] = [];\n for (const f of filters) {\n const value = valueOf(f);\n if (value == null) continue;\n chips.push({\n id: f.id,\n text: `${f.label}: ${value}`,\n clear: isCustom(f) ? f.onClear : () => f.onChange(f.emptyValue),\n });\n }\n return chips;\n}\n\nfunction Glyph({ path }: { path: string }) {\n return (\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" aria-hidden=\"true\">\n <path d={path} strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n );\n}\n\nconst PlusGlyph = () => <Glyph path=\"M8 3v10M3 8h10\" />;\nconst CrossGlyph = () => <Glyph path=\"M4 4l8 8M12 4l-8 8\" />;\nconst ChevronRightGlyph = () => <Glyph path=\"M6 3l5 5-5 5\" />;\nconst ChevronLeftGlyph = () => <Glyph path=\"M10 3 5 8l5 5\" />;\nconst CheckGlyph = () => <Glyph path=\"M3 8.5l3.5 3.5L13 4.5\" />;\n\n/**\n * A list toolbar with its filters behind one Add filter button.\n *\n * @example\n * <FilterBar\n * end={<Button>Export</Button>}\n * filters={[{ id: \"status\", label: \"Status\", icon: <StatusIcon aria-hidden />, value, emptyValue: \"all\",\n * onChange: setValue, options: [{ id: \"all\", label: \"Any status\" }, { id: \"open\", label: \"Open\" }] }]}\n * />\n */\n/**\n * One place in the chip row, for a chip or for Clear all. A slot added after the\n * first render opens from nothing while its content fades up and scales in, so the\n * chips beside it glide over; one that is removed stays, shrinking and collapsing,\n * until its exit ends. Nothing animates at first render.\n */\nfunction Slot({\n leaving,\n ready,\n onDone,\n id,\n children,\n}: {\n leaving: boolean;\n ready: boolean;\n onDone: (id: string) => void;\n id: string;\n children: ReactNode;\n}) {\n // Decided once, at this slot's own mount.\n const [fresh] = useState(ready);\n const remove = useCallback(() => onDone(id), [onDone, id]);\n useExitBackstop(leaving, remove, 900);\n // Reduced motion removes it at once: an animation that is switched off never ends.\n useIsomorphicLayoutEffect(() => {\n if (leaving && prefersReducedMotion()) remove();\n }, [leaving, remove]);\n return (\n <li\n data-slot={leaving ? \"filter-bar-leaving\" : undefined}\n aria-hidden={leaving || undefined}\n ref={(node) => {\n if (!node) return;\n if (leaving) node.setAttribute(\"inert\", \"\");\n else node.removeAttribute(\"inert\");\n }}\n onAnimationEnd={(event) => {\n if (leaving && event.target === event.currentTarget && event.animationName === \"chip-slot-out\")\n remove();\n }}\n className={cnAll(\n \"min-w-0 flex-none [interpolate-size:allow-keywords]\",\n leaving\n ? \"pointer-events-none motion-safe:animate-chip-out\"\n : fresh && \"motion-safe:animate-chip-in motion-reduce:animate-fade-in\",\n )}\n >\n {children}\n </li>\n );\n}\n\n/** A chip's text: a changed value swaps (the new text rises in) and the chip's width springs to it. */\nfunction ChipText({ text }: { text: string }) {\n const content = useRef<HTMLSpanElement>(null);\n useMorphWidth(content, text);\n return (\n <span className=\"button-slot\">\n <span ref={content} className=\"inline-flex flex-none items-center\">\n <SwapText swapKey={text} className=\"inline-block\">\n {text}\n </SwapText>\n </span>\n </span>\n );\n}\n\n/** A filter's icon, drawn at the control icon size in the secondary ink. */\nfunction RowIcon({ icon }: { icon: ReactNode }) {\n return icon ? (\n <span aria-hidden=\"true\" className=\"inline-flex text-text-secondary\">\n {icon}\n </span>\n ) : null;\n}\n\n/** The note for no filters. Arriving after the first render (the last chip has gone) it fades in; at first render it does not. */\nfunction EmptyNote({ ready, children }: { ready: boolean; children: ReactNode }) {\n // Decided once, at this note's own mount: adding the class later would start the animation on a note already on screen.\n const [fresh] = useState(ready);\n return (\n <span\n data-slot=\"filter-bar-empty\"\n className={cn(\"text-sm text-text-secondary\" + (fresh ? \" motion-safe:animate-fade-in\" : \"\"))}\n >\n {children}\n </span>\n );\n}\n\nconst STEP = \"motion-safe:animate-filter-step-in motion-reduce:animate-fade-in\";\n\n/**\n * The Add filter menu's panel, in two steps: the list of filters, then the\n * chosen filter's options (or its own control). A step arrives from the side it\n * was reached from; the first one, on open, does not animate by itself (the\n * popover grows from the button instead).\n */\nfunction FilterPanel({\n filters,\n label,\n backLabel,\n onClose,\n}: {\n filters: readonly FilterBarField[];\n label: string;\n backLabel: string;\n onClose: () => void;\n}) {\n const [fieldId, setFieldId] = useState<string | null>(null);\n // null until the user has navigated: the first step does not animate by itself.\n const [dir, setDir] = useState<1 | -1 | null>(null);\n // The filter whose step we came back from: focus returns to its row.\n const [returnTo, setReturnTo] = useState<string | null>(null);\n const listRef = useRef<HTMLDivElement>(null);\n const bindFields = useMenuHighlight();\n const bindOptions = useMenuHighlight();\n const field = filters.find((f) => f.id === fieldId) ?? null;\n\n // Coming back, focus lands on the row that was opened rather than on the first one.\n // The rows of a React Aria menu land a frame after it mounts, so this waits for the row.\n useEffect(() => {\n if (field || returnTo === null) return;\n const id = returnTo;\n let frame = 0;\n let tries = 0;\n const focusRow = () => {\n const row = Array.from(\n listRef.current?.querySelectorAll<HTMLElement>(\"[data-slot='menu-item']\") ?? [],\n ).find((node) => node.dataset.key === id);\n if (row) row.focus();\n else if (tries++ < 20) frame = requestAnimationFrame(focusRow);\n };\n focusRow();\n return () => cancelAnimationFrame(frame);\n }, [field, returnTo]);\n\n const open = (id: Key) => {\n setDir(1);\n setFieldId(String(id));\n };\n const back = () => {\n if (!field) return;\n setReturnTo(field.id);\n setDir(-1);\n setFieldId(null);\n };\n const stepClass = dir === null ? \"\" : cn(STEP + (dir === 1 ? \" [--step-dir:1]\" : \" [--step-dir:-1]\"));\n\n return (\n <div data-slot=\"filter-bar-panel\" className=\"overflow-x-clip\">\n {field ? (\n <div\n key={field.id}\n className={stepClass}\n onKeyDown={(event) => {\n // Arrow left steps back, from the options list; a custom control keeps its own keys.\n if (event.key === \"ArrowLeft\" && !isCustom(field)) {\n event.preventDefault();\n back();\n }\n }}\n >\n <div className=\"flex items-center gap-1 px-1 pb-1\">\n <Button\n variant=\"ghost\"\n size=\"icon\"\n aria-label={backLabel}\n onPress={back}\n className=\"size-8 flex-none rounded-full\"\n >\n <ChevronLeftGlyph />\n </Button>\n <span className=\"min-w-0 truncate text-sm font-medium text-foreground\">{field.label}</span>\n </div>\n {isCustom(field) ? (\n <div className=\"p-1.5\">{field.render()}</div>\n ) : (\n <AriaMenu\n ref={bindOptions}\n aria-label={field.label}\n data-slot=\"filter-bar-options\"\n autoFocus=\"first\"\n selectionMode=\"single\"\n disallowEmptySelection\n selectedKeys={field.value == null ? [] : [field.value]}\n onAction={(key) => {\n field.onChange(key);\n onClose();\n }}\n className={menuVariants()}\n >\n {field.options.map((o) => (\n <MenuItem key={o.id} id={o.id} textValue={o.label}>\n <span className=\"min-w-0 flex-1 truncate\">{o.label}</span>\n <span\n aria-hidden=\"true\"\n className={\n o.id === field.value\n ? \"inline-flex text-foreground\"\n : \"inline-flex text-foreground opacity-0\"\n }\n >\n <CheckGlyph />\n </span>\n </MenuItem>\n ))}\n </AriaMenu>\n )}\n </div>\n ) : (\n <div key=\"fields\" ref={listRef} className={stepClass}>\n <AriaMenu\n ref={bindFields}\n aria-label={label}\n data-slot=\"filter-bar-fields\"\n autoFocus={returnTo === null ? \"first\" : false}\n onAction={open}\n // Choosing a filter opens its options; it must not close the panel.\n onClose={() => {}}\n className={menuVariants()}\n >\n {filters.map((f) => {\n const value = valueOf(f);\n return (\n <MenuItem key={f.id} id={f.id} textValue={f.label}>\n <RowIcon icon={f.icon} />\n <span className=\"min-w-0 flex-1 truncate\">{f.label}</span>\n {value ? (\n <span className=\"max-w-24 truncate text-xs text-text-secondary\">{value}</span>\n ) : null}\n <span aria-hidden=\"true\" className=\"inline-flex text-text-secondary\">\n <ChevronRightGlyph />\n </span>\n </MenuItem>\n );\n })}\n </AriaMenu>\n </div>\n )}\n </div>\n );\n}\n\nexport const FilterBar = forwardRef<HTMLDivElement, FilterBarProps>(function FilterBar(\n {\n inline,\n end,\n filters,\n onClearAll,\n buttonLabel = \"Add filter\",\n clearAllLabel = \"Clear all\",\n emptyLabel = \"No filters applied\",\n chipsLabel = \"Filters set\",\n removeLabel = (text) => `Remove filter ${text}`,\n backLabel = \"Back to filters\",\n className,\n },\n ref,\n) {\n const [open, setOpen] = useState(false);\n const chips = chipsFor(filters);\n const [ready, setReady] = useState(false);\n useEffect(() => {\n // A frame after mount: chips added from then on arrive animated, the first ones do not.\n const frame = requestAnimationFrame(() => setReady(true));\n return () => cancelAnimationFrame(frame);\n }, []);\n // The chips, with Clear all as the row's last place, each kept on screen while it leaves.\n const places = useLeavingList([\n ...chips.map((chip) => ({ id: chip.id, chip })),\n ...(chips.length > 0 ? [{ id: \"\\u0000clear\", chip: null }] : []),\n ]);\n const frame = useRef<HTMLDivElement>(null);\n const row = useRef<HTMLUListElement>(null);\n // The row follows its height when a chip makes a line wrap or unwrap.\n useHeightFollow(frame, row, chips.map((chip) => chip.id).join());\n const clearAll = () => {\n if (onClearAll) onClearAll();\n else for (const c of chips) c.clear();\n };\n\n return (\n <div ref={ref} data-slot=\"filter-bar\" className={cn(filterBarVariants(), className)}>\n {inline ? <div className=\"flex flex-wrap items-center gap-2\">{inline}</div> : null}\n <div\n data-slot=\"filter-bar-chips-area\"\n className=\"flex min-h-(--density-control-h-sm) min-w-0 flex-[1_1_8rem] items-center\"\n >\n {places.list.length > 0 ? (\n <div ref={frame} data-slot=\"filter-bar-chips-frame\" className=\"height-follow min-w-0\">\n <ul\n ref={row}\n aria-label={chipsLabel}\n data-slot=\"filter-bar-chips\"\n className=\"m-0 flex list-none flex-wrap items-center gap-2 p-0\"\n >\n {places.list.map(({ item, leaving }) =>\n item.chip ? (\n <Slot key={item.id} id={item.id} leaving={leaving} ready={ready} onDone={places.done}>\n <span data-slot=\"filter-bar-chip\" className={filterBarChipVariants()}>\n <ChipText text={item.chip.text} />\n <Button\n variant=\"ghost\"\n size=\"sm\"\n aria-label={removeLabel(item.chip.text)}\n onPress={item.chip.clear}\n className=\"h-6 px-1\"\n >\n <CrossGlyph />\n </Button>\n </span>\n </Slot>\n ) : (\n <Slot key={item.id} id={item.id} leaving={leaving} ready={ready} onDone={places.done}>\n <Button variant=\"link\" size=\"sm\" onPress={clearAll}>\n {clearAllLabel}\n </Button>\n </Slot>\n ),\n )}\n </ul>\n </div>\n ) : (\n <EmptyNote ready={ready}>{emptyLabel}</EmptyNote>\n )}\n </div>\n {filters.length > 0 || end ? (\n <div data-slot=\"filter-bar-actions\" className={filterBarRowVariants()}>\n {filters.length > 0 ? (\n <PopoverTrigger isOpen={open} onOpenChange={setOpen}>\n <Button\n variant=\"outlineNeutral\"\n size=\"sm\"\n startIcon={<PlusGlyph />}\n data-slot=\"filter-bar-button\"\n >\n {buttonLabel}\n </Button>\n <Popover\n placement=\"bottom end\"\n offset={8}\n className=\"w-64 max-w-[calc(100vw-2rem)] p-1.5\"\n >\n <FilterPanel\n filters={filters}\n label={buttonLabel}\n backLabel={backLabel}\n onClose={() => setOpen(false)}\n />\n </Popover>\n </PopoverTrigger>\n ) : null}\n {end}\n </div>\n ) : null}\n </div>\n );\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"filter-bar.js","names":["Button","AriaMenu","MenuItem","Popover"],"sources":["../../src/filter-bar/filter-bar.tsx"],"sourcesContent":["\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { forwardRef, useCallback, useEffect, useRef, useState, type ReactNode } from \"react\";\nimport { Menu as AriaMenu, type Key } from \"react-aria-components\";\n\nimport { Button } from \"../button/button.js\";\nimport { cn, cnAll } from \"../lib/cn.js\";\nimport { useHeightFollow } from \"../lib/height-follow.js\";\nimport { useLeavingList } from \"../lib/leaving-list.js\";\nimport { useMenuHighlight } from \"../lib/menu-highlight.js\";\nimport { prefersReducedMotion, useExitBackstop, useIsomorphicLayoutEffect, useMorphWidth } from \"../lib/motion.js\";\nimport { SwapText } from \"../lib/swap-text.js\";\nimport { MenuItem, menuVariants } from \"../menu/menu.js\";\nimport { Popover, PopoverTrigger } from \"../popover/popover.js\";\n\n/**\n * FilterBar -- a filter toolbar: one pill that shows what is filtered as\n * removable chips (\"No filters applied\" while nothing is), with an \"Add filter\"\n * button that opens a menu of the filters.\n *\n * Why it exists: list screens grew a row of four to seven dropdowns beside\n * the search box and the status tabs. At desktop width that row wraps onto a\n * second line; on a phone it scrolls sideways. The bar keeps the row to one\n * line and makes what IS filtered visible as chips instead of as the\n * resting text of a dropdown.\n *\n * Three regions, all inside the one pill:\n *\n * - `inline`: always in the pill, at its start (status tabs, a search box).\n * - the chips, then \"Clear all\" once any filter is set; or the empty note.\n * - the Add filter button, then `end`: always in the pill, at its end (a\n * count, Export, a view toggle).\n *\n * The button opens a menu of the filters: each row is the filter's icon and\n * name, the value it holds now, and a chevron. A row opens that filter's own\n * options in the same panel (a back button returns to the list); choosing an\n * option sets the filter and closes the menu. A custom filter shows its own\n * control in that step.\n *\n * The bar holds NO filter state. Every value and every change belongs to the\n * caller: removing a chip hands the filter's `emptyValue` back to its\n * `onChange`; Clear all does that for each set filter, or calls\n * `onClearAll` when the caller resets its filters in one step.\n *\n * Accessibility comes from the parts: the panel is a dialog named by its\n * trigger, the trigger carries `aria-expanded`, focus moves into the panel and\n * is contained there, Escape closes it and focus returns to the trigger, and\n * each list is a real menu with arrow keys and type-ahead. Removing a chip, by\n * keyboard or pointer, moves focus to the chip that takes its place (the one\n * before it when it was last); removing the last chip, or Clear all, moves it to\n * the Add filter button.\n */\n\n/** The toolbar: one pill with a light border on the surface. */\nexport const filterBarVariants = cva([\n \"flex flex-wrap items-center gap-x-3 gap-y-2 rounded-full\",\n \"border-(length:--border-width) border-border bg-surface\",\n \"px-4 py-2 box-border font-sans\",\n]);\n\n/** The group at the pill's end: the Add filter button and the caller's `end`. */\nexport const filterBarRowVariants = cva([\"ms-auto flex flex-wrap items-center gap-2\"]);\n\nexport const filterBarChipVariants = cva([\n \"inline-flex items-center gap-1 rounded-full\",\n \"border-(length:--border-width) border-border bg-muted\",\n \"ps-3 pe-1 text-xs leading-(--leading-snug) text-foreground\",\n \"box-border font-sans\",\n]);\n\n/** One option of a dropdown filter. */\nexport interface FilterBarOption {\n /** The option's value, handed to `onChange`. */\n id: Key;\n /** The option's text: in the list, on the trigger and on the chip. */\n label: string;\n}\n\n/** A dropdown filter: a row in the Add filter menu that opens its options. */\nexport interface FilterBarSelectField {\n /** Stable identity, used as the React key. */\n id: string;\n /** The field's name: its row in the menu and the start of its chip. */\n label: string;\n /** An icon before the name in the menu row. Mark it `aria-hidden`. */\n icon?: ReactNode;\n /** The current value. `null` reads as not set. */\n value: Key | null;\n /** The value that means \"not filtered\" (the \"All\" option's id). */\n emptyValue: Key;\n /** The options, the \"All\" option included. */\n options: readonly FilterBarOption[];\n /** Told the chosen option's id, or `emptyValue` when the chip is removed. */\n onChange: (value: Key) => void;\n}\n\n/**\n * A filter that is not a plain dropdown (a date range, a searchable picker):\n * the caller renders the control and says what its chip reads.\n */\nexport interface FilterBarCustomField {\n /** Stable identity, used as the React key. */\n id: string;\n /** The start of its chip, and its row in the menu. The control rendered by `render` names itself. */\n label: string;\n /** An icon before the name in the menu row. Mark it `aria-hidden`. */\n icon?: ReactNode;\n /** The control, rendered in the menu's step for this filter. */\n render: () => ReactNode;\n /** The chip's value text while the filter is set, or `null` while it is not. */\n activeLabel: string | null;\n /** Unsets the filter: the chip's remove button and Clear all call it. */\n onClear: () => void;\n}\n\nexport type FilterBarField = FilterBarSelectField | FilterBarCustomField;\n\nexport interface FilterBarProps {\n /** Always in the pill, at its start: status tabs and the search box. */\n inline?: ReactNode;\n /** Always in the pill, at its end: a count, Export, a view toggle. */\n end?: ReactNode;\n /** The filters in the Add filter menu. With none, no button renders. */\n filters: readonly FilterBarField[];\n /** Resets every filter in one step. Without it, each set filter is handed its `emptyValue`. */\n onClearAll?: () => void;\n /**\n * The button's text, and the menu's name.\n *\n * @default \"Add filter\"\n */\n buttonLabel?: string;\n /**\n * The text of Clear all.\n *\n * @default \"Clear all\"\n */\n clearAllLabel?: string;\n /**\n * Ignored: the menu has no Done button, because choosing an option closes it.\n *\n * @deprecated Kept so existing callers still compile.\n */\n doneLabel?: string;\n /**\n * The note shown while no filter is set.\n *\n * @default \"No filters applied\"\n */\n emptyLabel?: string;\n /**\n * The accessible name of the list of chips.\n *\n * @default \"Filters set\"\n */\n chipsLabel?: string;\n /**\n * The accessible name of a chip's remove button, given the chip's text.\n *\n * @default (text) => `Remove filter ${text}`\n */\n removeLabel?: (chipText: string) => string;\n /**\n * The accessible name of the menu's back button.\n *\n * @default \"Back to filters\"\n */\n backLabel?: string;\n /** Extra classes on the root. Merged last, so they win conflicts with ours. */\n className?: string;\n}\n\nconst isCustom = (f: FilterBarField): f is FilterBarCustomField => \"render\" in f;\n\ninterface Chip {\n id: string;\n text: string;\n clear: () => void;\n}\n\n/** What a filter holds now, for its menu row and its chip: `null` while it is not set. */\nfunction valueOf(f: FilterBarField): string | null {\n if (isCustom(f)) return f.activeLabel;\n if (f.value == null || f.value === f.emptyValue) return null;\n const option = f.options.find((o) => o.id === f.value);\n return option ? option.label : String(f.value);\n}\n\n/** The set filters, each with its chip text and how to unset it. */\nfunction chipsFor(filters: readonly FilterBarField[]): Chip[] {\n const chips: Chip[] = [];\n for (const f of filters) {\n const value = valueOf(f);\n if (value == null) continue;\n chips.push({\n id: f.id,\n text: `${f.label}: ${value}`,\n clear: isCustom(f) ? f.onClear : () => f.onChange(f.emptyValue),\n });\n }\n return chips;\n}\n\nfunction Glyph({ path }: { path: string }) {\n return (\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" aria-hidden=\"true\">\n <path d={path} strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n );\n}\n\nconst PlusGlyph = () => <Glyph path=\"M8 3v10M3 8h10\" />;\nconst CrossGlyph = () => <Glyph path=\"M4 4l8 8M12 4l-8 8\" />;\nconst ChevronRightGlyph = () => <Glyph path=\"M6 3l5 5-5 5\" />;\nconst ChevronLeftGlyph = () => <Glyph path=\"M10 3 5 8l5 5\" />;\nconst CheckGlyph = () => <Glyph path=\"M3 8.5l3.5 3.5L13 4.5\" />;\n\n/**\n * A list toolbar with its filters behind one Add filter button.\n *\n * @example\n * <FilterBar\n * end={<Button>Export</Button>}\n * filters={[{ id: \"status\", label: \"Status\", icon: <StatusIcon aria-hidden />, value, emptyValue: \"all\",\n * onChange: setValue, options: [{ id: \"all\", label: \"Any status\" }, { id: \"open\", label: \"Open\" }] }]}\n * />\n */\n/**\n * One place in the chip row, for a chip or for Clear all. A slot added after the\n * first render opens from nothing while its content fades up and scales in, so the\n * chips beside it glide over; one that is removed stays, shrinking and collapsing,\n * until its exit ends. Nothing animates at first render.\n */\nfunction Slot({\n leaving,\n ready,\n onDone,\n id,\n children,\n}: {\n leaving: boolean;\n ready: boolean;\n onDone: (id: string) => void;\n id: string;\n children: ReactNode;\n}) {\n // Decided once, at this slot's own mount.\n const [fresh] = useState(ready);\n const remove = useCallback(() => onDone(id), [onDone, id]);\n useExitBackstop(leaving, remove, 900);\n // Reduced motion removes it at once: an animation that is switched off never ends.\n useIsomorphicLayoutEffect(() => {\n if (leaving && prefersReducedMotion()) remove();\n }, [leaving, remove]);\n return (\n <li\n data-slot={leaving ? \"filter-bar-leaving\" : undefined}\n aria-hidden={leaving || undefined}\n ref={(node) => {\n if (!node) return;\n if (leaving) node.setAttribute(\"inert\", \"\");\n else node.removeAttribute(\"inert\");\n }}\n onAnimationEnd={(event) => {\n if (leaving && event.target === event.currentTarget && event.animationName === \"chip-slot-out\")\n remove();\n }}\n className={cnAll(\n \"min-w-0 flex-none [interpolate-size:allow-keywords]\",\n leaving\n ? \"pointer-events-none motion-safe:animate-chip-out\"\n : fresh && \"motion-safe:animate-chip-in motion-reduce:animate-fade-in\",\n )}\n >\n {children}\n </li>\n );\n}\n\n/**\n * A chip's remove button. It registers itself with the bar under its chip's id\n * through a ref callback that stays the same across renders, so React does not\n * detach and re-attach it on every commit.\n */\nfunction ChipRemove({\n id,\n label,\n register,\n onPress,\n}: {\n id: string;\n label: string;\n register: (id: string, node: HTMLButtonElement | null) => void;\n onPress: () => void;\n}) {\n const ref = useCallback((node: HTMLButtonElement | null) => register(id, node), [register, id]);\n return (\n <Button\n ref={ref}\n variant=\"ghost\"\n size=\"sm\"\n aria-label={label}\n onPress={onPress}\n // The ring is drawn inside the button, which fits inside the chip: the\n // default ring sits outside, stood taller than the chip and covered the end\n // of its label.\n className=\"h-6 rounded-full px-1 data-[focus-visible]:-outline-offset-(--focus-ring-width)\"\n >\n <CrossGlyph />\n </Button>\n );\n}\n\n/** A chip's text: a changed value swaps (the new text rises in) and the chip's width springs to it. */\nfunction ChipText({ text }: { text: string }) {\n const content = useRef<HTMLSpanElement>(null);\n useMorphWidth(content, text);\n return (\n <span className=\"button-slot\">\n <span ref={content} className=\"inline-flex flex-none items-center\">\n <SwapText swapKey={text} className=\"inline-block\">\n {text}\n </SwapText>\n </span>\n </span>\n );\n}\n\n/** A filter's icon, drawn at the control icon size in the secondary ink. */\nfunction RowIcon({ icon }: { icon: ReactNode }) {\n return icon ? (\n <span aria-hidden=\"true\" className=\"inline-flex text-text-secondary\">\n {icon}\n </span>\n ) : null;\n}\n\n/** The note for no filters. Arriving after the first render (the last chip has gone) it fades in; at first render it does not. */\nfunction EmptyNote({ ready, children }: { ready: boolean; children: ReactNode }) {\n // Decided once, at this note's own mount: adding the class later would start the animation on a note already on screen.\n const [fresh] = useState(ready);\n return (\n <span\n data-slot=\"filter-bar-empty\"\n className={cn(\"text-sm text-text-secondary\" + (fresh ? \" motion-safe:animate-fade-in\" : \"\"))}\n >\n {children}\n </span>\n );\n}\n\nconst STEP = \"motion-safe:animate-filter-step-in motion-reduce:animate-fade-in\";\n\n/**\n * The Add filter menu's panel, in two steps: the list of filters, then the\n * chosen filter's options (or its own control). A step arrives from the side it\n * was reached from; the first one, on open, does not animate by itself (the\n * popover grows from the button instead).\n */\nfunction FilterPanel({\n filters,\n label,\n backLabel,\n onClose,\n}: {\n filters: readonly FilterBarField[];\n label: string;\n backLabel: string;\n onClose: () => void;\n}) {\n const [fieldId, setFieldId] = useState<string | null>(null);\n // null until the user has navigated: the first step does not animate by itself.\n const [dir, setDir] = useState<1 | -1 | null>(null);\n // The filter whose step we came back from: focus returns to its row.\n const [returnTo, setReturnTo] = useState<string | null>(null);\n const listRef = useRef<HTMLDivElement>(null);\n const bindFields = useMenuHighlight();\n const bindOptions = useMenuHighlight();\n const field = filters.find((f) => f.id === fieldId) ?? null;\n\n // Coming back, focus lands on the row that was opened rather than on the first one.\n // The rows of a React Aria menu land a frame after it mounts, so this waits for the row.\n useEffect(() => {\n if (field || returnTo === null) return;\n const id = returnTo;\n let frame = 0;\n let tries = 0;\n const focusRow = () => {\n const row = Array.from(\n listRef.current?.querySelectorAll<HTMLElement>(\"[data-slot='menu-item']\") ?? [],\n ).find((node) => node.dataset.key === id);\n if (row) row.focus();\n else if (tries++ < 20) frame = requestAnimationFrame(focusRow);\n };\n focusRow();\n return () => cancelAnimationFrame(frame);\n }, [field, returnTo]);\n\n const open = (id: Key) => {\n setDir(1);\n setFieldId(String(id));\n };\n const back = () => {\n if (!field) return;\n setReturnTo(field.id);\n setDir(-1);\n setFieldId(null);\n };\n const stepClass = dir === null ? \"\" : cn(STEP + (dir === 1 ? \" [--step-dir:1]\" : \" [--step-dir:-1]\"));\n\n return (\n <div data-slot=\"filter-bar-panel\" className=\"overflow-x-clip\">\n {field ? (\n <div\n key={field.id}\n className={stepClass}\n onKeyDown={(event) => {\n // Arrow left steps back, from the options list; a custom control keeps its own keys.\n if (event.key === \"ArrowLeft\" && !isCustom(field)) {\n event.preventDefault();\n back();\n }\n }}\n >\n <div className=\"flex items-center gap-1 px-1 pb-1\">\n <Button\n variant=\"ghost\"\n size=\"icon\"\n aria-label={backLabel}\n onPress={back}\n className=\"size-8 flex-none rounded-full\"\n >\n <ChevronLeftGlyph />\n </Button>\n <span className=\"min-w-0 truncate text-sm font-medium text-foreground\">{field.label}</span>\n </div>\n {isCustom(field) ? (\n <div className=\"p-1.5\">{field.render()}</div>\n ) : (\n <AriaMenu\n ref={bindOptions}\n aria-label={field.label}\n data-slot=\"filter-bar-options\"\n autoFocus=\"first\"\n selectionMode=\"single\"\n disallowEmptySelection\n selectedKeys={field.value == null ? [] : [field.value]}\n onAction={(key) => {\n field.onChange(key);\n onClose();\n }}\n className={menuVariants()}\n >\n {field.options.map((o) => (\n <MenuItem key={o.id} id={o.id} textValue={o.label}>\n <span className=\"min-w-0 flex-1 truncate\">{o.label}</span>\n <span\n aria-hidden=\"true\"\n className={\n o.id === field.value\n ? \"inline-flex text-foreground\"\n : \"inline-flex text-foreground opacity-0\"\n }\n >\n <CheckGlyph />\n </span>\n </MenuItem>\n ))}\n </AriaMenu>\n )}\n </div>\n ) : (\n <div key=\"fields\" ref={listRef} className={stepClass}>\n <AriaMenu\n ref={bindFields}\n aria-label={label}\n data-slot=\"filter-bar-fields\"\n autoFocus={returnTo === null ? \"first\" : false}\n onAction={open}\n // Choosing a filter opens its options; it must not close the panel.\n onClose={() => {}}\n className={menuVariants()}\n >\n {filters.map((f) => {\n const value = valueOf(f);\n return (\n <MenuItem key={f.id} id={f.id} textValue={f.label}>\n <RowIcon icon={f.icon} />\n <span className=\"min-w-0 flex-1 truncate\">{f.label}</span>\n {value ? (\n <span className=\"max-w-24 truncate text-xs text-text-secondary\">{value}</span>\n ) : null}\n <span aria-hidden=\"true\" className=\"inline-flex text-text-secondary\">\n <ChevronRightGlyph />\n </span>\n </MenuItem>\n );\n })}\n </AriaMenu>\n </div>\n )}\n </div>\n );\n}\n\nexport const FilterBar = forwardRef<HTMLDivElement, FilterBarProps>(function FilterBar(\n {\n inline,\n end,\n filters,\n onClearAll,\n buttonLabel = \"Add filter\",\n clearAllLabel = \"Clear all\",\n emptyLabel = \"No filters applied\",\n chipsLabel = \"Filters set\",\n removeLabel = (text) => `Remove filter ${text}`,\n backLabel = \"Back to filters\",\n className,\n },\n ref,\n) {\n const [open, setOpen] = useState(false);\n const chips = chipsFor(filters);\n const [ready, setReady] = useState(false);\n useEffect(() => {\n // A frame after mount: chips added from then on arrive animated, the first ones do not.\n const frame = requestAnimationFrame(() => setReady(true));\n return () => cancelAnimationFrame(frame);\n }, []);\n // The chips, with Clear all as the row's last place, each kept on screen while it leaves.\n const places = useLeavingList([\n ...chips.map((chip) => ({ id: chip.id, chip })),\n ...(chips.length > 0 ? [{ id: \"\\u0000clear\", chip: null }] : []),\n ]);\n const frame = useRef<HTMLDivElement>(null);\n const row = useRef<HTMLUListElement>(null);\n // The row follows its height when a chip makes a line wrap or unwrap.\n const chipKey = chips.map((chip) => chip.id).join();\n useHeightFollow(frame, row, chipKey);\n // A removed chip's slot turns inert as it leaves, which would drop focus to the\n // page: where focus goes instead is noted on the press (the removed chip's index,\n // or \"add\" for Clear all) and moved once the caller's change has removed it.\n const removeButtons = useRef(new Map<string, HTMLButtonElement>());\n const registerRemove = useCallback((id: string, node: HTMLButtonElement | null) => {\n if (node) removeButtons.current.set(id, node);\n else removeButtons.current.delete(id);\n }, []);\n const addButton = useRef<HTMLButtonElement>(null);\n const focusAfter = useRef<number | \"add\" | null>(null);\n const noteFocusAfter = (after: number | \"add\") => {\n focusAfter.current = after;\n // A caller that ignores the press changes no chip, so the note is never\n // applied; drop it a frame later, or it would move focus on some later,\n // unrelated change to the chips. A caller that does change them commits\n // inside the press, before this frame.\n requestAnimationFrame(() => {\n if (focusAfter.current === after) focusAfter.current = null;\n });\n };\n useIsomorphicLayoutEffect(() => {\n const after = focusAfter.current;\n if (after === null) return;\n focusAfter.current = null;\n const next =\n after === \"add\" || chips.length === 0 ? null : chips[Math.min(after, chips.length - 1)]!;\n (next ? removeButtons.current.get(next.id) : addButton.current)?.focus();\n }, [chipKey]);\n const clearAll = () => {\n noteFocusAfter(\"add\");\n if (onClearAll) onClearAll();\n else for (const c of chips) c.clear();\n };\n\n return (\n <div ref={ref} data-slot=\"filter-bar\" className={cn(filterBarVariants(), className)}>\n {inline ? <div className=\"flex flex-wrap items-center gap-2\">{inline}</div> : null}\n <div\n data-slot=\"filter-bar-chips-area\"\n className=\"flex min-h-(--density-control-h-sm) min-w-0 flex-[1_1_8rem] items-center\"\n >\n {places.list.length > 0 ? (\n <div ref={frame} data-slot=\"filter-bar-chips-frame\" className=\"height-follow min-w-0\">\n <ul\n ref={row}\n aria-label={chipsLabel}\n data-slot=\"filter-bar-chips\"\n className=\"m-0 flex list-none flex-wrap items-center gap-2 p-0\"\n >\n {places.list.map(({ item, leaving }) =>\n item.chip ? (\n <Slot key={item.id} id={item.id} leaving={leaving} ready={ready} onDone={places.done}>\n <span data-slot=\"filter-bar-chip\" className={filterBarChipVariants()}>\n <ChipText text={item.chip.text} />\n <ChipRemove\n id={item.id}\n label={removeLabel(item.chip.text)}\n register={registerRemove}\n onPress={() => {\n noteFocusAfter(chips.findIndex((chip) => chip.id === item.id));\n item.chip.clear();\n }}\n />\n </span>\n </Slot>\n ) : (\n <Slot key={item.id} id={item.id} leaving={leaving} ready={ready} onDone={places.done}>\n <Button variant=\"link\" size=\"sm\" onPress={clearAll}>\n {clearAllLabel}\n </Button>\n </Slot>\n ),\n )}\n </ul>\n </div>\n ) : (\n <EmptyNote ready={ready}>{emptyLabel}</EmptyNote>\n )}\n </div>\n {filters.length > 0 || end ? (\n <div data-slot=\"filter-bar-actions\" className={filterBarRowVariants()}>\n {filters.length > 0 ? (\n <PopoverTrigger isOpen={open} onOpenChange={setOpen}>\n <Button\n ref={addButton}\n variant=\"outlineNeutral\"\n size=\"sm\"\n startIcon={<PlusGlyph />}\n data-slot=\"filter-bar-button\"\n >\n {buttonLabel}\n </Button>\n <Popover\n placement=\"bottom end\"\n offset={8}\n className=\"w-64 max-w-[calc(100vw-2rem)] p-1.5\"\n >\n <FilterPanel\n filters={filters}\n label={buttonLabel}\n backLabel={backLabel}\n onClose={() => setOpen(false)}\n />\n </Popover>\n </PopoverTrigger>\n ) : null}\n {end}\n </div>\n ) : null}\n </div>\n );\n});\n"],"mappings":"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"}
@@ -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, {