@teamofsilicons/silicon-ui 0.0.0-stage → 0.2.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 (354) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +81 -2
  3. package/check.mjs +236 -0
  4. package/components/accordion/accordion.d.ts +13 -0
  5. package/components/accordion/accordion.js +35 -0
  6. package/components/accordion/accordion.module.css +15 -0
  7. package/components/action-button/action-button.d.ts +11 -0
  8. package/components/action-button/action-button.js +57 -0
  9. package/components/action-button/action-button.module.css +15 -0
  10. package/components/activity-heatmap/activity-heatmap.d.ts +37 -0
  11. package/components/activity-heatmap/activity-heatmap.js +303 -0
  12. package/components/activity-heatmap/activity-heatmap.module.css +83 -0
  13. package/components/alert/alert.d.ts +13 -0
  14. package/components/alert/alert.js +79 -0
  15. package/components/alert/alert.module.css +15 -0
  16. package/components/animated-counter/animated-counter.d.ts +13 -0
  17. package/components/animated-counter/animated-counter.js +73 -0
  18. package/components/animated-counter/animated-counter.module.css +12 -0
  19. package/components/announcement-bar/announcement-bar.d.ts +53 -0
  20. package/components/announcement-bar/announcement-bar.js +187 -0
  21. package/components/announcement-bar/announcement-bar.module.css +60 -0
  22. package/components/avatar/avatar.d.ts +8 -0
  23. package/components/avatar/avatar.js +18 -0
  24. package/components/avatar/avatar.module.css +60 -0
  25. package/components/avatar-group/avatar-group.d.ts +13 -0
  26. package/components/avatar-group/avatar-group.js +24 -0
  27. package/components/avatar-group/avatar-group.module.css +27 -0
  28. package/components/badge/badge.d.ts +10 -0
  29. package/components/badge/badge.js +66 -0
  30. package/components/badge/badge.module.css +52 -0
  31. package/components/bar-chart/bar-chart.d.ts +32 -0
  32. package/components/bar-chart/bar-chart.js +327 -0
  33. package/components/bar-chart/bar-chart.module.css +49 -0
  34. package/components/billing-toggle/billing-toggle.d.ts +35 -0
  35. package/components/billing-toggle/billing-toggle.js +83 -0
  36. package/components/billing-toggle/billing-toggle.module.css +139 -0
  37. package/components/bottom-sheet/bottom-sheet.d.ts +27 -0
  38. package/components/bottom-sheet/bottom-sheet.js +386 -0
  39. package/components/bottom-sheet/bottom-sheet.module.css +69 -0
  40. package/components/breadcrumb/breadcrumb.d.ts +13 -0
  41. package/components/breadcrumb/breadcrumb.js +19 -0
  42. package/components/breadcrumb/breadcrumb.module.css +12 -0
  43. package/components/brush-chart/brush-chart.d.ts +45 -0
  44. package/components/brush-chart/brush-chart.js +426 -0
  45. package/components/brush-chart/brush-chart.module.css +69 -0
  46. package/components/button/button.d.ts +10 -0
  47. package/components/button/button.js +97 -0
  48. package/components/button/button.module.css +39 -0
  49. package/components/button-group/button-group.d.ts +59 -0
  50. package/components/button-group/button-group.js +280 -0
  51. package/components/button-group/button-group.module.css +87 -0
  52. package/components/button-link/button-link.d.ts +24 -0
  53. package/components/button-link/button-link.js +11 -0
  54. package/components/button-link/button-link.module.css +7 -0
  55. package/components/calendar/calendar.d.ts +22 -0
  56. package/components/calendar/calendar.js +253 -0
  57. package/components/calendar/calendar.module.css +51 -0
  58. package/components/card/card.d.ts +19 -0
  59. package/components/card/card.js +108 -0
  60. package/components/card/card.module.css +51 -0
  61. package/components/card-stack/card-stack.d.ts +34 -0
  62. package/components/card-stack/card-stack.js +291 -0
  63. package/components/card-stack/card-stack.module.css +63 -0
  64. package/components/carousel/carousel.d.ts +28 -0
  65. package/components/carousel/carousel.js +332 -0
  66. package/components/carousel/carousel.module.css +42 -0
  67. package/components/chat-thread/chat-thread.d.ts +135 -0
  68. package/components/chat-thread/chat-thread.js +406 -0
  69. package/components/chat-thread/chat-thread.module.css +242 -0
  70. package/components/checkbox/checkbox.d.ts +7 -0
  71. package/components/checkbox/checkbox.js +23 -0
  72. package/components/checkbox/checkbox.module.css +15 -0
  73. package/components/chip-group/chip-group.d.ts +24 -0
  74. package/components/chip-group/chip-group.js +196 -0
  75. package/components/chip-group/chip-group.module.css +38 -0
  76. package/components/code-block/code-block.d.ts +11 -0
  77. package/components/code-block/code-block.js +133 -0
  78. package/components/code-block/code-block.module.css +43 -0
  79. package/components/color-picker/color-picker.d.ts +40 -0
  80. package/components/color-picker/color-picker.js +449 -0
  81. package/components/color-picker/color-picker.module.css +111 -0
  82. package/components/combobox/combobox.d.ts +19 -0
  83. package/components/combobox/combobox.js +167 -0
  84. package/components/combobox/combobox.module.css +28 -0
  85. package/components/command-palette/command-palette.d.ts +20 -0
  86. package/components/command-palette/command-palette.js +151 -0
  87. package/components/command-palette/command-palette.module.css +94 -0
  88. package/components/comment-thread/comment-thread.d.ts +120 -0
  89. package/components/comment-thread/comment-thread.js +343 -0
  90. package/components/comment-thread/comment-thread.module.css +195 -0
  91. package/components/confirm-morph/confirm-morph.d.ts +65 -0
  92. package/components/confirm-morph/confirm-morph.js +355 -0
  93. package/components/confirm-morph/confirm-morph.module.css +146 -0
  94. package/components/context-menu/context-menu.d.ts +18 -0
  95. package/components/context-menu/context-menu.js +148 -0
  96. package/components/context-menu/context-menu.module.css +17 -0
  97. package/components/copy-button/copy-button.d.ts +10 -0
  98. package/components/copy-button/copy-button.js +69 -0
  99. package/components/copy-button/copy-button.module.css +21 -0
  100. package/components/countdown/countdown.d.ts +31 -0
  101. package/components/countdown/countdown.js +177 -0
  102. package/components/countdown/countdown.module.css +49 -0
  103. package/components/date-picker/date-picker.d.ts +17 -0
  104. package/components/date-picker/date-picker.js +100 -0
  105. package/components/date-picker/date-picker.module.css +34 -0
  106. package/components/date-range-picker/date-range-picker.d.ts +44 -0
  107. package/components/date-range-picker/date-range-picker.js +534 -0
  108. package/components/date-range-picker/date-range-picker.module.css +214 -0
  109. package/components/dialog/dialog.d.ts +11 -0
  110. package/components/dialog/dialog.js +52 -0
  111. package/components/dialog/dialog.module.css +36 -0
  112. package/components/donut-chart/donut-chart.d.ts +46 -0
  113. package/components/donut-chart/donut-chart.js +382 -0
  114. package/components/donut-chart/donut-chart.module.css +48 -0
  115. package/components/drawer/drawer.d.ts +20 -0
  116. package/components/drawer/drawer.js +111 -0
  117. package/components/drawer/drawer.module.css +97 -0
  118. package/components/dropdown-menu/dropdown-menu.d.ts +16 -0
  119. package/components/dropdown-menu/dropdown-menu.js +39 -0
  120. package/components/dropdown-menu/dropdown-menu.module.css +38 -0
  121. package/components/elastic-slider/elastic-slider.d.ts +39 -0
  122. package/components/elastic-slider/elastic-slider.js +261 -0
  123. package/components/elastic-slider/elastic-slider.module.css +40 -0
  124. package/components/empty-state/empty-state.d.ts +12 -0
  125. package/components/empty-state/empty-state.js +69 -0
  126. package/components/empty-state/empty-state.module.css +13 -0
  127. package/components/expandable-card/expandable-card.d.ts +13 -0
  128. package/components/expandable-card/expandable-card.js +89 -0
  129. package/components/expandable-card/expandable-card.module.css +22 -0
  130. package/components/expanding-button-group/expanding-button-group.d.ts +41 -0
  131. package/components/expanding-button-group/expanding-button-group.js +258 -0
  132. package/components/expanding-button-group/expanding-button-group.module.css +44 -0
  133. package/components/expanding-search/expanding-search.d.ts +38 -0
  134. package/components/expanding-search/expanding-search.js +242 -0
  135. package/components/expanding-search/expanding-search.module.css +52 -0
  136. package/components/file-dropzone/file-dropzone.d.ts +44 -0
  137. package/components/file-dropzone/file-dropzone.js +379 -0
  138. package/components/file-dropzone/file-dropzone.module.css +115 -0
  139. package/components/file-upload/file-upload.d.ts +22 -0
  140. package/components/file-upload/file-upload.js +192 -0
  141. package/components/file-upload/file-upload.module.css +49 -0
  142. package/components/filter-toolbar/filter-toolbar.d.ts +48 -0
  143. package/components/filter-toolbar/filter-toolbar.js +446 -0
  144. package/components/filter-toolbar/filter-toolbar.module.css +70 -0
  145. package/components/floating-button-group/floating-button-group.d.ts +51 -0
  146. package/components/floating-button-group/floating-button-group.js +215 -0
  147. package/components/floating-button-group/floating-button-group.module.css +74 -0
  148. package/components/gauge/gauge.d.ts +18 -0
  149. package/components/gauge/gauge.js +101 -0
  150. package/components/gauge/gauge.module.css +22 -0
  151. package/components/hold-to-confirm/hold-to-confirm.d.ts +25 -0
  152. package/components/hold-to-confirm/hold-to-confirm.js +165 -0
  153. package/components/hold-to-confirm/hold-to-confirm.module.css +20 -0
  154. package/components/hover-card/hover-card.d.ts +38 -0
  155. package/components/hover-card/hover-card.js +145 -0
  156. package/components/hover-card/hover-card.module.css +20 -0
  157. package/components/image-compare/image-compare.d.ts +27 -0
  158. package/components/image-compare/image-compare.js +267 -0
  159. package/components/image-compare/image-compare.module.css +25 -0
  160. package/components/in-view-title/in-view-title.d.ts +17 -0
  161. package/components/in-view-title/in-view-title.js +70 -0
  162. package/components/in-view-title/in-view-title.module.css +13 -0
  163. package/components/inline-edit/inline-edit.d.ts +27 -0
  164. package/components/inline-edit/inline-edit.js +315 -0
  165. package/components/inline-edit/inline-edit.module.css +73 -0
  166. package/components/input/input.d.ts +7 -0
  167. package/components/input/input.js +60 -0
  168. package/components/input/input.module.css +22 -0
  169. package/components/json-viewer/json-viewer.d.ts +48 -0
  170. package/components/json-viewer/json-viewer.js +463 -0
  171. package/components/json-viewer/json-viewer.module.css +92 -0
  172. package/components/line-chart/line-chart.d.ts +58 -0
  173. package/components/line-chart/line-chart.js +383 -0
  174. package/components/line-chart/line-chart.module.css +60 -0
  175. package/components/mention-input/mention-input.d.ts +72 -0
  176. package/components/mention-input/mention-input.js +417 -0
  177. package/components/mention-input/mention-input.module.css +134 -0
  178. package/components/metric-card/metric-card.d.ts +8 -0
  179. package/components/metric-card/metric-card.js +44 -0
  180. package/components/metric-card/metric-card.module.css +18 -0
  181. package/components/money-input/money-input.d.ts +48 -0
  182. package/components/money-input/money-input.js +503 -0
  183. package/components/money-input/money-input.module.css +77 -0
  184. package/components/morph-select/morph-select.d.ts +50 -0
  185. package/components/morph-select/morph-select.js +395 -0
  186. package/components/morph-select/morph-select.module.css +88 -0
  187. package/components/multi-select/multi-select.d.ts +18 -0
  188. package/components/multi-select/multi-select.js +105 -0
  189. package/components/multi-select/multi-select.module.css +37 -0
  190. package/components/notification-center/notification-center.d.ts +24 -0
  191. package/components/notification-center/notification-center.js +146 -0
  192. package/components/notification-center/notification-center.module.css +79 -0
  193. package/components/number-field/number-field.d.ts +41 -0
  194. package/components/number-field/number-field.js +455 -0
  195. package/components/number-field/number-field.module.css +71 -0
  196. package/components/otp-input/otp-input.d.ts +13 -0
  197. package/components/otp-input/otp-input.js +129 -0
  198. package/components/otp-input/otp-input.module.css +28 -0
  199. package/components/pagination/pagination.d.ts +7 -0
  200. package/components/pagination/pagination.js +60 -0
  201. package/components/pagination/pagination.module.css +16 -0
  202. package/components/password-field/password-field.d.ts +14 -0
  203. package/components/password-field/password-field.js +51 -0
  204. package/components/password-field/password-field.module.css +27 -0
  205. package/components/password-strength/password-strength.d.ts +107 -0
  206. package/components/password-strength/password-strength.js +161 -0
  207. package/components/password-strength/password-strength.module.css +57 -0
  208. package/components/phone-input/phone-input.d.ts +73 -0
  209. package/components/phone-input/phone-input.js +612 -0
  210. package/components/phone-input/phone-input.module.css +98 -0
  211. package/components/popover/popover.d.ts +6 -0
  212. package/components/popover/popover.js +19 -0
  213. package/components/popover/popover.module.css +32 -0
  214. package/components/progress/progress.d.ts +8 -0
  215. package/components/progress/progress.js +42 -0
  216. package/components/progress/progress.module.css +1 -0
  217. package/components/radio-cards/radio-cards.d.ts +35 -0
  218. package/components/radio-cards/radio-cards.js +110 -0
  219. package/components/radio-cards/radio-cards.module.css +84 -0
  220. package/components/radio-group/radio-group.d.ts +13 -0
  221. package/components/radio-group/radio-group.js +55 -0
  222. package/components/radio-group/radio-group.module.css +30 -0
  223. package/components/resizable-panels/resizable-panels.d.ts +37 -0
  224. package/components/resizable-panels/resizable-panels.js +338 -0
  225. package/components/resizable-panels/resizable-panels.module.css +40 -0
  226. package/components/rich-text-editor/rich-text-editor.d.ts +55 -0
  227. package/components/rich-text-editor/rich-text-editor.js +1733 -0
  228. package/components/rich-text-editor/rich-text-editor.module.css +201 -0
  229. package/components/ridgeline/ridgeline.d.ts +37 -0
  230. package/components/ridgeline/ridgeline.js +346 -0
  231. package/components/ridgeline/ridgeline.module.css +58 -0
  232. package/components/scroll-area/scroll-area.d.ts +40 -0
  233. package/components/scroll-area/scroll-area.js +153 -0
  234. package/components/scroll-area/scroll-area.module.css +79 -0
  235. package/components/search-field/search-field.d.ts +7 -0
  236. package/components/search-field/search-field.js +20 -0
  237. package/components/search-field/search-field.module.css +17 -0
  238. package/components/segmented-control/segmented-control.d.ts +16 -0
  239. package/components/segmented-control/segmented-control.js +56 -0
  240. package/components/segmented-control/segmented-control.module.css +15 -0
  241. package/components/select/select.d.ts +15 -0
  242. package/components/select/select.js +36 -0
  243. package/components/select/select.module.css +35 -0
  244. package/components/shortcut-recorder/shortcut-recorder.d.ts +108 -0
  245. package/components/shortcut-recorder/shortcut-recorder.js +380 -0
  246. package/components/shortcut-recorder/shortcut-recorder.module.css +85 -0
  247. package/components/signature-pad/signature-pad.d.ts +72 -0
  248. package/components/signature-pad/signature-pad.js +450 -0
  249. package/components/signature-pad/signature-pad.module.css +84 -0
  250. package/components/skeleton/skeleton.d.ts +13 -0
  251. package/components/skeleton/skeleton.js +53 -0
  252. package/components/skeleton/skeleton.module.css +11 -0
  253. package/components/slider/slider.d.ts +45 -0
  254. package/components/slider/slider.js +347 -0
  255. package/components/slider/slider.module.css +45 -0
  256. package/components/slope-chart/slope-chart.d.ts +38 -0
  257. package/components/slope-chart/slope-chart.js +183 -0
  258. package/components/slope-chart/slope-chart.module.css +51 -0
  259. package/components/slot-text/slot-text.d.ts +26 -0
  260. package/components/slot-text/slot-text.js +119 -0
  261. package/components/slot-text/slot-text.module.css +19 -0
  262. package/components/sortable-data-table/sortable-data-table.d.ts +38 -0
  263. package/components/sortable-data-table/sortable-data-table.js +240 -0
  264. package/components/sortable-data-table/sortable-data-table.module.css +116 -0
  265. package/components/sparkline/sparkline.d.ts +19 -0
  266. package/components/sparkline/sparkline.js +239 -0
  267. package/components/sparkline/sparkline.module.css +26 -0
  268. package/components/split-button/split-button.d.ts +19 -0
  269. package/components/split-button/split-button.js +33 -0
  270. package/components/split-button/split-button.module.css +51 -0
  271. package/components/stepper/stepper.d.ts +36 -0
  272. package/components/stepper/stepper.js +108 -0
  273. package/components/stepper/stepper.module.css +74 -0
  274. package/components/streamgraph/streamgraph.d.ts +51 -0
  275. package/components/streamgraph/streamgraph.js +376 -0
  276. package/components/streamgraph/streamgraph.module.css +55 -0
  277. package/components/swipe-actions/swipe-actions.d.ts +35 -0
  278. package/components/swipe-actions/swipe-actions.js +330 -0
  279. package/components/swipe-actions/swipe-actions.module.css +64 -0
  280. package/components/switch/switch.d.ts +7 -0
  281. package/components/switch/switch.js +45 -0
  282. package/components/switch/switch.module.css +12 -0
  283. package/components/tabs/tabs.d.ts +8 -0
  284. package/components/tabs/tabs.js +138 -0
  285. package/components/tabs/tabs.module.css +27 -0
  286. package/components/tag-input/tag-input.d.ts +15 -0
  287. package/components/tag-input/tag-input.js +150 -0
  288. package/components/tag-input/tag-input.module.css +34 -0
  289. package/components/text-morph/text-morph.d.ts +13 -0
  290. package/components/text-morph/text-morph.js +69 -0
  291. package/components/text-morph/text-morph.module.css +8 -0
  292. package/components/text-reveal/text-reveal.d.ts +14 -0
  293. package/components/text-reveal/text-reveal.js +20 -0
  294. package/components/text-reveal/text-reveal.module.css +13 -0
  295. package/components/text-shimmer/text-shimmer.d.ts +18 -0
  296. package/components/text-shimmer/text-shimmer.js +90 -0
  297. package/components/text-shimmer/text-shimmer.module.css +9 -0
  298. package/components/textarea/textarea.d.ts +7 -0
  299. package/components/textarea/textarea.js +56 -0
  300. package/components/textarea/textarea.module.css +22 -0
  301. package/components/theme-switch/theme-switch.d.ts +11 -0
  302. package/components/theme-switch/theme-switch.js +37 -0
  303. package/components/theme-switch/theme-switch.module.css +31 -0
  304. package/components/time-picker/time-picker.d.ts +13 -0
  305. package/components/time-picker/time-picker.js +95 -0
  306. package/components/time-picker/time-picker.module.css +23 -0
  307. package/components/timeline/timeline.d.ts +47 -0
  308. package/components/timeline/timeline.js +171 -0
  309. package/components/timeline/timeline.module.css +51 -0
  310. package/components/toast/toast.d.ts +8 -0
  311. package/components/toast/toast.js +118 -0
  312. package/components/toast/toast.module.css +100 -0
  313. package/components/toast-stack/toast-stack.d.ts +57 -0
  314. package/components/toast-stack/toast-stack.js +399 -0
  315. package/components/toast-stack/toast-stack.module.css +61 -0
  316. package/components/tooltip/tooltip.d.ts +8 -0
  317. package/components/tooltip/tooltip.js +62 -0
  318. package/components/tooltip/tooltip.module.css +18 -0
  319. package/components/tree-view/tree-view.d.ts +17 -0
  320. package/components/tree-view/tree-view.js +147 -0
  321. package/components/tree-view/tree-view.module.css +22 -0
  322. package/components/treemap/treemap.d.ts +35 -0
  323. package/components/treemap/treemap.js +375 -0
  324. package/components/treemap/treemap.module.css +59 -0
  325. package/components/usage-meter/usage-meter.d.ts +24 -0
  326. package/components/usage-meter/usage-meter.js +296 -0
  327. package/components/usage-meter/usage-meter.module.css +61 -0
  328. package/components/user-menu/user-menu.d.ts +62 -0
  329. package/components/user-menu/user-menu.js +316 -0
  330. package/components/user-menu/user-menu.module.css +97 -0
  331. package/components/waffle-chart/waffle-chart.d.ts +39 -0
  332. package/components/waffle-chart/waffle-chart.js +244 -0
  333. package/components/waffle-chart/waffle-chart.module.css +52 -0
  334. package/components.json +1330 -0
  335. package/eslint.js +53 -0
  336. package/lib/escape.d.ts +19 -0
  337. package/lib/escape.js +50 -0
  338. package/lib/media.d.ts +353 -0
  339. package/lib/media.js +65 -0
  340. package/lib/menu-highlight.d.ts +28 -0
  341. package/lib/menu-highlight.js +43 -0
  342. package/lib/motion-tokens.d.ts +60 -0
  343. package/lib/motion-tokens.js +25 -0
  344. package/lib/return-focus.d.ts +9 -0
  345. package/lib/return-focus.js +53 -0
  346. package/lib/silicon-provider.d.ts +60 -0
  347. package/lib/silicon-provider.js +47 -0
  348. package/lib/use-copy-feedback.d.ts +8 -0
  349. package/lib/use-copy-feedback.js +36 -0
  350. package/lib/use-today.d.ts +2 -0
  351. package/lib/use-today.js +29 -0
  352. package/package.json +76 -4
  353. package/styles.css +218 -0
  354. package/styles.css.d.ts +1 -0
