@teamofsilicons/silicon-ui 0.0.0-stage → 0.1.0

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 (352) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +63 -2
  3. package/components/accordion/accordion.d.ts +13 -0
  4. package/components/accordion/accordion.js +35 -0
  5. package/components/accordion/accordion.module.css +15 -0
  6. package/components/action-button/action-button.d.ts +11 -0
  7. package/components/action-button/action-button.js +57 -0
  8. package/components/action-button/action-button.module.css +15 -0
  9. package/components/activity-heatmap/activity-heatmap.d.ts +37 -0
  10. package/components/activity-heatmap/activity-heatmap.js +303 -0
  11. package/components/activity-heatmap/activity-heatmap.module.css +83 -0
  12. package/components/alert/alert.d.ts +13 -0
  13. package/components/alert/alert.js +79 -0
  14. package/components/alert/alert.module.css +15 -0
  15. package/components/animated-counter/animated-counter.d.ts +13 -0
  16. package/components/animated-counter/animated-counter.js +73 -0
  17. package/components/animated-counter/animated-counter.module.css +12 -0
  18. package/components/announcement-bar/announcement-bar.d.ts +53 -0
  19. package/components/announcement-bar/announcement-bar.js +187 -0
  20. package/components/announcement-bar/announcement-bar.module.css +60 -0
  21. package/components/avatar/avatar.d.ts +8 -0
  22. package/components/avatar/avatar.js +18 -0
  23. package/components/avatar/avatar.module.css +60 -0
  24. package/components/avatar-group/avatar-group.d.ts +13 -0
  25. package/components/avatar-group/avatar-group.js +24 -0
  26. package/components/avatar-group/avatar-group.module.css +27 -0
  27. package/components/badge/badge.d.ts +10 -0
  28. package/components/badge/badge.js +66 -0
  29. package/components/badge/badge.module.css +52 -0
  30. package/components/bar-chart/bar-chart.d.ts +32 -0
  31. package/components/bar-chart/bar-chart.js +327 -0
  32. package/components/bar-chart/bar-chart.module.css +49 -0
  33. package/components/billing-toggle/billing-toggle.d.ts +35 -0
  34. package/components/billing-toggle/billing-toggle.js +83 -0
  35. package/components/billing-toggle/billing-toggle.module.css +139 -0
  36. package/components/bottom-sheet/bottom-sheet.d.ts +27 -0
  37. package/components/bottom-sheet/bottom-sheet.js +386 -0
  38. package/components/bottom-sheet/bottom-sheet.module.css +69 -0
  39. package/components/breadcrumb/breadcrumb.d.ts +13 -0
  40. package/components/breadcrumb/breadcrumb.js +19 -0
  41. package/components/breadcrumb/breadcrumb.module.css +12 -0
  42. package/components/brush-chart/brush-chart.d.ts +45 -0
  43. package/components/brush-chart/brush-chart.js +426 -0
  44. package/components/brush-chart/brush-chart.module.css +69 -0
  45. package/components/button/button.d.ts +10 -0
  46. package/components/button/button.js +97 -0
  47. package/components/button/button.module.css +39 -0
  48. package/components/button-group/button-group.d.ts +59 -0
  49. package/components/button-group/button-group.js +280 -0
  50. package/components/button-group/button-group.module.css +87 -0
  51. package/components/button-link/button-link.d.ts +24 -0
  52. package/components/button-link/button-link.js +11 -0
  53. package/components/button-link/button-link.module.css +7 -0
  54. package/components/calendar/calendar.d.ts +22 -0
  55. package/components/calendar/calendar.js +253 -0
  56. package/components/calendar/calendar.module.css +51 -0
  57. package/components/card/card.d.ts +19 -0
  58. package/components/card/card.js +108 -0
  59. package/components/card/card.module.css +51 -0
  60. package/components/card-stack/card-stack.d.ts +34 -0
  61. package/components/card-stack/card-stack.js +291 -0
  62. package/components/card-stack/card-stack.module.css +63 -0
  63. package/components/carousel/carousel.d.ts +28 -0
  64. package/components/carousel/carousel.js +332 -0
  65. package/components/carousel/carousel.module.css +42 -0
  66. package/components/chat-thread/chat-thread.d.ts +135 -0
  67. package/components/chat-thread/chat-thread.js +406 -0
  68. package/components/chat-thread/chat-thread.module.css +242 -0
  69. package/components/checkbox/checkbox.d.ts +7 -0
  70. package/components/checkbox/checkbox.js +23 -0
  71. package/components/checkbox/checkbox.module.css +15 -0
  72. package/components/chip-group/chip-group.d.ts +24 -0
  73. package/components/chip-group/chip-group.js +196 -0
  74. package/components/chip-group/chip-group.module.css +38 -0
  75. package/components/code-block/code-block.d.ts +11 -0
  76. package/components/code-block/code-block.js +133 -0
  77. package/components/code-block/code-block.module.css +43 -0
  78. package/components/color-picker/color-picker.d.ts +40 -0
  79. package/components/color-picker/color-picker.js +449 -0
  80. package/components/color-picker/color-picker.module.css +111 -0
  81. package/components/combobox/combobox.d.ts +19 -0
  82. package/components/combobox/combobox.js +167 -0
  83. package/components/combobox/combobox.module.css +28 -0
  84. package/components/command-palette/command-palette.d.ts +20 -0
  85. package/components/command-palette/command-palette.js +151 -0
  86. package/components/command-palette/command-palette.module.css +94 -0
  87. package/components/comment-thread/comment-thread.d.ts +120 -0
  88. package/components/comment-thread/comment-thread.js +343 -0
  89. package/components/comment-thread/comment-thread.module.css +195 -0
  90. package/components/confirm-morph/confirm-morph.d.ts +65 -0
  91. package/components/confirm-morph/confirm-morph.js +355 -0
  92. package/components/confirm-morph/confirm-morph.module.css +146 -0
  93. package/components/context-menu/context-menu.d.ts +18 -0
  94. package/components/context-menu/context-menu.js +148 -0
  95. package/components/context-menu/context-menu.module.css +17 -0
  96. package/components/copy-button/copy-button.d.ts +10 -0
  97. package/components/copy-button/copy-button.js +69 -0
  98. package/components/copy-button/copy-button.module.css +21 -0
  99. package/components/countdown/countdown.d.ts +31 -0
  100. package/components/countdown/countdown.js +177 -0
  101. package/components/countdown/countdown.module.css +49 -0
  102. package/components/date-picker/date-picker.d.ts +17 -0
  103. package/components/date-picker/date-picker.js +100 -0
  104. package/components/date-picker/date-picker.module.css +34 -0
  105. package/components/date-range-picker/date-range-picker.d.ts +44 -0
  106. package/components/date-range-picker/date-range-picker.js +534 -0
  107. package/components/date-range-picker/date-range-picker.module.css +214 -0
  108. package/components/dialog/dialog.d.ts +11 -0
  109. package/components/dialog/dialog.js +52 -0
  110. package/components/dialog/dialog.module.css +36 -0
  111. package/components/donut-chart/donut-chart.d.ts +46 -0
  112. package/components/donut-chart/donut-chart.js +382 -0
  113. package/components/donut-chart/donut-chart.module.css +48 -0
  114. package/components/drawer/drawer.d.ts +20 -0
  115. package/components/drawer/drawer.js +111 -0
  116. package/components/drawer/drawer.module.css +97 -0
  117. package/components/dropdown-menu/dropdown-menu.d.ts +16 -0
  118. package/components/dropdown-menu/dropdown-menu.js +39 -0
  119. package/components/dropdown-menu/dropdown-menu.module.css +38 -0
  120. package/components/elastic-slider/elastic-slider.d.ts +39 -0
  121. package/components/elastic-slider/elastic-slider.js +261 -0
  122. package/components/elastic-slider/elastic-slider.module.css +40 -0
  123. package/components/empty-state/empty-state.d.ts +12 -0
  124. package/components/empty-state/empty-state.js +69 -0
  125. package/components/empty-state/empty-state.module.css +13 -0
  126. package/components/expandable-card/expandable-card.d.ts +13 -0
  127. package/components/expandable-card/expandable-card.js +89 -0
  128. package/components/expandable-card/expandable-card.module.css +22 -0
  129. package/components/expanding-button-group/expanding-button-group.d.ts +41 -0
  130. package/components/expanding-button-group/expanding-button-group.js +258 -0
  131. package/components/expanding-button-group/expanding-button-group.module.css +44 -0
  132. package/components/expanding-search/expanding-search.d.ts +38 -0
  133. package/components/expanding-search/expanding-search.js +242 -0
  134. package/components/expanding-search/expanding-search.module.css +52 -0
  135. package/components/file-dropzone/file-dropzone.d.ts +44 -0
  136. package/components/file-dropzone/file-dropzone.js +379 -0
  137. package/components/file-dropzone/file-dropzone.module.css +115 -0
  138. package/components/file-upload/file-upload.d.ts +22 -0
  139. package/components/file-upload/file-upload.js +192 -0
  140. package/components/file-upload/file-upload.module.css +49 -0
  141. package/components/filter-toolbar/filter-toolbar.d.ts +48 -0
  142. package/components/filter-toolbar/filter-toolbar.js +446 -0
  143. package/components/filter-toolbar/filter-toolbar.module.css +70 -0
  144. package/components/floating-button-group/floating-button-group.d.ts +51 -0
  145. package/components/floating-button-group/floating-button-group.js +215 -0
  146. package/components/floating-button-group/floating-button-group.module.css +74 -0
  147. package/components/gauge/gauge.d.ts +18 -0
  148. package/components/gauge/gauge.js +101 -0
  149. package/components/gauge/gauge.module.css +22 -0
  150. package/components/hold-to-confirm/hold-to-confirm.d.ts +25 -0
  151. package/components/hold-to-confirm/hold-to-confirm.js +165 -0
  152. package/components/hold-to-confirm/hold-to-confirm.module.css +20 -0
  153. package/components/hover-card/hover-card.d.ts +38 -0
  154. package/components/hover-card/hover-card.js +145 -0
  155. package/components/hover-card/hover-card.module.css +20 -0
  156. package/components/image-compare/image-compare.d.ts +27 -0
  157. package/components/image-compare/image-compare.js +267 -0
  158. package/components/image-compare/image-compare.module.css +25 -0
  159. package/components/in-view-title/in-view-title.d.ts +17 -0
  160. package/components/in-view-title/in-view-title.js +70 -0
  161. package/components/in-view-title/in-view-title.module.css +13 -0
  162. package/components/inline-edit/inline-edit.d.ts +27 -0
  163. package/components/inline-edit/inline-edit.js +315 -0
  164. package/components/inline-edit/inline-edit.module.css +73 -0
  165. package/components/input/input.d.ts +7 -0
  166. package/components/input/input.js +60 -0
  167. package/components/input/input.module.css +22 -0
  168. package/components/json-viewer/json-viewer.d.ts +48 -0
  169. package/components/json-viewer/json-viewer.js +463 -0
  170. package/components/json-viewer/json-viewer.module.css +92 -0
  171. package/components/line-chart/line-chart.d.ts +58 -0
  172. package/components/line-chart/line-chart.js +383 -0
  173. package/components/line-chart/line-chart.module.css +60 -0
  174. package/components/mention-input/mention-input.d.ts +72 -0
  175. package/components/mention-input/mention-input.js +417 -0
  176. package/components/mention-input/mention-input.module.css +134 -0
  177. package/components/metric-card/metric-card.d.ts +8 -0
  178. package/components/metric-card/metric-card.js +44 -0
  179. package/components/metric-card/metric-card.module.css +18 -0
  180. package/components/money-input/money-input.d.ts +48 -0
  181. package/components/money-input/money-input.js +503 -0
  182. package/components/money-input/money-input.module.css +77 -0
  183. package/components/morph-select/morph-select.d.ts +50 -0
  184. package/components/morph-select/morph-select.js +395 -0
  185. package/components/morph-select/morph-select.module.css +88 -0
  186. package/components/multi-select/multi-select.d.ts +18 -0
  187. package/components/multi-select/multi-select.js +105 -0
  188. package/components/multi-select/multi-select.module.css +37 -0
  189. package/components/notification-center/notification-center.d.ts +24 -0
  190. package/components/notification-center/notification-center.js +146 -0
  191. package/components/notification-center/notification-center.module.css +79 -0
  192. package/components/number-field/number-field.d.ts +41 -0
  193. package/components/number-field/number-field.js +455 -0
  194. package/components/number-field/number-field.module.css +71 -0
  195. package/components/otp-input/otp-input.d.ts +13 -0
  196. package/components/otp-input/otp-input.js +129 -0
  197. package/components/otp-input/otp-input.module.css +28 -0
  198. package/components/pagination/pagination.d.ts +7 -0
  199. package/components/pagination/pagination.js +60 -0
  200. package/components/pagination/pagination.module.css +16 -0
  201. package/components/password-field/password-field.d.ts +14 -0
  202. package/components/password-field/password-field.js +51 -0
  203. package/components/password-field/password-field.module.css +27 -0
  204. package/components/password-strength/password-strength.d.ts +107 -0
  205. package/components/password-strength/password-strength.js +161 -0
  206. package/components/password-strength/password-strength.module.css +57 -0
  207. package/components/phone-input/phone-input.d.ts +73 -0
  208. package/components/phone-input/phone-input.js +612 -0
  209. package/components/phone-input/phone-input.module.css +98 -0
  210. package/components/popover/popover.d.ts +6 -0
  211. package/components/popover/popover.js +19 -0
  212. package/components/popover/popover.module.css +32 -0
  213. package/components/progress/progress.d.ts +8 -0
  214. package/components/progress/progress.js +42 -0
  215. package/components/progress/progress.module.css +1 -0
  216. package/components/radio-cards/radio-cards.d.ts +35 -0
  217. package/components/radio-cards/radio-cards.js +110 -0
  218. package/components/radio-cards/radio-cards.module.css +84 -0
  219. package/components/radio-group/radio-group.d.ts +13 -0
  220. package/components/radio-group/radio-group.js +55 -0
  221. package/components/radio-group/radio-group.module.css +30 -0
  222. package/components/resizable-panels/resizable-panels.d.ts +37 -0
  223. package/components/resizable-panels/resizable-panels.js +338 -0
  224. package/components/resizable-panels/resizable-panels.module.css +40 -0
  225. package/components/rich-text-editor/rich-text-editor.d.ts +55 -0
  226. package/components/rich-text-editor/rich-text-editor.js +1733 -0
  227. package/components/rich-text-editor/rich-text-editor.module.css +201 -0
  228. package/components/ridgeline/ridgeline.d.ts +37 -0
  229. package/components/ridgeline/ridgeline.js +346 -0
  230. package/components/ridgeline/ridgeline.module.css +58 -0
  231. package/components/scroll-area/scroll-area.d.ts +40 -0
  232. package/components/scroll-area/scroll-area.js +153 -0
  233. package/components/scroll-area/scroll-area.module.css +79 -0
  234. package/components/search-field/search-field.d.ts +7 -0
  235. package/components/search-field/search-field.js +20 -0
  236. package/components/search-field/search-field.module.css +17 -0
  237. package/components/segmented-control/segmented-control.d.ts +16 -0
  238. package/components/segmented-control/segmented-control.js +56 -0
  239. package/components/segmented-control/segmented-control.module.css +15 -0
  240. package/components/select/select.d.ts +15 -0
  241. package/components/select/select.js +36 -0
  242. package/components/select/select.module.css +35 -0
  243. package/components/shortcut-recorder/shortcut-recorder.d.ts +108 -0
  244. package/components/shortcut-recorder/shortcut-recorder.js +380 -0
  245. package/components/shortcut-recorder/shortcut-recorder.module.css +85 -0
  246. package/components/signature-pad/signature-pad.d.ts +72 -0
  247. package/components/signature-pad/signature-pad.js +450 -0
  248. package/components/signature-pad/signature-pad.module.css +84 -0
  249. package/components/skeleton/skeleton.d.ts +13 -0
  250. package/components/skeleton/skeleton.js +53 -0
  251. package/components/skeleton/skeleton.module.css +11 -0
  252. package/components/slider/slider.d.ts +45 -0
  253. package/components/slider/slider.js +347 -0
  254. package/components/slider/slider.module.css +45 -0
  255. package/components/slope-chart/slope-chart.d.ts +38 -0
  256. package/components/slope-chart/slope-chart.js +183 -0
  257. package/components/slope-chart/slope-chart.module.css +51 -0
  258. package/components/slot-text/slot-text.d.ts +26 -0
  259. package/components/slot-text/slot-text.js +119 -0
  260. package/components/slot-text/slot-text.module.css +19 -0
  261. package/components/sortable-data-table/sortable-data-table.d.ts +38 -0
  262. package/components/sortable-data-table/sortable-data-table.js +240 -0
  263. package/components/sortable-data-table/sortable-data-table.module.css +116 -0
  264. package/components/sparkline/sparkline.d.ts +19 -0
  265. package/components/sparkline/sparkline.js +239 -0
  266. package/components/sparkline/sparkline.module.css +26 -0
  267. package/components/split-button/split-button.d.ts +19 -0
  268. package/components/split-button/split-button.js +33 -0
  269. package/components/split-button/split-button.module.css +51 -0
  270. package/components/stepper/stepper.d.ts +36 -0
  271. package/components/stepper/stepper.js +108 -0
  272. package/components/stepper/stepper.module.css +74 -0
  273. package/components/streamgraph/streamgraph.d.ts +51 -0
  274. package/components/streamgraph/streamgraph.js +376 -0
  275. package/components/streamgraph/streamgraph.module.css +55 -0
  276. package/components/swipe-actions/swipe-actions.d.ts +35 -0
  277. package/components/swipe-actions/swipe-actions.js +330 -0
  278. package/components/swipe-actions/swipe-actions.module.css +64 -0
  279. package/components/switch/switch.d.ts +7 -0
  280. package/components/switch/switch.js +45 -0
  281. package/components/switch/switch.module.css +12 -0
  282. package/components/tabs/tabs.d.ts +8 -0
  283. package/components/tabs/tabs.js +138 -0
  284. package/components/tabs/tabs.module.css +27 -0
  285. package/components/tag-input/tag-input.d.ts +15 -0
  286. package/components/tag-input/tag-input.js +150 -0
  287. package/components/tag-input/tag-input.module.css +34 -0
  288. package/components/text-morph/text-morph.d.ts +13 -0
  289. package/components/text-morph/text-morph.js +69 -0
  290. package/components/text-morph/text-morph.module.css +8 -0
  291. package/components/text-reveal/text-reveal.d.ts +14 -0
  292. package/components/text-reveal/text-reveal.js +20 -0
  293. package/components/text-reveal/text-reveal.module.css +13 -0
  294. package/components/text-shimmer/text-shimmer.d.ts +18 -0
  295. package/components/text-shimmer/text-shimmer.js +90 -0
  296. package/components/text-shimmer/text-shimmer.module.css +9 -0
  297. package/components/textarea/textarea.d.ts +7 -0
  298. package/components/textarea/textarea.js +56 -0
  299. package/components/textarea/textarea.module.css +22 -0
  300. package/components/theme-switch/theme-switch.d.ts +11 -0
  301. package/components/theme-switch/theme-switch.js +37 -0
  302. package/components/theme-switch/theme-switch.module.css +31 -0
  303. package/components/time-picker/time-picker.d.ts +13 -0
  304. package/components/time-picker/time-picker.js +95 -0
  305. package/components/time-picker/time-picker.module.css +23 -0
  306. package/components/timeline/timeline.d.ts +47 -0
  307. package/components/timeline/timeline.js +171 -0
  308. package/components/timeline/timeline.module.css +51 -0
  309. package/components/toast/toast.d.ts +8 -0
  310. package/components/toast/toast.js +118 -0
  311. package/components/toast/toast.module.css +100 -0
  312. package/components/toast-stack/toast-stack.d.ts +57 -0
  313. package/components/toast-stack/toast-stack.js +399 -0
  314. package/components/toast-stack/toast-stack.module.css +61 -0
  315. package/components/tooltip/tooltip.d.ts +8 -0
  316. package/components/tooltip/tooltip.js +62 -0
  317. package/components/tooltip/tooltip.module.css +18 -0
  318. package/components/tree-view/tree-view.d.ts +17 -0
  319. package/components/tree-view/tree-view.js +147 -0
  320. package/components/tree-view/tree-view.module.css +22 -0
  321. package/components/treemap/treemap.d.ts +35 -0
  322. package/components/treemap/treemap.js +375 -0
  323. package/components/treemap/treemap.module.css +59 -0
  324. package/components/usage-meter/usage-meter.d.ts +24 -0
  325. package/components/usage-meter/usage-meter.js +296 -0
  326. package/components/usage-meter/usage-meter.module.css +61 -0
  327. package/components/user-menu/user-menu.d.ts +62 -0
  328. package/components/user-menu/user-menu.js +316 -0
  329. package/components/user-menu/user-menu.module.css +97 -0
  330. package/components/waffle-chart/waffle-chart.d.ts +39 -0
  331. package/components/waffle-chart/waffle-chart.js +244 -0
  332. package/components/waffle-chart/waffle-chart.module.css +52 -0
  333. package/components.json +1330 -0
  334. package/lib/escape.d.ts +19 -0
  335. package/lib/escape.js +50 -0
  336. package/lib/media.d.ts +353 -0
  337. package/lib/media.js +65 -0
  338. package/lib/menu-highlight.d.ts +28 -0
  339. package/lib/menu-highlight.js +43 -0
  340. package/lib/motion-tokens.d.ts +60 -0
  341. package/lib/motion-tokens.js +25 -0
  342. package/lib/return-focus.d.ts +9 -0
  343. package/lib/return-focus.js +53 -0
  344. package/lib/silicon-provider.d.ts +60 -0
  345. package/lib/silicon-provider.js +47 -0
  346. package/lib/use-copy-feedback.d.ts +8 -0
  347. package/lib/use-copy-feedback.js +36 -0
  348. package/lib/use-today.d.ts +2 -0
  349. package/lib/use-today.js +29 -0
  350. package/package.json +66 -4
  351. package/styles.css +218 -0
  352. package/styles.css.d.ts +1 -0