@@ -0,0 +1,148 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { createPortal } from "react-dom";
4
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
5
+ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
6
+ import { Check, Copy, MoreHorizontal, Trash2 } from "lucide-react";
7
+ import { motionTokens } from "../../lib/motion-tokens.js";
8
+ import styles from "./context-menu.module.css";
9
+ const MENU_WIDTH = 208;
10
+ // Keeps the menu 8px inside the viewport. Rows are 34px with 5px padding and a 1px border; the real size is measured before paint.
11
+ function clampTo(x, y, width, height) {
12
+ const left = Math.max(8, Math.min(x, window.innerWidth - width - 8));
13
+ const top = Math.max(8, Math.min(y, window.innerHeight - height - 8));
14
+ // The menu grows from the pointer, even when it is clamped away from a viewport edge.
15
+ return { x: left, y: top, originX: Math.max(0, Math.min(width, x - left)), originY: Math.max(0, Math.min(height, y - top)) };
16
+ }
17
+ export function ContextMenu({ children, items, label = "Context menu" }) {
18
+ const reduced = useReducedMotion();
19
+ const [open, setOpen] = useState(false);
20
+ // The portal mounts on first open (client only) and stays so the menu can animate out.
21
+ const [portal, setPortal] = useState(false);
22
+ // Each open gets its own key, so reopening at a new point fades the old menu out in place
23
+ // and grows a fresh one from the pointer instead of teleporting the visible menu.
24
+ const [point, setPoint] = useState({ x: 0, y: 0, originX: 0, originY: 0, key: 0 });
25
+ const [highlight, setHighlight] = useState(null);
26
+ const menuRef = useRef(null);
27
+ const targetRef = useRef(null);
28
+ const pointer = useRef(false);
29
+ const clearTimer = useRef(0);
30
+ // A primary press on the target while the menu is open closes it; the click that follows must not reopen it.
31
+ const pressedWhileOpen = useRef(false);
32
+ // An exiting menu keeps its last props, so its handlers check this before moving focus or selecting.
33
+ const live = useRef(false);
34
+ const anchor = useRef({ x: 0, y: 0 });
35
+ useEffect(() => { live.current = open; }, [open]);
36
+ // Re-clamp against the menu's real size before the first frame, so long labels or many rows never overflow the viewport.
37
+ useLayoutEffect(() => {
38
+ const node = menuRef.current;
39
+ if (!open || !node)
40
+ return;
41
+ const next = clampTo(anchor.current.x, anchor.current.y, node.offsetWidth, node.offsetHeight);
42
+ setPoint(current => (current.x === next.x && current.y === next.y && current.originX === next.originX && current.originY === next.originY ? current : { ...next, key: current.key }));
43
+ }, [open, point.key]);
44
+ useEffect(() => () => window.clearTimeout(clearTimer.current), []);
45
+ useEffect(() => {
46
+ if (!open)
47
+ return;
48
+ menuRef.current?.querySelector('[role="menuitem"]:not(:disabled)')?.focus({ preventScroll: true });
49
+ const closeOnPointerDown = (event) => {
50
+ if (!menuRef.current?.contains(event.target))
51
+ setOpen(false);
52
+ };
53
+ const closeOnViewportChange = (event) => {
54
+ if (event.target instanceof Node && menuRef.current?.contains(event.target))
55
+ return;
56
+ setOpen(false);
57
+ };
58
+ document.addEventListener("pointerdown", closeOnPointerDown);
59
+ window.addEventListener("scroll", closeOnViewportChange, true);
60
+ window.addEventListener("resize", closeOnViewportChange);
61
+ return () => {
62
+ document.removeEventListener("pointerdown", closeOnPointerDown);
63
+ window.removeEventListener("scroll", closeOnViewportChange, true);
64
+ window.removeEventListener("resize", closeOnViewportChange);
65
+ };
66
+ }, [open, point.key]);
67
+ function showMenu(x, y) {
68
+ anchor.current = { x, y };
69
+ const estimate = clampTo(x, y, MENU_WIDTH, Math.min(300, items.length * 34 + 12));
70
+ setPoint(current => ({ ...estimate, key: current.key + 1 }));
71
+ window.clearTimeout(clearTimer.current);
72
+ pointer.current = false;
73
+ setHighlight(null);
74
+ setPortal(true);
75
+ setOpen(true);
76
+ }
77
+ function onTargetKeyDown(event) {
78
+ if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
79
+ event.preventDefault();
80
+ const rect = targetRef.current?.getBoundingClientRect();
81
+ if (rect)
82
+ showMenu(rect.left + 12, rect.bottom + 8);
83
+ }
84
+ }
85
+ function onTargetClick() {
86
+ if (pressedWhileOpen.current) {
87
+ pressedWhileOpen.current = false;
88
+ return;
89
+ }
90
+ const rect = targetRef.current?.getBoundingClientRect();
91
+ if (rect)
92
+ showMenu(rect.left + 12, rect.bottom + 8);
93
+ }
94
+ function onMenuKeyDown(event) {
95
+ const enabled = items.map((item, index) => ({ item, index })).filter(({ item }) => !item.disabled);
96
+ const current = document.activeElement?.getAttribute("data-index");
97
+ const currentPosition = enabled.findIndex(({ index }) => String(index) === current);
98
+ let nextPosition;
99
+ if (event.key === "ArrowDown")
100
+ nextPosition = (currentPosition + 1) % enabled.length;
101
+ if (event.key === "ArrowUp")
102
+ nextPosition = currentPosition < 0 ? enabled.length - 1 : (currentPosition - 1 + enabled.length) % enabled.length;
103
+ if (event.key === "Home")
104
+ nextPosition = 0;
105
+ if (event.key === "End")
106
+ nextPosition = enabled.length - 1;
107
+ if (nextPosition !== undefined && enabled.length) {
108
+ event.preventDefault();
109
+ event.currentTarget.querySelector(`[data-index="${enabled[nextPosition].index}"]`)?.focus();
110
+ }
111
+ if (event.key === "Escape") {
112
+ event.preventDefault();
113
+ setOpen(false);
114
+ targetRef.current?.focus();
115
+ }
116
+ }
117
+ function place(item, glide) {
118
+ window.clearTimeout(clearTimer.current);
119
+ const next = { index: Number(item.dataset.index), top: item.offsetTop, height: item.offsetHeight, danger: item.dataset.tone === "danger" };
120
+ setHighlight(current => ({ ...next, glide: glide && current !== null }));
121
+ }
122
+ // Keyboard focus places the highlight instantly; the pointer makes it glide between rows.
123
+ function onMenuFocus(event) {
124
+ const item = event.target instanceof HTMLElement ? event.target.closest('[role="menuitem"]') : null;
125
+ if (item) {
126
+ // A pointer-opened menu focuses its first row without showing a highlight, like a native menu.
127
+ if (!pointer.current && item.matches(":focus-visible"))
128
+ place(item, false);
129
+ return;
130
+ }
131
+ // A short grace period keeps the highlight gliding across the gap between rows.
132
+ window.clearTimeout(clearTimer.current);
133
+ clearTimer.current = window.setTimeout(() => setHighlight(null), pointer.current ? 70 : 0);
134
+ }
135
+ return _jsxs(_Fragment, { children: [_jsx("div", { ref: targetRef, className: styles.target, tabIndex: 0, onPointerDown: event => { pressedWhileOpen.current = open && event.button === 0; }, onClick: onTargetClick, onContextMenu: event => { event.preventDefault(); showMenu(event.clientX, event.clientY); }, onKeyDown: onTargetKeyDown, "aria-label": label, "aria-haspopup": "menu", "aria-expanded": open, children: children }), portal ? createPortal(_jsx(AnimatePresence, { children: open && _jsxs(motion.div, { ref: node => { if (node)
136
+ menuRef.current = node; }, className: styles.menu, role: "menu", "aria-label": label, tabIndex: -1, style: { left: point.x, top: point.y, transformOrigin: `${point.originX}px ${point.originY}px` }, onKeyDown: onMenuKeyDown, onFocus: onMenuFocus, onPointerMoveCapture: () => { pointer.current = true; }, onKeyDownCapture: () => { pointer.current = false; }, onPointerLeave: event => { if (live.current)
137
+ event.currentTarget.focus({ preventScroll: true }); }, initial: reduced ? { opacity: 0 } : { opacity: 0, scale: .96 }, animate: { opacity: 1, scale: 1 }, exit: reduced ? { opacity: 0, transition: { duration: motionTokens.duration.instant } } : { opacity: 0, scale: .98, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }, transition: reduced ? { duration: motionTokens.duration.instant } : { default: motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } }, children: [_jsx(motion.span, { className: styles.highlight, "data-tone": highlight?.danger ? "danger" : undefined, "aria-hidden": "true", initial: false, animate: highlight ? { y: highlight.top, height: highlight.height, opacity: 1 } : { opacity: 0 }, transition: { default: highlight?.glide && !reduced ? motionTokens.spring.snappy : { duration: 0 }, opacity: { duration: reduced ? 0 : motionTokens.duration.instant } } }), items.map((item, index) => _jsxs("button", { type: "button", role: "menuitem", tabIndex: -1, "data-index": index, "data-tone": item.destructive ? "danger" : undefined, style: { "--i": index }, disabled: item.disabled, className: [styles.item, item.destructive ? styles.destructive : ""].filter(Boolean).join(" "), onPointerMove: event => { if (!live.current)
138
+ return; window.clearTimeout(clearTimer.current); if (document.activeElement !== event.currentTarget)
139
+ event.currentTarget.focus({ preventScroll: true }); if (highlight?.index !== index)
140
+ place(event.currentTarget, true); }, onPointerLeave: () => { if (live.current)
141
+ menuRef.current?.focus({ preventScroll: true }); }, onClick: () => { if (!live.current)
142
+ return; live.current = false; item.onSelect?.(); setOpen(false); targetRef.current?.focus(); }, children: [_jsx("span", { className: styles.icon, "aria-hidden": "true", children: item.icon ?? (item.checked ? _jsx(Check, { size: 15 }) : _jsx(MoreHorizontal, { size: 15 })) }), _jsx("span", { children: item.label })] }, item.id))] }, point.key) }), document.body) : null] });
143
+ }
144
+ export const contextMenuExampleItems = [
145
+ { id: "copy", label: "Copy link", icon: _jsx(Copy, { size: 15 }) },
146
+ { id: "delete", label: "Delete project", icon: _jsx(Trash2, { size: 15 }), destructive: true },
147
+ ];
148
+ export default ContextMenu;
@@ -0,0 +1,17 @@
1
+ .target { display: block; outline: none; }
2
+ /* Motion scales the menu from the pointer (transform-origin is set inline) and fades it out faster than it arrives. */
3
+ .menu { position: fixed; z-index: 100; min-width: min(13rem, calc(100vw - 16px)); max-width: calc(100vw - 16px); max-height: min(300px, calc(100vh - 16px)); overflow-y: auto; padding: 5px; border: 1px solid var(--border); border-radius: var(--radius-container); background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-floating); outline: none; }
4
+ .highlight { position: absolute; top: 0; right: 5px; left: 5px; border-radius: calc(var(--radius-container) - 6px); background: var(--surface-muted); opacity: 0; pointer-events: none; transition: background-color var(--duration-fast) var(--ease-standard); }
5
+ .highlight[data-tone="danger"] { background: color-mix(in oklab, var(--danger) 8%, var(--surface)); }
6
+ .item { position: relative; display: flex; width: 100%; min-height: 34px; align-items: center; gap: 9px; padding: 0 11px; border: 0; border-radius: calc(var(--radius-container) - 6px); background: transparent; color: inherit; font: inherit; font-size: var(--text-sm); letter-spacing: var(--tracking-body); line-height: var(--leading-body); text-align: left; cursor: pointer; outline: none; transition: background-color var(--duration-instant) var(--ease-standard), color var(--duration-fast) var(--ease-standard); animation: context-item-in var(--duration-standard) var(--ease-enter) both; animation-delay: calc(min(var(--i, 0), 4) * 35ms); }
7
+ /* The gliding highlight is the focus indicator, as in the dropdown menu. */
8
+ .item:focus-visible { outline: none; }
9
+ /* Rows press with a tint, not a scale. */
10
+ .item:active:not(:disabled) { background: color-mix(in oklab, var(--foreground) 5%, transparent); }
11
+ .item:disabled { opacity: .45; cursor: default; }
12
+ .item.destructive { color: var(--danger); }
13
+ .icon { display: inline-flex; flex: none; width: 16px; align-items: center; justify-content: center; color: var(--text-secondary); }
14
+ .item > span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
15
+ .destructive .icon { color: currentColor; }
16
+ @keyframes context-item-in { from { opacity: 0; transform: translateY(-2px); } }
17
+ @media (prefers-reduced-motion: reduce) { .item, .highlight { animation: none; transition: none; } }
@@ -0,0 +1,10 @@
1
+ export interface CopyButtonProps {
2
+ value: string;
3
+ label?: string;
4
+ className?: string;
5
+ iconOnly?: boolean;
6
+ variant?: "outline" | "plain";
7
+ disabled?: boolean;
8
+ onCopied?: () => void;
9
+ }
10
+ export declare function CopyButton({ value, label, className, iconOnly, variant, disabled, onCopied }: CopyButtonProps): import("react").JSX.Element;
@@ -0,0 +1,69 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
5
+ import { CircleAlert, Copy } from "lucide-react";
6
+ import { motionTokens } from "../../lib/motion-tokens.js";
7
+ import { useCopyFeedback } from "../../lib/use-copy-feedback.js";
8
+ import styles from "./copy-button.module.css";
9
+ /** Copy feedback is deliberately unhurried: a slow, almost critically damped spring and long, soft crossfades read as calm, never busy.
10
+ * The same motion plays in reverse when the confirmation hands back to idle, so nothing ever snaps. */
11
+ const settle = { type: "spring", visualDuration: .5, bounce: .06 };
12
+ const enter = { duration: .36, ease: [...motionTokens.ease.enter] };
13
+ const leave = { duration: .2, ease: [...motionTokens.ease.standard] };
14
+ const instant = { duration: motionTokens.duration.instant };
15
+ const soft = `blur(${motionTokens.blur.soft}px)`;
16
+ const rest = { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" };
17
+ const fadeIn = { ...rest, opacity: 0 };
18
+ const fadeOut = { opacity: 0, transition: instant };
19
+ /** Icons trade places in one soft breath: the old glyph shrinks into a blur while the new one grows out of it on the slow spring. */
20
+ const iconIn = { opacity: 0, scale: .6, filter: soft };
21
+ const iconOut = { opacity: 0, scale: .6, filter: soft, transition: { duration: .24, ease: [...motionTokens.ease.standard] } };
22
+ const iconEnter = { scale: settle, opacity: { ...enter, delay: .03 }, filter: { ...enter, delay: .03 } };
23
+ /** Letters rise about .3em out of a soft blur; the outgoing ones lift away a little faster. */
24
+ const glyphIn = { opacity: 0, y: 4, filter: soft };
25
+ const glyphOut = { opacity: 0, y: -3, filter: soft, transition: leave };
26
+ /** The success tick draws itself from its short stroke, the way a hand would write it: quick to start, then easing into place while the icon settles. */
27
+ function DrawnCheck({ reduced }) {
28
+ return _jsx("svg", { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.75, strokeLinecap: "round", strokeLinejoin: "round", children: _jsx(motion.path, { d: "M4 12l5 5L20 6", initial: reduced ? false : { pathLength: 0, opacity: 0 }, animate: { pathLength: 1, opacity: 1 }, transition: { pathLength: { duration: .5, ease: [...motionTokens.ease.standard], delay: .05 }, opacity: { duration: .01, delay: .05 } } }) });
29
+ }
30
+ const toGlyphs = (chars, seq) => chars.map((char, order) => ({ id: `${seq}:${order}`, char, order }));
31
+ /** Shared leading and trailing characters keep their identity, so "Copy" to "Copied" only replaces the changed letters. */
32
+ function useGlyphs(text) {
33
+ const [state, setState] = useState(() => ({ text, seq: 0, glyphs: toGlyphs([...text], 0) }));
34
+ if (state.text === text)
35
+ return state.glyphs;
36
+ const prev = [...state.text], next = [...text];
37
+ let start = 0, end = 0;
38
+ while (start < prev.length && start < next.length && prev[start] === next[start])
39
+ start++;
40
+ while (end < prev.length - start && end < next.length - start && prev[prev.length - 1 - end] === next[next.length - 1 - end])
41
+ end++;
42
+ if (start < 2)
43
+ start = 0;
44
+ if (end < 2)
45
+ end = 0;
46
+ const seq = state.seq + 1;
47
+ const glyphs = [...state.glyphs.slice(0, start), ...toGlyphs(next.slice(start, next.length - end), seq), ...state.glyphs.slice(state.glyphs.length - end)];
48
+ setState({ text, seq, glyphs });
49
+ return glyphs;
50
+ }
51
+ /**
52
+ * The label cell already reserves its widest state, so the row stays put and only the letters move. It is a layout root:
53
+ * the letters measure their glide inside the label only, so when the button itself moves (a page entering, a panel
54
+ * sliding, a list reflowing) they ride along instead of flying in from where the button used to be.
55
+ */
56
+ function MorphText({ text, reduced }) {
57
+ const glyphs = useGlyphs(text);
58
+ return _jsx(motion.span, { className: styles.glyphs, layoutRoot: true, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: glyphs.map(glyph => _jsx(motion.span, { className: styles.glyph, layout: reduced ? false : "position", layoutDependency: text, initial: reduced ? fadeIn : glyphIn, animate: rest, exit: reduced ? fadeOut : glyphOut, transition: reduced ? instant : { ...enter, delay: Math.min(glyph.order * .02, .12), layout: settle }, children: glyph.char }, glyph.id)) }) });
59
+ }
60
+ export function CopyButton({ value, label = "Copy", className, iconOnly = false, variant = "outline", disabled, onCopied }) {
61
+ const { state, copy } = useCopyFeedback();
62
+ const reduced = useReducedMotion() ?? false;
63
+ const text = state === "copied" ? "Copied" : state === "error" ? "Failed" : label;
64
+ async function handleCopy() {
65
+ if (await copy(value))
66
+ onCopied?.();
67
+ }
68
+ return _jsxs(_Fragment, { children: [_jsxs("button", { type: "button", className: [styles.button, iconOnly && styles.iconOnly, variant === "plain" && styles.plain, className].filter(Boolean).join(" "), onClick: () => void handleCopy(), "aria-label": label, "data-copy-state": state, disabled: disabled, children: [_jsx("span", { className: styles.icon, "aria-hidden": "true", children: _jsx(AnimatePresence, { initial: false, children: _jsx(motion.span, { className: styles.iconInner, "data-state": state, initial: reduced ? fadeIn : iconIn, animate: rest, exit: reduced ? fadeOut : iconOut, transition: reduced ? instant : iconEnter, children: state === "copied" ? _jsx(DrawnCheck, { reduced: reduced }) : state === "error" ? _jsx(CircleAlert, { size: 16, strokeWidth: 1.75 }) : _jsx(Copy, { size: 16, strokeWidth: 1.75 }) }, state) }) }), !iconOnly && _jsxs("span", { className: styles.label, "aria-hidden": "true", children: [_jsx("span", { className: styles.measure, children: label }), _jsx("span", { className: styles.measure, children: "Copied" }), _jsx("span", { className: styles.measure, children: "Failed" }), _jsx(MorphText, { text: text, reduced: reduced })] })] }), _jsx("span", { className: styles.srOnly, role: "status", "aria-live": "polite", children: state === "idle" ? "" : state === "error" ? `${label}: Could not copy` : `${label}: Copied` })] });
69
+ }
@@ -0,0 +1,21 @@
1
+ /* Width never changes: the label cell reserves its widest state while icon and letters crossfade inside it. Press releases on the spring curve. */
2
+ .button { display: inline-flex; width: max-content; max-width: 100%; min-height: var(--control-height-sm); align-items: center; justify-content: center; gap: var(--space-related); border: 1px solid var(--border); border-radius: var(--radius-control); padding: 0 var(--space-group); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); font-weight: 500; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
3
+ .button:active:not(:disabled) { transform: scale(.97); transition-duration: var(--duration-fast), var(--duration-fast), var(--duration-fast), var(--duration-instant); transition-timing-function: var(--ease-standard); }
4
+ .iconOnly:active:not(:disabled) { transform: scale(.96); }
5
+ @media (hover: hover) and (pointer: fine) { .button:hover:not(:disabled) { background: var(--surface-muted); border-color: var(--border-strong); } .plain:hover:not(:disabled) { border-color: transparent; background: var(--surface-muted); color: var(--foreground); } }
6
+ .button:disabled { cursor: not-allowed; opacity: .5; }
7
+ /* Each icon carries its own color, so the outgoing glyph never tints on its way out. */
8
+ .icon { position: relative; display: grid; width: 16px; height: 16px; flex: none; }
9
+ .iconInner { position: absolute; inset: 0; display: grid; place-items: center; }
10
+ .iconInner[data-state="copied"] { color: var(--success); }
11
+ .iconInner[data-state="error"] { color: var(--danger); }
12
+ /* The inline padding gives blurred edge letters room before the horizontal clip; the negative margin keeps the box the same size. */
13
+ .label { display: grid; min-width: 0; margin-inline: calc(var(--space-tight) * -1); padding-inline: var(--space-tight); overflow-x: clip; text-align: left; white-space: nowrap; }
14
+ .measure, .glyphs { grid-area: 1 / 1; }
15
+ .measure { visibility: hidden; }
16
+ .glyphs { position: relative; display: inline-flex; justify-self: start; white-space: pre; }
17
+ .glyph { display: inline-block; }
18
+ .iconOnly { width: var(--control-height-sm); padding: 0; }
19
+ .plain { border-color: transparent; background: transparent; color: var(--text-secondary); }
20
+ .srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
21
+ @media (prefers-reduced-motion: reduce) { .button { transition: none; } .button:active:not(:disabled) { transform: none; } }
@@ -0,0 +1,31 @@
1
+ import { type ReactNode, type Ref } from "react";
2
+ export type CountdownUnit = "days" | "hours" | "minutes" | "seconds";
3
+ /** Use a countdown for a fixed moment people wait for: a launch, a live event, or a deadline. */
4
+ export interface CountdownProps {
5
+ /**
6
+ * The moment to count down to. A number is epoch milliseconds, a Date is used as is, and an ISO string with `Z` or an offset
7
+ * such as `2026-10-06T10:00:00-07:00` is exact. A string without an offset is wall time in `timeZone`, or UTC when no zone is given,
8
+ * so the result never depends on the visitor's own time zone.
9
+ */
10
+ target: Date | number | string;
11
+ /** IANA zone for a target string without an offset, such as "America/Los_Angeles". Daylight saving is resolved for the target date. */
12
+ timeZone?: string;
13
+ /** Units to show, largest first. The largest unit absorbs the rest, so hours can pass 24 when days are left out. */
14
+ units?: CountdownUnit[];
15
+ /** Large digits with unit labels, or one compact line for banners and table cells. */
16
+ variant?: "large" | "compact";
17
+ /** Shown once the target passes. The digits morph into it. */
18
+ completeLabel?: ReactNode;
19
+ /** Fires once, the moment the countdown reaches zero while mounted. It fires on time even when the tab is hidden. */
20
+ onComplete?: () => void;
21
+ /** Accessible name, such as "Keynote starts in". */
22
+ label?: string;
23
+ /** Clock source in epoch milliseconds. Pass a server-synced clock when the visitor's device time cannot be trusted. */
24
+ clock?: () => number;
25
+ ref?: Ref<HTMLDivElement>;
26
+ className?: string;
27
+ }
28
+ /** Resolves a countdown target to epoch milliseconds without ever reading the device's own time zone. */
29
+ export declare function toEpoch(target: Date | number | string, timeZone?: string): number;
30
+ export declare function Countdown({ target, timeZone, units, variant, completeLabel, onComplete, label, clock, ref, className }: CountdownProps): import("react").JSX.Element;
31
+ export default Countdown;
@@ -0,0 +1,177 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
4
+ import { AnimatePresence, animate, motion, useInView, useMotionValue, useReducedMotion, useTransform } from "motion/react";
5
+ import { motionTokens } from "../../lib/motion-tokens.js";
6
+ import styles from "./countdown.module.css";
7
+ const { spring, stagger } = motionTokens;
8
+ const DEFAULT_UNITS = ["days", "hours", "minutes", "seconds"];
9
+ const SECONDS = { days: 86400, hours: 3600, minutes: 60, seconds: 1 };
10
+ /** How far each unit counts before carrying into the next, when a larger unit is shown. */
11
+ const LIMIT = { days: Infinity, hours: 24, minutes: 60, seconds: 60 };
12
+ const NAMES = { days: ["Days", "d", "day"], hours: ["Hours", "h", "hour"], minutes: ["Minutes", "m", "minute"], seconds: ["Seconds", "s", "second"] };
13
+ const MAX_TIMEOUT = 2147483647;
14
+ const systemClock = () => Date.now();
15
+ /** Offset of a zone from UTC at an instant, in milliseconds. */
16
+ function zoneOffset(epoch, timeZone) {
17
+ const parts = new Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }).formatToParts(new Date(epoch));
18
+ const get = (type) => Number(parts.find(part => part.type === type)?.value ?? 0);
19
+ return Date.UTC(get("year"), get("month") - 1, get("day"), get("hour"), get("minute"), get("second")) - Math.floor(epoch / 1000) * 1000;
20
+ }
21
+ /** Resolves a countdown target to epoch milliseconds without ever reading the device's own time zone. */
22
+ export function toEpoch(target, timeZone) {
23
+ if (typeof target === "number")
24
+ return target;
25
+ if (target instanceof Date)
26
+ return target.getTime();
27
+ const wall = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?)?$/.exec(target.trim());
28
+ if (!wall)
29
+ return Date.parse(target);
30
+ const utc = Date.UTC(+wall[1], +wall[2] - 1, +wall[3], +(wall[4] ?? 0), +(wall[5] ?? 0), +(wall[6] ?? 0));
31
+ if (!timeZone)
32
+ return utc;
33
+ // Two passes settle the offset across a daylight saving change.
34
+ const guess = utc - zoneOffset(utc, timeZone);
35
+ return utc - zoneOffset(guess, timeZone);
36
+ }
37
+ /** Whole seconds left, split across the units on show. */
38
+ function partsFor(seconds, units) {
39
+ return units.map((unit, index) => ({ unit, value: index === 0 ? Math.floor(seconds / SECONDS[unit]) : Math.floor(seconds / SECONDS[unit]) % LIMIT[unit] }));
40
+ }
41
+ /** Wheel sizes per column: a tens column that only ever shows 0 to 5 turns on a six-digit wheel, so 00 → 59 is a single step. */
42
+ function basesFor(unit, largest, length) {
43
+ return Array.from({ length }, (_, index) => {
44
+ const tens = index === length - 2 && length === 2 && !largest;
45
+ return tens ? (unit === "hours" ? 3 : 6) : 10;
46
+ });
47
+ }
48
+ const subscribeNothing = () => () => { };
49
+ const subscribeVisibility = (notify) => { document.addEventListener("visibilitychange", notify); return () => document.removeEventListener("visibilitychange", notify); };
50
+ /** One digit on a wheel. Its offset from the wheel position decides where it sits and how clearly it shows. */
51
+ function Glyph({ position, digit, base }) {
52
+ const offset = useTransform(position, current => ((((digit - current) % base) + base * 1.5) % base) - base / 2);
53
+ const y = useTransform(offset, current => `${current}em`);
54
+ const opacity = useTransform(offset, current => Math.max(0, 1 - Math.abs(current)));
55
+ const visibility = useTransform(offset, current => Math.abs(current) >= 1 ? "hidden" : "visible");
56
+ const filter = useTransform(offset, current => Math.abs(current) < .02 || Math.abs(current) >= 1 ? "none" : `blur(${(Math.abs(current) * motionTokens.blur.subtle).toFixed(2)}px)`);
57
+ return _jsx(motion.span, { className: styles.glyph, style: { y, opacity, filter, visibility }, children: digit });
58
+ }
59
+ /** A digit wheel that takes the short way round, so a countdown ticks downward and a wrap such as 00 → 59 is one step. */
60
+ function Wheel({ digit, base, delay, reduced }) {
61
+ const position = useMotionValue(0);
62
+ const wheel = useRef({ digit: 0, target: 0, revealed: false });
63
+ useEffect(() => {
64
+ const state = wheel.current;
65
+ if (state.digit === digit) {
66
+ state.revealed = true;
67
+ return;
68
+ }
69
+ const up = (digit - state.digit + base) % base, down = base - up;
70
+ state.target += down <= up ? -down : up;
71
+ state.digit = digit;
72
+ const first = !state.revealed;
73
+ state.revealed = true;
74
+ // A new animation on the same value takes over from the one in flight, keeping its velocity; no cleanup, so a re-run never strands a wheel mid-turn.
75
+ if (reduced)
76
+ position.jump(state.target);
77
+ else
78
+ animate(position, state.target, first ? { ...spring.smooth, delay } : spring.snappy);
79
+ }, [base, delay, digit, position, reduced]);
80
+ return _jsxs(motion.span, { className: styles.wheel, initial: { width: 0, opacity: 0 }, animate: { width: "auto", opacity: 1 }, exit: { width: 0, opacity: 0 }, transition: reduced ? { duration: 0 } : spring.morph, children: [_jsx("span", { className: styles.sizer, children: "0" }), Array.from({ length: base }, (_, value) => _jsx(Glyph, { position: position, digit: value, base: base }, value))] });
81
+ }
82
+ export function Countdown({ target, timeZone, units = DEFAULT_UNITS, variant = "large", completeLabel = "Live now", onComplete, label = "Time remaining", clock = systemClock, ref, className }) {
83
+ const root = useRef(null);
84
+ useImperativeHandle(ref, () => root.current);
85
+ const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);
86
+ const reduced = !!useReducedMotion() && hydrated;
87
+ const end = toEpoch(target, timeZone);
88
+ const valid = Number.isFinite(end);
89
+ const shownUnits = units.length ? [...units].sort((a, b) => SECONDS[b] - SECONDS[a]) : DEFAULT_UNITS;
90
+ // The clock only ticks while the countdown is on screen and the tab is visible; coming back reads the clock again, so nothing drifts.
91
+ const pageVisible = useSyncExternalStore(subscribeVisibility, () => document.visibilityState === "visible", () => true);
92
+ const inView = useInView(root, { amount: 0 });
93
+ const running = pageVisible && inView && valid;
94
+ const [now, setNow] = useState(null);
95
+ const clockRef = useRef(clock);
96
+ useLayoutEffect(() => { clockRef.current = clock; });
97
+ useEffect(() => {
98
+ if (!running)
99
+ return;
100
+ let timer;
101
+ const tick = () => {
102
+ const time = clockRef.current();
103
+ setNow(time);
104
+ const left = end - time;
105
+ // Wake just after the next whole second, aligned to the target rather than to when the page loaded.
106
+ if (left > 0)
107
+ timer = setTimeout(tick, (left % 1000 || 1000) + 8);
108
+ };
109
+ timer = setTimeout(tick, 0);
110
+ return () => clearTimeout(timer);
111
+ }, [end, running]);
112
+ // Completion is armed separately, so it lands on time even while ticking is paused.
113
+ useEffect(() => {
114
+ if (!valid)
115
+ return;
116
+ const left = end - clockRef.current();
117
+ if (left <= 0)
118
+ return;
119
+ const timer = setTimeout(() => setNow(clockRef.current()), Math.min(MAX_TIMEOUT, left + 8));
120
+ return () => clearTimeout(timer);
121
+ }, [end, valid]);
122
+ const left = now === null || !valid ? null : Math.max(0, end - now);
123
+ const complete = left === 0;
124
+ const onCompleteRef = useRef(onComplete);
125
+ useLayoutEffect(() => { onCompleteRef.current = onComplete; });
126
+ const wasRunning = useRef(null);
127
+ useEffect(() => {
128
+ if (left === null)
129
+ return;
130
+ if (left > 0) {
131
+ wasRunning.current = end;
132
+ return;
133
+ }
134
+ if (wasRunning.current === end) {
135
+ wasRunning.current = null;
136
+ onCompleteRef.current?.();
137
+ }
138
+ }, [end, left]);
139
+ const seconds = left === null ? 0 : Math.ceil(left / 1000);
140
+ const parts = partsFor(seconds, shownUnits);
141
+ const pending = left === null;
142
+ const compact = variant === "compact";
143
+ // The shell springs to the size of whatever it holds, so the digits can morph into the finished label without a jump.
144
+ const content = useRef(null);
145
+ const width = useMotionValue("auto"), height = useMotionValue("auto");
146
+ useLayoutEffect(() => {
147
+ const node = content.current;
148
+ if (!node || typeof ResizeObserver === "undefined")
149
+ return;
150
+ let first = true;
151
+ const observer = new ResizeObserver(() => {
152
+ const w = node.offsetWidth, h = node.offsetHeight;
153
+ if (first || reduced) {
154
+ width.jump(w);
155
+ height.jump(h);
156
+ first = false;
157
+ return;
158
+ }
159
+ animate(width, w, spring.morph);
160
+ animate(height, h, spring.smooth);
161
+ });
162
+ observer.observe(node);
163
+ return () => observer.disconnect();
164
+ }, [height, reduced, width]);
165
+ const spoken = complete ? (typeof completeLabel === "string" ? completeLabel : "Complete") : pending ? "" : parts.filter((part, index) => part.value > 0 || index === parts.length - 1).filter(part => seconds < 60 || part.unit !== "seconds").map(part => `${part.value} ${part.value === 1 ? NAMES[part.unit][2] : `${NAMES[part.unit][2]}s`}`).join(", ");
166
+ const swap = reduced
167
+ ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: motionTokens.duration.fast } }
168
+ : { initial: { opacity: 0, scale: .92, filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, scale: 1, filter: "blur(0px)" }, exit: { opacity: 0, scale: .92, filter: `blur(${motionTokens.blur.soft}px)`, transition: { duration: motionTokens.duration.fast } }, transition: spring.morph };
169
+ return _jsxs("div", { ref: root, className: [styles.countdown, className].filter(Boolean).join(" "), "data-variant": variant, "data-complete": complete || undefined, "data-pending": pending || undefined, role: "timer", "aria-label": label, children: [_jsx(motion.div, { className: styles.shell, style: { width, height }, children: _jsx("div", { ref: content, className: styles.content, "aria-hidden": "true", children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: complete
170
+ ? _jsxs(motion.span, { className: styles.done, ...swap, children: [_jsx("span", { className: styles.pulse }), completeLabel] }, "done")
171
+ : _jsx(motion.span, { className: styles.units, ...swap, children: parts.map((part, group) => {
172
+ const text = String(part.value).padStart(2, "0");
173
+ const bases = basesFor(part.unit, group === 0, text.length);
174
+ return _jsxs("span", { className: styles.unit, children: [!compact && group > 0 && _jsx("span", { className: styles.colon, children: ":" }), _jsxs("span", { className: styles.group, children: [_jsx("span", { className: styles.number, children: _jsx(AnimatePresence, { initial: false, children: [...text].map((char, index) => _jsx(Wheel, { digit: Number(char), base: bases[index], delay: Math.min((group * 2 + index) * stagger.item, .3), reduced: reduced }, `${part.unit}${text.length - index}`)) }) }), _jsx("span", { className: styles.name, children: NAMES[part.unit][compact ? 1 : 0] })] })] }, part.unit);
175
+ }) }, "running") }) }) }), _jsx("span", { className: styles.srOnly, children: spoken })] });
176
+ }
177
+ export default Countdown;
@@ -0,0 +1,49 @@
1
+ .countdown { position: relative; min-width: 0; color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
2
+ .countdown[data-variant="large"] { container-type: inline-size; display: flex; justify-content: center; }
3
+ .countdown[data-variant="compact"] { display: inline-flex; vertical-align: middle; }
4
+
5
+ /* The shell springs to whatever it holds; the compact pill shows that morph as its own outline. */
6
+ .shell { position: relative; display: flex; justify-content: center; }
7
+ .content { position: relative; display: inline-flex; flex: none; align-items: center; justify-content: center; }
8
+ .countdown[data-variant="compact"] .shell { overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-full); corner-shape: round; background: var(--surface); transition: border-color var(--duration-standard) var(--ease-standard), background-color var(--duration-standard) var(--ease-standard); }
9
+ .countdown[data-variant="compact"] .content { min-height: 32px; padding: 0 var(--space-group); }
10
+ .countdown[data-variant="compact"][data-complete] .shell { border-color: color-mix(in oklab, var(--success) 35%, var(--border)); background: color-mix(in oklab, var(--success) 9%, var(--surface)); }
11
+
12
+ .units { display: inline-flex; align-items: flex-start; white-space: nowrap; }
13
+ .unit { display: inline-flex; align-items: flex-start; }
14
+ .group { display: inline-grid; justify-items: center; gap: 6px; }
15
+ .number { display: inline-flex; font-variant-numeric: tabular-nums; transition: opacity var(--duration-considered) var(--ease-standard); }
16
+ .countdown[data-pending] .number { opacity: .3; }
17
+ .name { color: var(--text-muted); font-size: var(--text-xs); line-height: 1; }
18
+ .colon { color: var(--text-muted); opacity: .6; }
19
+
20
+ /* Large: display digits sized to the container, so four groups fit at 320px and breathe on a wide card. */
21
+ .countdown[data-variant="large"] .units { gap: 0; font-family: var(--font-display); font-size: clamp(1.75rem, 12.5cqi, var(--text-5xl)); font-weight: 500; letter-spacing: var(--tracking-display); line-height: 1; }
22
+ .countdown[data-variant="large"] .colon { padding-inline: .12em; line-height: 1; transform: translateY(-.06em); }
23
+ /* On a narrow card the unit names outgrow the digits; an even column floor keeps the colons evenly spaced instead of following each name's width. */
24
+ .countdown[data-variant="large"] .group { min-width: min(calc((100cqi - 1.6em) / 4), calc(var(--text-xs) * 4.2)); }
25
+ .countdown[data-variant="large"] .name { font-family: var(--font-body); letter-spacing: var(--tracking-body); }
26
+
27
+ /* Compact: one line, digits then a quiet unit letter. */
28
+ .countdown[data-variant="compact"] .units { gap: 6px; font-size: var(--text-sm); font-weight: 500; line-height: 1.3; }
29
+ .countdown[data-variant="compact"] .group { display: inline-flex; align-items: baseline; gap: 1px; }
30
+ .countdown[data-variant="compact"] .name { font-size: var(--text-sm); font-weight: 400; line-height: 1.3; }
31
+
32
+ /* Each digit is a clipped wheel whose window feathers out, so a turning digit fades at the edge instead of being cut. */
33
+ .wheel { --feather: .16em; position: relative; display: inline-block; overflow: hidden; margin-block: calc(var(--feather) * -1); padding-block: var(--feather); mask-image: linear-gradient(to bottom, transparent, #000 calc(var(--feather) * 1.5), #000 calc(100% - var(--feather) * 1.5), transparent); }
34
+ .sizer { visibility: hidden; }
35
+ .glyph { position: absolute; inset: var(--feather) 0; text-align: center; }
36
+
37
+ /* Finished: the digits give way to the label and a live mark. */
38
+ .done { display: inline-flex; align-items: center; gap: .4em; white-space: nowrap; }
39
+ .countdown[data-variant="large"] .done { font-family: var(--font-display); font-size: clamp(1.75rem, 11cqi, var(--text-4xl)); font-weight: 500; letter-spacing: var(--tracking-display); line-height: 1.1; }
40
+ .countdown[data-variant="compact"] .done { color: var(--success); font-size: var(--text-sm); font-weight: 500; line-height: 1.3; }
41
+ .pulse { position: relative; width: .3em; height: .3em; min-width: 8px; min-height: 8px; flex: none; border-radius: 50%; corner-shape: round; background: var(--success); }
42
+ .pulse::after { position: absolute; inset: 0; border-radius: inherit; corner-shape: inherit; background: var(--success); content: ""; animation: pulse 2s var(--ease-standard) infinite; }
43
+ @keyframes pulse { from { opacity: .5; transform: scale(1); } 70%, to { opacity: 0; transform: scale(2.6); } }
44
+
45
+ .srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
46
+ @media (prefers-reduced-motion: reduce) {
47
+ .pulse::after { animation: none; }
48
+ .number, .shell { transition: none; }
49
+ }
@@ -0,0 +1,17 @@
1
+ import type { ButtonHTMLAttributes } from "react";
2
+ import { type CalendarDateMatcher } from "../calendar/calendar.js";
3
+ export interface DatePickerProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "value" | "onChange"> {
4
+ label: string;
5
+ value?: Date;
6
+ onChange?: (date: Date | undefined) => void;
7
+ description?: string;
8
+ placeholder?: string;
9
+ minDate?: Date;
10
+ maxDate?: Date;
11
+ disabledDates?: CalendarDateMatcher;
12
+ locale?: string;
13
+ format?: Intl.DateTimeFormatOptions;
14
+ /** Adds a Today button to the calendar header. */
15
+ showToday?: boolean;
16
+ }
17
+ export declare function DatePicker({ label, value, onChange, description, placeholder, minDate, maxDate, disabledDates, locale, format, showToday, id, className, disabled, ...buttonProps }: DatePickerProps): import("react").JSX.Element;