@@ -0,0 +1,253 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
4
+ import { ChevronLeft, ChevronRight } from "lucide-react";
5
+ import { animate, AnimatePresence, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform } from "motion/react";
6
+ import { motionTokens } from "../../lib/motion-tokens.js";
7
+ import { useToday } from "../../lib/use-today.js";
8
+ import styles from "./calendar.module.css";
9
+ const startOfDay = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());
10
+ const monthStart = (date) => new Date(date.getFullYear(), date.getMonth(), 1);
11
+ const sameDay = (a, b) => Boolean(a && b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate());
12
+ const sameMonth = (a, b) => Boolean(a && b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth());
13
+ const addDays = (date, amount) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount);
14
+ const addMonths = (date, amount) => new Date(date.getFullYear(), date.getMonth() + amount, 1);
15
+ /** Moves by whole months and keeps the day, clamped to the shorter month: January 31 plus one month is February 28. */
16
+ const shiftMonths = (date, amount) => new Date(date.getFullYear(), date.getMonth() + amount, Math.min(date.getDate(), new Date(date.getFullYear(), date.getMonth() + amount + 1, 0).getDate()));
17
+ const isBefore = (a, b) => Boolean(b && startOfDay(a).getTime() < startOfDay(b).getTime());
18
+ const isAfter = (a, b) => Boolean(b && startOfDay(a).getTime() > startOfDay(b).getTime());
19
+ const dateKey = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
20
+ /** Every month shows six weeks, so the grid keeps one height and never jumps while months change. */
21
+ const makeWeeks = (month) => {
22
+ const start = addDays(month, -month.getDay());
23
+ return Array.from({ length: 6 }, (_, week) => Array.from({ length: 7 }, (_, day) => addDays(start, week * 7 + day)));
24
+ };
25
+ const subscribeNothing = () => () => { };
26
+ const clientSnapshot = () => true;
27
+ const serverSnapshot = () => false;
28
+ /** The viewer's local date (lib/use-today.ts, shared with date-range-picker). Re-exported so existing imports from calendar keep working. */
29
+ export { useToday } from "../../lib/use-today.js";
30
+ const { spring, duration, ease } = motionTokens;
31
+ /** Months sit side by side on one strip. Rapid clicks retarget the same spring, so the strip never queues or stacks panes. */
32
+ const stripSpring = { type: "spring", visualDuration: 0.36, bounce: 0, restDelta: 0.002 };
33
+ const monthIndex = (date) => date.getFullYear() * 12 + date.getMonth();
34
+ const fromIndex = (index) => new Date(Math.floor(index / 12), ((index % 12) + 12) % 12, 1);
35
+ /** The title crossfades in place: the old month leaves toward the side the strip travels to while the new one arrives from the other. */
36
+ const titleSlide = {
37
+ enter: (direction) => ({ opacity: 0, x: direction * 12 }),
38
+ center: { opacity: 1, x: 0, transition: { duration: duration.standard, ease: ease.enter } },
39
+ exit: (direction) => ({ opacity: 0, x: direction * -12, transition: { duration: 0.14, ease: ease.standard } }),
40
+ };
41
+ /** Reduced motion swaps the title in place, with no travel and no blank frame. */
42
+ const titleFade = { enter: { opacity: 1, x: 0 }, center: { opacity: 1, x: 0, transition: { duration: 0 } }, exit: { opacity: 0, transition: { duration: 0 } } };
43
+ /**
44
+ * The selected disc of one month. It is positioned by week and weekday rather than measured, so it glides on a straight
45
+ * line between any two days, keeps gliding while the strip slides, and retargets mid-flight on a new pick.
46
+ * It only grows in or fades out when the selection enters or leaves this month.
47
+ */
48
+ function SelectionDisc({ cell, reduced }) {
49
+ const visible = cell >= 0;
50
+ const col = useMotionValue(visible ? cell % 7 : 0);
51
+ const row = useMotionValue(visible ? Math.floor(cell / 7) : 0);
52
+ const opacity = useMotionValue(visible ? 1 : 0);
53
+ const scale = useMotionValue(visible ? 1 : 0.6);
54
+ // Each step is one cell plus the 4px gap; percentages resolve against the disc's own size, which matches a cell.
55
+ const transform = useTransform(() => `translate(calc(${col.get()} * (100% + 4px)), calc(${row.get()} * (100% + 4px))) scale(${scale.get()})`);
56
+ const hidden = useRef(!visible);
57
+ useLayoutEffect(() => {
58
+ if (!visible) {
59
+ hidden.current = true;
60
+ const fade = reduced ? { duration: 0 } : { duration: 0.14, ease: ease.standard };
61
+ const controls = [animate(opacity, 0, fade), animate(scale, 0.6, fade)];
62
+ return () => controls.forEach((control) => control.stop());
63
+ }
64
+ const nextCol = cell % 7, nextRow = Math.floor(cell / 7);
65
+ const controls = [];
66
+ if (hidden.current || reduced) {
67
+ col.jump(nextCol);
68
+ row.jump(nextRow);
69
+ }
70
+ else
71
+ controls.push(animate(col, nextCol, spring.morph), animate(row, nextRow, spring.morph));
72
+ hidden.current = false;
73
+ controls.push(animate(opacity, 1, reduced ? { duration: 0 } : { duration: duration.fast, ease: ease.enter }), animate(scale, 1, reduced ? { duration: 0 } : spring.snappy));
74
+ return () => controls.forEach((control) => control.stop());
75
+ }, [cell, visible, reduced, col, row, opacity, scale]);
76
+ return _jsx(motion.span, { className: styles.highlight, style: { transform, opacity }, "aria-hidden": "true", children: _jsx("span", { className: styles.highlightFill }) });
77
+ }
78
+ /** One month on the strip. Only the month being navigated to is focusable and exposed; the one sliding past is inert. */
79
+ function MonthPane({ index, position, present, children }) {
80
+ const x = useTransform(position, (value) => `calc(${(index - value) * 100}% + ${(index - value) * 16}px)`);
81
+ const opacity = useTransform(position, (value) => 1 - Math.min(1, Math.abs(index - value)) * 0.6);
82
+ return _jsx(motion.div, { className: styles.monthBody, style: { x, opacity }, "data-present": present || undefined, "aria-hidden": present ? undefined : true, inert: !present, children: children });
83
+ }
84
+ export function Calendar({ value, onChange, month: controlledMonth, onMonthChange, minDate, maxDate, disabledDates, locale = "en-US", className, showToday = false, }) {
85
+ const titleId = useId();
86
+ const today = useToday();
87
+ // Motion preference only counts after hydration, so server and client markup agree.
88
+ const hydrated = useSyncExternalStore(subscribeNothing, clientSnapshot, serverSnapshot);
89
+ const reducedMotion = (useReducedMotion() ?? false) && hydrated;
90
+ const [internalMonth, setInternalMonth] = useState(() => (value ? monthStart(value) : undefined));
91
+ const [focusedDate, setFocusedDate] = useState(value);
92
+ const viewportRef = useRef(null);
93
+ const focusRequest = useRef(null);
94
+ // Without a value or month the calendar waits for the client's today, then settles on that month once.
95
+ const fallback = value ?? today;
96
+ const month = controlledMonth ? monthStart(controlledMonth) : internalMonth ?? (fallback && monthStart(fallback));
97
+ if (!controlledMonth && !internalMonth && month)
98
+ setInternalMonth(month);
99
+ const monthTime = month?.getTime();
100
+ const monthKey = month ? dateKey(month) : "pending";
101
+ // Direction is read from the month itself, so arrows, keys, Today, and a controlled month all travel the same way.
102
+ const [shownMonth, setShownMonth] = useState(monthTime);
103
+ const [direction, setDirection] = useState(0);
104
+ if (shownMonth !== monthTime) {
105
+ setShownMonth(monthTime);
106
+ setDirection(shownMonth === undefined || monthTime === undefined ? 0 : monthTime > shownMonth ? 1 : -1);
107
+ }
108
+ const weeks = useMemo(() => (monthTime === undefined ? [] : makeWeeks(new Date(monthTime))), [monthTime]);
109
+ // The strip position is measured in months. The panes on either side of it render, plus the month being navigated to.
110
+ const target = month ? monthIndex(month) : 0;
111
+ const position = useMotionValue(target);
112
+ const [span, setSpan] = useState([target, target]);
113
+ useMotionValueEvent(position, "change", (value) => {
114
+ const next = [Math.floor(value + 1e-3), Math.ceil(value - 1e-3)];
115
+ setSpan((current) => (current[0] === next[0] && current[1] === next[1] ? current : next));
116
+ });
117
+ const ready = month !== undefined;
118
+ const placed = useRef(ready);
119
+ useLayoutEffect(() => {
120
+ if (!ready)
121
+ return;
122
+ const from = position.get();
123
+ if (from === target)
124
+ return;
125
+ // The first month and reduced motion land in place; everything else slides on the strip.
126
+ if (!placed.current || reducedMotion) {
127
+ placed.current = true;
128
+ position.jump(target);
129
+ return;
130
+ }
131
+ // Long jumps (Today, a year with Shift) start one month away, so the strip never scrolls through the months between.
132
+ if (Math.abs(target - from) > 2)
133
+ position.jump(target - Math.sign(target - from));
134
+ const controls = animate(position, target, stripSpring);
135
+ return () => controls.stop();
136
+ }, [ready, target, reducedMotion, position]);
137
+ const paneIndexes = Array.from(new Set([span[0], span[1], target])).sort((a, b) => a - b);
138
+ const formatter = useMemo(() => new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }), [locale]);
139
+ const weekdayFormatter = useMemo(() => new Intl.DateTimeFormat(locale, { weekday: "short" }), [locale]);
140
+ const weekdays = useMemo(() => Array.from({ length: 7 }, (_, index) => weekdayFormatter.format(new Date(2024, 0, 7 + index))), [weekdayFormatter]);
141
+ const monthLabel = month ? formatter.format(month) : "";
142
+ // The selected disc glides between days, so each number flips color as the disc actually passes it:
143
+ // the day it leaves waits longer on short hops, the day it lands on waits longer on long ones.
144
+ const valueKey = value ? dateKey(value) : "";
145
+ const [last, setLast] = useState({ key: valueKey, date: value });
146
+ const [handoff, setHandoff] = useState({ from: "", to: "", distance: 0 });
147
+ if (last.key !== valueKey) {
148
+ const days = last.date && value ? Math.round((startOfDay(value).getTime() - startOfDay(last.date).getTime()) / 864e5) : 0;
149
+ const columns = last.date && value ? value.getDay() - last.date.getDay() : 0;
150
+ setLast({ key: valueKey, date: value });
151
+ setHandoff({ from: last.key, to: valueKey, distance: Math.hypot(columns, (days - columns) / 7) });
152
+ }
153
+ const numberDelay = (key) => {
154
+ if (!handoff.distance || reducedMotion || (key !== handoff.to && key !== handoff.from))
155
+ return undefined;
156
+ const delay = key === handoff.to ? Math.min(260, 95 + 85 * Math.log(handoff.distance)) : Math.max(20, 130 / handoff.distance);
157
+ return { "--number-delay": `${Math.round(delay)}ms` };
158
+ };
159
+ const isDisabled = (date) => isBefore(date, minDate) || isAfter(date, maxDate) || Boolean(disabledDates?.(date));
160
+ const previousMonth = month && addMonths(month, -1);
161
+ const nextMonth = month && addMonths(month, 1);
162
+ // While the month is still unknown the controls keep their resting look, so hydration does not flash them dim.
163
+ const previousDisabled = Boolean(previousMonth && minDate && previousMonth.getTime() < monthStart(minDate).getTime());
164
+ const nextDisabled = Boolean(nextMonth && maxDate && nextMonth.getTime() > monthStart(maxDate).getTime());
165
+ const todaySelectable = Boolean(today && !isDisabled(today) && onChange);
166
+ const todayIdle = Boolean(today && sameMonth(month, today) && (!todaySelectable || sameDay(value, today)));
167
+ // Roving tab stop: the day last focused, else the selection, else today, else the first open day of the month.
168
+ const tabbableKey = (() => {
169
+ const open = (date) => (date && sameMonth(date, month) && !isDisabled(date) ? dateKey(date) : "");
170
+ return open(focusedDate) || open(value) || open(today) || dateKey(weeks.flat().find((date) => sameMonth(date, month) && !isDisabled(date)) ?? new Date(0));
171
+ })();
172
+ const changeMonth = (target) => {
173
+ const normalized = monthStart(target);
174
+ if (!controlledMonth)
175
+ setInternalMonth(normalized);
176
+ onMonthChange?.(normalized);
177
+ };
178
+ /** Keyboard focus lands on the new day in the same frame the month starts to slide. */
179
+ useLayoutEffect(() => {
180
+ const key = focusRequest.current;
181
+ if (!key)
182
+ return;
183
+ const button = viewportRef.current?.querySelector(`[data-present] [data-date="${key}"]`);
184
+ if (button) {
185
+ focusRequest.current = null;
186
+ button.focus({ preventScroll: true });
187
+ }
188
+ });
189
+ /** Clamps a keyboard move to the allowed range and steps past blocked days in the direction of travel. */
190
+ const moveFocus = (from, target, step) => {
191
+ let next = isBefore(target, minDate) && minDate ? startOfDay(minDate) : isAfter(target, maxDate) && maxDate ? startOfDay(maxDate) : target;
192
+ for (let tries = 0; tries < 42 && isDisabled(next); tries += 1)
193
+ next = addDays(next, step);
194
+ if (isDisabled(next) || sameDay(next, from))
195
+ return;
196
+ setFocusedDate(next);
197
+ focusRequest.current = dateKey(next);
198
+ if (!sameMonth(next, month))
199
+ changeMonth(next);
200
+ };
201
+ const onDayKeyDown = (event, date) => {
202
+ if (event.key === "Enter" || event.key === " ") {
203
+ event.preventDefault();
204
+ if (!isDisabled(date))
205
+ onChange?.(date);
206
+ return;
207
+ }
208
+ const moves = {
209
+ ArrowLeft: [addDays(date, -1), -1],
210
+ ArrowRight: [addDays(date, 1), 1],
211
+ ArrowUp: [addDays(date, -7), -1],
212
+ ArrowDown: [addDays(date, 7), 1],
213
+ Home: [addDays(date, -date.getDay()), 1],
214
+ End: [addDays(date, 6 - date.getDay()), -1],
215
+ PageUp: [shiftMonths(date, event.shiftKey ? -12 : -1), -1],
216
+ PageDown: [shiftMonths(date, event.shiftKey ? 12 : 1), 1],
217
+ };
218
+ const move = moves[event.key];
219
+ if (!move)
220
+ return;
221
+ event.preventDefault();
222
+ moveFocus(date, move[0], move[1]);
223
+ };
224
+ const goToToday = () => {
225
+ if (!today || !month || todayIdle)
226
+ return;
227
+ setFocusedDate(today);
228
+ if (!sameMonth(today, month))
229
+ changeMonth(today);
230
+ if (todaySelectable && !sameDay(value, today))
231
+ onChange?.(today);
232
+ };
233
+ const cx = (...names) => names.filter(Boolean).join(" ");
234
+ // Six fixed weeks per month, so a day's cell is its distance from the first day on the grid.
235
+ const cellOf = (paneMonth) => {
236
+ if (!value)
237
+ return -1;
238
+ const first = addDays(paneMonth, -paneMonth.getDay());
239
+ const index = Math.round((startOfDay(value).getTime() - first.getTime()) / 864e5);
240
+ return index >= 0 && index < 42 ? index : -1;
241
+ };
242
+ const renderMonth = (paneMonth, present) => _jsxs(_Fragment, { children: [_jsx(SelectionDisc, { cell: cellOf(paneMonth), reduced: reducedMotion }), _jsx("div", { className: styles.grid, role: "grid", "aria-label": formatter.format(paneMonth), children: makeWeeks(paneMonth).map((week) => _jsx("div", { className: styles.week, role: "row", children: week.map((date) => {
243
+ const key = dateKey(date);
244
+ const selected = sameDay(date, value);
245
+ const isToday = sameDay(date, today);
246
+ return _jsx("button", { type: "button", role: "gridcell", "data-date": key, "aria-label": date.toLocaleDateString(locale, { dateStyle: "full" }), "aria-selected": selected, "aria-current": isToday ? "date" : undefined, tabIndex: present && key === tabbableKey ? 0 : -1, disabled: isDisabled(date), style: present ? numberDelay(key) : undefined, className: cx(styles.day, !sameMonth(date, paneMonth) && styles.outside, selected && styles.selected, isToday && styles.today), onFocus: () => setFocusedDate(date), onKeyDown: (event) => onDayKeyDown(event, date), onClick: () => onChange?.(date), children: _jsx("span", { className: styles.dayNumber, children: date.getDate() }) }, key);
247
+ }) }, dateKey(week[0]))) })] });
248
+ return _jsxs("section", { className: cx(styles.calendar, className), "aria-labelledby": titleId, children: [_jsxs("div", { className: styles.header, children: [_jsxs("h2", { id: titleId, className: styles.heading, children: [_jsx("span", { className: styles.srOnly, children: monthLabel }), _jsx("span", { className: styles.title, "aria-hidden": "true", children: _jsx(AnimatePresence, { initial: false, custom: direction, children: month && _jsx(motion.span, { className: styles.titleRow, custom: direction, variants: reducedMotion || !direction ? titleFade : titleSlide, initial: "enter", animate: "center", exit: "exit", children: monthLabel }, monthKey) }) })] }), _jsx("span", { className: styles.srOnly, "aria-live": "polite", children: direction ? monthLabel : "" }), _jsxs("div", { className: styles.navigation, children: [showToday && _jsx("button", { type: "button", className: styles.todayButton, "aria-disabled": todayIdle || undefined, "aria-label": today ? `Today, ${today.toLocaleDateString(locale, { dateStyle: "full" })}` : "Today", onClick: goToToday, children: "Today" }), _jsx("button", { type: "button", className: styles.navButton, "aria-label": "Previous month", "aria-disabled": previousDisabled || undefined, onClick: () => { if (!previousDisabled && previousMonth)
249
+ changeMonth(previousMonth); }, children: _jsx(ChevronLeft, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), _jsx("button", { type: "button", className: styles.navButton, "aria-label": "Next month", "aria-disabled": nextDisabled || undefined, onClick: () => { if (!nextDisabled && nextMonth)
250
+ changeMonth(nextMonth); }, children: _jsx(ChevronRight, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })] })] }), _jsx("div", { className: styles.weekdays, "aria-hidden": "true", children: weekdays.map((day, index) => _jsx("span", { children: day.slice(0, 2) }, `${day}-${index}`)) }), _jsx("div", { className: styles.monthViewport, ref: viewportRef, children: month ? paneIndexes.map((index) => _jsx(MonthPane, { index: index, position: position, present: index === target, children: renderMonth(fromIndex(index), index === target) }, index))
251
+ : _jsx("div", { className: styles.monthBody, "aria-hidden": "true", children: _jsx("div", { className: styles.grid, children: Array.from({ length: 6 }, (_, week) => _jsx("div", { className: styles.week, children: Array.from({ length: 7 }, (_, day) => _jsx("span", { className: styles.placeholderDay }, day)) }, week)) }) }) })] });
252
+ }
253
+ export { addDays, addMonths, sameDay, startOfDay };
@@ -0,0 +1,51 @@
1
+ .calendar { width: min(100%, 328px); min-width: 0; color: var(--foreground); }
2
+ /* The header is its own size container: below 260px the title takes a full row and the controls sit under it, decided by width alone so a long month name never reflows it. */
3
+ .header { container-type: inline-size; display: grid; min-height: 34px; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 4px 12px; margin-bottom: 14px; }
4
+ @container (max-width: 259px) { .heading, .navigation { grid-column: 1 / -1; } .navigation { justify-self: end; } }
5
+ .heading { min-width: 0; margin: 0; font-family: var(--font-body); font-size: var(--text-base); font-weight: 500; line-height: var(--leading-body); letter-spacing: var(--tracking-body); }
6
+ /* The global heading rule forces a tight line-height on h2, so the title row sets its own to center with the controls. */
7
+ .title { display: grid; line-height: var(--leading-body); }
8
+ /* Old and new titles share one cell while they cross, so the heading never changes height or jumps. */
9
+ .title > .titleRow { grid-area: 1 / 1; justify-self: start; }
10
+ .titleRow { display: inline-block; white-space: nowrap; font-variant-numeric: tabular-nums; }
11
+ .srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
12
+ .navigation { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 2px; margin-right: -6px; }
13
+ /* Quiet header controls: a fill on hover, a short press, and a spring release. */
14
+ .navButton, .todayButton { display: grid; height: 32px; place-items: center; border: 0; border-radius: var(--radius-full); corner-shape: round; background: transparent; color: var(--text-secondary); font: inherit; cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard), scale var(--duration-spring) var(--ease-spring); }
15
+ .navButton { width: 32px; }
16
+ .todayButton { padding: 0 11px; font-size: var(--text-sm); font-weight: 500; line-height: 1; }
17
+ @media (hover: hover) and (pointer: fine) { .navButton:hover:not([aria-disabled]), .todayButton:hover:not([aria-disabled]) { background: var(--surface-muted); color: var(--foreground); } }
18
+ .navButton:active:not([aria-disabled]), .todayButton:active:not([aria-disabled]) { scale: .96; transition-duration: var(--duration-fast), var(--duration-fast), var(--duration-fast), 100ms; transition-timing-function: var(--ease-standard); }
19
+ .navButton[aria-disabled], .todayButton[aria-disabled] { cursor: default; opacity: .36; }
20
+ .weekdays, .week { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); column-gap: 4px; }
21
+ .weekdays { margin-bottom: 6px; color: var(--text-muted); font-size: var(--text-xs); font-weight: 500; line-height: 24px; text-align: center; }
22
+ .weekdays span { min-width: 0; overflow: hidden; }
23
+ /* Months sit on one strip inside a clipped window. Every pane shares one grid cell and has six weeks, so the height never changes. */
24
+ .monthViewport { position: relative; display: grid; overflow: hidden; contain: paint; }
25
+ .monthBody { position: relative; grid-area: 1 / 1; min-width: 0; will-change: transform; }
26
+ .monthBody[inert] { pointer-events: none; }
27
+ .grid { display: grid; row-gap: 4px; }
28
+ .placeholderDay { aspect-ratio: 1; }
29
+ .day { position: relative; display: grid; width: 100%; aspect-ratio: 1; min-width: 0; place-items: center; border: 0; border-radius: 50%; corner-shape: round; padding: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); font-weight: 400; line-height: 1; font-variant-numeric: tabular-nums; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard) var(--number-delay, 0ms); }
30
+ /* The inner circle carries hover and press; the grid cell itself never moves, so the hit area stays put. */
31
+ .day::before { content: ""; position: absolute; inset: 0; border-radius: inherit; corner-shape: inherit; background: transparent; transition: background-color var(--duration-fast) var(--ease-standard), scale var(--duration-spring) var(--ease-spring); }
32
+ .dayNumber { position: relative; transition: opacity var(--duration-fast) var(--ease-standard) var(--number-delay, 0ms), scale var(--duration-spring) var(--ease-spring); }
33
+ /* The selected disc is one element per month, one cell in size, placed by week and weekday under the day buttons. It glides on a transform, so the strip sliding around it never bends its path. Press scales the inner fill so it never fights the glide. */
34
+ .highlight { position: absolute; top: 0; left: 0; width: calc((100% - 24px) / 7); aspect-ratio: 1; pointer-events: none; will-change: transform; }
35
+ .highlightFill { position: absolute; inset: 0; border-radius: 50%; corner-shape: round; background: var(--accent); transition: background-color var(--duration-fast) var(--ease-standard), scale var(--duration-spring) var(--ease-spring); }
36
+ @media (hover: hover) and (pointer: fine) { .day:hover:not(:disabled):not(.selected)::before { background: var(--surface-muted); } .monthBody:has(.day.selected:hover) .highlightFill { background: var(--accent-strong); } }
37
+ .day:active:not(:disabled)::before, .day:active:not(:disabled) .dayNumber, .monthBody:has(.day.selected:active:not(:disabled)) .highlightFill { scale: .94; transition-duration: var(--duration-fast), 100ms; transition-timing-function: var(--ease-standard); }
38
+ /* Outside days dim their number, not the button, so a disc gliding onto one keeps its full strength. */
39
+ .day.outside { color: var(--text-muted); }
40
+ .day.outside:not(.selected) .dayNumber { opacity: .8; }
41
+ /* Today is a small dot under the number. It takes the number's color, so it flips with it when the disc arrives. */
42
+ .day.today .dayNumber::after { content: ""; position: absolute; top: calc(100% + 4px); left: 50%; width: 4px; height: 4px; margin-left: -2px; border-radius: 50%; corner-shape: round; background: currentColor; }
43
+ .day.selected, .day.today { font-weight: 500; }
44
+ .day.selected { color: var(--accent-foreground); }
45
+ .day:disabled { cursor: default; color: var(--text-muted); }
46
+ .day:disabled .dayNumber { opacity: .42; }
47
+ .day.outside:disabled .dayNumber { opacity: .3; }
48
+ @media (prefers-reduced-motion: reduce) {
49
+ .navButton, .todayButton, .day, .day::before, .dayNumber, .highlightFill { transition: none; }
50
+ .navButton:active, .todayButton:active, .day:active::before, .monthBody:has(.day.selected:active) .highlightFill, .day:active .dayNumber { scale: none; }
51
+ }
@@ -0,0 +1,19 @@
1
+ import type { HTMLAttributes, ReactNode } from "react";
2
+ export interface CardProps extends HTMLAttributes<HTMLElement> {
3
+ title: string;
4
+ description?: string;
5
+ media?: ReactNode;
6
+ action?: ReactNode;
7
+ /** A small leading visual for the footer, such as the owner's avatar. */
8
+ avatar?: ReactNode;
9
+ /** Who the card belongs to, such as the owner's name. */
10
+ meta?: ReactNode;
11
+ /** A short status under the meta, such as "Updated 2 hours ago". Changed words rise in and are announced politely. */
12
+ status?: string;
13
+ /** Content for a quick look. When set, the whole card opens and grows into a larger view; Escape or the close control morphs it back. */
14
+ details?: ReactNode;
15
+ open?: boolean;
16
+ defaultOpen?: boolean;
17
+ onOpenChange?: (open: boolean) => void;
18
+ }
19
+ export declare function Card({ title, description, media, action, avatar, meta, status, details, open: openProp, defaultOpen, onOpenChange, children, className, style, ...props }: CardProps): import("react").JSX.Element;
@@ -0,0 +1,108 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import * as Dialog from "@radix-ui/react-dialog";
4
+ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
5
+ import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
6
+ import { X } from "lucide-react";
7
+ import { motionTokens } from "../../lib/motion-tokens.js";
8
+ import styles from "./card.module.css";
9
+ const { blur, duration, ease, spring, stagger } = motionTokens;
10
+ /** One critically damped spring carries the surface both ways, so it never overshoots and can reverse mid-flight. Closing is a touch quicker. */
11
+ const grow = { ...spring.smooth, visualDuration: .3 };
12
+ const settle = { ...spring.smooth, visualDuration: .26 };
13
+ const RETURN_MS = 300;
14
+ const fade = { duration: duration.instant };
15
+ /** The photo drifts in slowly while the card is pointed at, and eases back a little faster. */
16
+ const ZOOM = 1.04;
17
+ const zoomIn = { duration: duration.considered * 2, ease: [...ease.standard] };
18
+ const zoomOut = { duration: duration.considered, ease: [...ease.standard] };
19
+ /** Quick look extras arrive once the surface has mostly grown, so they never ride the stretch. */
20
+ const reveal = { delay: duration.instant, duration: duration.standard, ease: [...ease.enter] };
21
+ const closeIn = { delay: duration.instant, duration: duration.fast, ease: [...ease.enter] };
22
+ /** A new status replaces the whole line: the old one lifts away quickly while the new words rise in one after another. */
23
+ const lineMotion = {
24
+ enter: {},
25
+ center: {},
26
+ exit: { opacity: 0, y: "-.3em", filter: `blur(${blur.subtle}px)`, transition: { duration: duration.fast, ease: [...ease.standard] } },
27
+ };
28
+ const wordMotion = {
29
+ enter: { opacity: 0, y: ".3em", filter: `blur(${blur.soft}px)` },
30
+ center: (order) => ({ opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.standard, ease: [...ease.enter], delay: order * stagger.word } }),
31
+ };
32
+ function Status({ text, reduced }) {
33
+ return _jsxs("span", { className: styles.status, role: "status", children: [_jsx("span", { className: styles.srOnly, children: text }), _jsx("span", { className: styles.roll, "aria-hidden": "true", children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsx(motion.span, { className: styles.line, variants: lineMotion, initial: reduced ? false : "enter", animate: "center", exit: reduced ? undefined : "exit", children: text.split(/(\s+)/).map((part, index) => _jsx(motion.span, { className: styles.word, custom: index / 2, variants: wordMotion, children: part }, index)) }, text) }) })] });
34
+ }
35
+ /** Motion only scale-corrects pixel radii, so token radii are read back in pixels for the morph. */
36
+ function px(node, value) {
37
+ const amount = parseFloat(value);
38
+ if (!Number.isFinite(amount))
39
+ return undefined;
40
+ return value.trim().endsWith("rem") ? amount * parseFloat(getComputedStyle(node.ownerDocument.documentElement).fontSize) : amount;
41
+ }
42
+ export function Card({ title, description, media, action, avatar, meta, status, details, open: openProp, defaultOpen = false, onOpenChange, children, className, style, ...props }) {
43
+ const reduced = useReducedMotion() ?? false;
44
+ const group = useId();
45
+ const cardRef = useRef(null);
46
+ const descriptionRef = useRef(null);
47
+ const [uncontrolled, setUncontrolled] = useState(defaultOpen);
48
+ const [hovered, setHovered] = useState(false);
49
+ const [geometry, setGeometry] = useState({});
50
+ /** Where the quick look lands when it closes: the card's box on screen. The panel travels there above the page, then hands back to the card. */
51
+ const [landing, setLanding] = useState(null);
52
+ const open = details ? openProp ?? uncontrolled : false;
53
+ const morph = Boolean(details) && !reduced;
54
+ const returning = !open && landing !== null;
55
+ const hasDescription = Boolean(description);
56
+ const setOpen = useCallback((next) => { if (openProp === undefined)
57
+ setUncontrolled(next); onOpenChange?.(next); }, [openProp, onOpenChange]);
58
+ // The quick look keeps the card's line length, so the description never rewraps while it travels.
59
+ useEffect(() => {
60
+ const node = cardRef.current, text = descriptionRef.current;
61
+ if (!morph || !node || typeof ResizeObserver === "undefined")
62
+ return;
63
+ const observer = new ResizeObserver(() => {
64
+ const computed = getComputedStyle(node);
65
+ const radius = px(node, computed.borderTopLeftRadius), surface = px(node, computed.getPropertyValue("--radius-overlay")), measure = text?.offsetWidth || undefined;
66
+ setGeometry(current => current.card !== undefined && current.measure === measure ? current : { card: current.card ?? radius, panel: current.panel ?? surface, measure });
67
+ });
68
+ observer.observe(node);
69
+ if (text)
70
+ observer.observe(text);
71
+ return () => observer.disconnect();
72
+ }, [morph, hasDescription]);
73
+ // Closing keeps the quick look mounted and sends it back to the card's box, so the surface never drops behind the page or a clipped preview.
74
+ const wasOpen = useRef(open);
75
+ useLayoutEffect(() => {
76
+ const closed = wasOpen.current && !open;
77
+ wasOpen.current = open;
78
+ const node = cardRef.current;
79
+ if (open)
80
+ setLanding(null); // eslint-disable-line react-hooks/set-state-in-effect -- reopening mid-return cancels the landing
81
+ else if (closed && morph && node) {
82
+ const box = node.getBoundingClientRect();
83
+ setLanding({ top: box.top, left: box.left, width: box.width, height: box.height });
84
+ }
85
+ }, [open, morph]);
86
+ // Once the surface has visually landed, the card takes over. Any last sub-pixel of travel carries on in the card itself, so the handoff has no seam.
87
+ useEffect(() => {
88
+ if (!returning)
89
+ return;
90
+ const timer = window.setTimeout(() => setLanding(null), RETURN_MS);
91
+ return () => window.clearTimeout(timer);
92
+ }, [returning]);
93
+ /** The same pieces live in the card and in the quick look; a shared id lets each one travel between them.
94
+ Crossfade is off: the arriving piece takes over at full opacity and the other hides, so one solid surface moves instead of two translucent copies. */
95
+ const shared = (id, side, layout = true) => morph ? { layoutId: id, layout, layoutCrossfade: false, layoutDependency: side === "card" ? open : returning ? "return" : "panel", transition: { layout: side === "card" ? settle : grow } } : {};
96
+ const footer = (side) => avatar || meta || status || action ? _jsxs("div", { className: styles.footer, children: [avatar || meta || status ? _jsxs(motion.div, { className: styles.byline, ...shared("byline", side, "position"), children: [avatar ? _jsx("span", { className: styles.avatar, children: avatar }) : null, _jsxs("span", { className: styles.bylineText, children: [meta ? _jsx("span", { className: styles.meta, children: meta }) : null, status ? _jsx(Status, { text: status, reduced: reduced }) : null] })] }) : null, action ? _jsx(motion.div, { className: styles.action, ...shared("action", side, "position"), children: action }) : null] }) : null;
97
+ const card = _jsxs(motion.article, { ...props, ref: cardRef, className: [styles.card, className].filter(Boolean).join(" "), style: { ...style, borderRadius: geometry.card ?? style?.borderRadius }, "data-hover": hovered || undefined, whileHover: reduced ? undefined : { y: -2 }, onHoverStart: () => setHovered(true), onHoverEnd: () => setHovered(false), ...shared("card", "card"), transition: { default: spring.snappy, layout: settle }, children: [media ? _jsx(motion.div, { className: styles.media, ...shared("media", "card"), children: _jsx(motion.div, { className: styles.zoom, initial: false, animate: { scale: hovered && !reduced ? ZOOM : 1 }, transition: hovered ? zoomIn : zoomOut, children: media }) }) : null, _jsxs("div", { className: styles.content, children: [_jsx(motion.h3, { className: styles.title, ...shared("title", "card"), children: details ? _jsx(Dialog.Trigger, { asChild: true, children: _jsx("button", { type: "button", className: styles.trigger, children: title }) }) : title }), description ? _jsx(motion.p, { ref: descriptionRef, className: styles.description, ...shared("description", "card", "position"), children: description }) : null, children, footer("card")] })] });
98
+ if (!details)
99
+ return card;
100
+ // The quick look grows out of the card: the surface, photo, and copy travel on one spring while the details settle in beneath them.
101
+ return _jsx(Dialog.Root, { open: open, onOpenChange: setOpen, children: _jsxs(LayoutGroup, { id: group, children: [card, _jsx(AnimatePresence, { children: (open || returning) && _jsxs(Dialog.Portal, { forceMount: true, children: [_jsx(AnimatePresence, { children: open && _jsx(Dialog.Overlay, { asChild: true, forceMount: true, children: _jsx(motion.div, { className: styles.overlay, initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: reduced ? fade : { duration: duration.exit, ease: [...ease.standard] } }, transition: reduced ? fade : { duration: duration.standard, ease: [...ease.enter] } }) }, "overlay") }), _jsx(Dialog.Content, { asChild: true, forceMount: true, ...(description ? {} : { "aria-describedby": undefined }), children: _jsxs(motion.div, { className: styles.panel, "data-framer-portal-id": group, "data-returning": returning || undefined, layoutScroll: true, style: { borderRadius: geometry.panel, "--card-measure": geometry.measure ? `${geometry.measure}px` : undefined, ...(landing && returning ? { "--landing-top": `${landing.top}px`, "--landing-left": `${landing.left}px`, "--landing-width": `${landing.width}px`, "--landing-height": `${landing.height}px` } : {}) }, ...(morph ? {
102
+ ...shared("card", "panel"),
103
+ initial: false,
104
+ animate: geometry.card !== undefined && geometry.panel !== undefined ? { borderRadius: returning ? geometry.card : geometry.panel } : undefined,
105
+ transition: { layout: returning ? settle : grow, borderRadius: returning ? settle : grow },
106
+ exit: { opacity: 0, transition: { duration: 0 } },
107
+ } : { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: fade }, transition: fade }), children: [media ? _jsx(motion.div, { className: styles.media, ...shared("media", "panel"), children: _jsx(motion.div, { className: styles.zoom, initial: { scale: hovered && !reduced ? ZOOM : 1 }, animate: { scale: 1 }, transition: grow, children: media }) }) : null, _jsx(motion.span, { className: styles.closeSlot, initial: reduced ? { opacity: 0 } : { opacity: 0, scale: .9 }, animate: returning ? { opacity: 0, scale: .9 } : { opacity: 1, scale: 1 }, transition: returning ? fade : reduced ? fade : closeIn, children: _jsx(Dialog.Close, { asChild: true, children: _jsx(motion.button, { type: "button", className: styles.close, "aria-label": "Close quick look", whileTap: reduced ? undefined : { scale: .94 }, transition: spring.snappy, children: _jsx(X, { width: 16, height: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }) }) }), _jsxs("div", { className: styles.content, children: [_jsx(Dialog.Title, { asChild: true, children: _jsx(motion.h2, { className: styles.title, ...shared("title", "panel"), children: title }) }), description ? _jsx(Dialog.Description, { asChild: true, children: _jsx(motion.p, { className: styles.description, ...shared("description", "panel", "position"), children: description }) }) : null, children, footer("panel"), _jsx(motion.div, { className: styles.details, initial: reduced ? false : { opacity: 0, y: 6 }, animate: returning ? { opacity: 0, y: 0 } : { opacity: 1, y: 0 }, exit: { opacity: 0, transition: fade }, transition: returning || reduced ? fade : reveal, children: details })] })] }) })] }, "quick-look") })] }) });
108
+ }
@@ -0,0 +1,51 @@
1
+ /* Isolation keeps the rounded clip on the media while the card lifts on its own layer (Safari drops it otherwise).
2
+ Motion drives the lift and the quick look morph, so transform never gets a CSS transition here. */
3
+ .card { position: relative; min-width: 0; overflow: hidden; isolation: isolate; border: 1px solid var(--border); border-radius: var(--radius-container); corner-shape: var(--corner-shape); background: var(--surface); color: var(--foreground); transition: border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-standard) var(--ease-standard); }
4
+ /* A pointed-at card floats a little, so the shadow arrives with the lift. Touch and pen never lift. */
5
+ .card[data-hover] { border-color: var(--border-strong); box-shadow: var(--shadow-raised); }
6
+ .card:has(.trigger:focus-visible) { border-color: var(--border-strong); }
7
+ .media { position: relative; overflow: hidden; background: var(--surface-muted); }
8
+ .zoom { display: grid; min-height: 112px; place-items: center; }
9
+ .content { padding: var(--space-comfort); }
10
+ /* Fit-content width keeps the title's box the shape of its text, so it scales evenly when it grows into the quick look. */
11
+ .title { width: fit-content; max-width: 100%; margin: 0; font-size: var(--text-lg); font-weight: 500; letter-spacing: var(--tracking-body); line-height: 1.3; text-wrap: balance; }
12
+ .trigger { padding: 0; border: 0; background: none; color: inherit; font: inherit; letter-spacing: inherit; text-align: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; }
13
+ /* The title's hit area stretches over the whole card, so pointing anywhere opens the quick look while the action stays on top. */
14
+ .trigger::after { content: ""; position: absolute; inset: 0; }
15
+ .trigger:focus-visible { outline: none; }
16
+ .description { max-width: 34ch; margin: var(--space-related) 0 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); text-wrap: pretty; }
17
+ .footer { display: flex; align-items: center; justify-content: space-between; gap: var(--space-group); margin-top: var(--space-comfort); }
18
+ .byline { display: flex; flex: 1 1 auto; align-items: center; gap: var(--space-group); min-width: 0; }
19
+ .avatar { display: grid; flex: none; width: var(--space-block); height: var(--space-block); overflow: hidden; border-radius: var(--radius-full); corner-shape: round; background: var(--surface-muted); }
20
+ .avatar > * { width: 100%; height: 100%; object-fit: cover; }
21
+ /* A shrinkable column, so a narrow footer truncates the byline instead of sliding it under the action. */
22
+ .bylineText { display: grid; grid-template-columns: minmax(0, 1fr); min-width: 0; font-size: var(--text-xs); line-height: var(--leading-body); }
23
+ .meta { overflow: hidden; color: var(--foreground); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
24
+ .status { position: relative; overflow: clip visible; color: var(--text-muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
25
+ /* The line clips sideways only, so a status too long for its column ends in an ellipsis while its words still rise in unclipped. */
26
+ .roll { position: relative; display: block; }
27
+ .line { display: block; overflow: clip visible; text-overflow: ellipsis; }
28
+ .word { display: inline-block; white-space: pre; }
29
+ .srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
30
+ .action { position: relative; z-index: 1; display: flex; flex: none; flex-wrap: wrap; gap: var(--space-group); }
31
+
32
+ .overlay { position: fixed; inset: 0; z-index: 50; background: oklch(10% 0 0 / .46); backdrop-filter: blur(7px); }
33
+ /* Centered with auto margins, so transform stays free for the morph. */
34
+ .panel { position: fixed; inset: 0; z-index: 51; width: min(calc(100vw - var(--space-block)), 30rem); height: fit-content; max-height: calc(100dvh - var(--space-block)); margin: auto; overflow: hidden auto; overscroll-behavior: contain; border: 1px solid var(--border); border-radius: var(--radius-overlay); corner-shape: var(--corner-shape); background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-floating); transition: background-color var(--duration-standard) var(--ease-standard), box-shadow var(--duration-standard) var(--ease-standard); }
35
+ .panel:focus { outline: none; }
36
+ /* Closing: the panel takes the card's box and look, so it lands exactly where the card sits and hands back without a seam. */
37
+ .panel[data-returning] { inset: auto; top: var(--landing-top); left: var(--landing-left); width: var(--landing-width); height: var(--landing-height); max-height: none; margin: 0; overflow: hidden; border-color: var(--border); background: var(--surface); box-shadow: none; pointer-events: none; }
38
+ .panel[data-returning] .content { padding: var(--space-comfort); }
39
+ .panel[data-returning] .title { font-size: var(--text-lg); }
40
+ .panel .content { padding: var(--space-section); }
41
+ .panel .title { font-size: var(--text-xl); }
42
+ .panel .description { max-width: var(--card-measure, 34ch); }
43
+ /* The slot carries the entrance, so the button's own press answers instantly. */
44
+ .closeSlot { position: absolute; top: var(--space-inset); right: var(--space-inset); z-index: 2; display: grid; }
45
+ .close { display: grid; width: var(--space-block); height: var(--space-block); place-items: center; padding: 0; border: 1px solid color-mix(in oklab, var(--border) 70%, transparent); border-radius: var(--radius-full); corner-shape: round; background: color-mix(in oklab, var(--surface-raised) 74%, transparent); backdrop-filter: blur(12px); color: var(--foreground); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard); }
46
+ @media (hover: hover) and (pointer: fine) { .close:hover { background: var(--surface-raised); } }
47
+ .details { margin-top: var(--space-section); padding-top: var(--space-comfort); border-top: 1px solid var(--border); color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
48
+
49
+ @media (prefers-reduced-motion: reduce) {
50
+ .card, .close { transition-duration: var(--duration-instant); }
51
+ }
@@ -0,0 +1,34 @@
1
+ import type { ReactNode } from "react";
2
+ export type CardDecision = "left" | "right";
3
+ /**
4
+ * A deck of cards reviewed one at a time: drag or flick the top card left or right, or use the buttons and arrow keys.
5
+ * Use it for quick, one-by-one triage where each item deserves a moment of attention: shortlisting destinations, candidates, or design options.
6
+ * Use a list or table when people need to compare items side by side.
7
+ */
8
+ export interface CardStackProps<T> {
9
+ items: T[];
10
+ getKey: (item: T) => string;
11
+ /** A short name for the card, used in its accessible label and announcements, such as "Lisbon". */
12
+ getLabel: (item: T) => string;
13
+ renderCard: (item: T) => ReactNode;
14
+ onDecide?: (item: T, decision: CardDecision) => void;
15
+ onUndo?: (item: T, decision: CardDecision) => void;
16
+ onReset?: () => void;
17
+ /** Action names for each side, used on the buttons and the stamps that appear while dragging. */
18
+ labels?: {
19
+ left: string;
20
+ right: string;
21
+ };
22
+ /** Past tense of each action for announcements, such as "skipped" and "shortlisted". */
23
+ outcomes?: {
24
+ left: string;
25
+ right: string;
26
+ };
27
+ /** Accessible name for the stack. */
28
+ label?: string;
29
+ /** Shown once every card has been decided. Receives a function that brings all cards back. */
30
+ renderEmpty?: (reset: () => void) => ReactNode;
31
+ className?: string;
32
+ }
33
+ export declare function CardStack<T>({ items, getKey, getLabel, renderCard, onDecide, onUndo, onReset, labels, outcomes, label, renderEmpty, className }: CardStackProps<T>): import("react").JSX.Element;
34
+ export default CardStack;