@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,417 @@
1
+ "use client";
2
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { forwardRef, useCallback, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react";
4
+ import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
5
+ import { Hash } from "lucide-react";
6
+ import { motionTokens } from "../../lib/motion-tokens.js";
7
+ import styles from "./mention-input.module.css";
8
+ const EMPTY = { text: "", mentions: [] };
9
+ const SYMBOL = { person: "@", channel: "#" };
10
+ const POPOVER_WIDTH = 272;
11
+ const { spring, duration, ease } = motionTokens;
12
+ const enter = [...ease.enter];
13
+ const standard = [...ease.standard];
14
+ const physical = (visualDuration, bounce) => {
15
+ const root = 2 * Math.PI / (visualDuration * 1.2);
16
+ return { type: "spring", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };
17
+ };
18
+ const GLIDE = physical(.3, .1), GROW = physical(spring.smooth.visualDuration, 0);
19
+ /** First placements. A jump made in the commit that mounts its element never reaches the DOM; an instant animation lands on the first frame. */
20
+ const INSTANT = { duration: 0 };
21
+ export const mentionText = (kind, label) => `${SYMBOL[kind]}${label}`;
22
+ /** Turns a value into storage friendly text, `<@id>` for people and `<#id>` for channels by default. */
23
+ export function serializeMentions(value, format = mention => `<${SYMBOL[mention.kind]}${mention.id}>`) {
24
+ let out = "", at = 0;
25
+ for (const mention of [...value.mentions].sort((a, b) => a.start - b.start)) {
26
+ out += value.text.slice(at, mention.start) + format(mention);
27
+ at = mention.end;
28
+ }
29
+ return out + value.text.slice(at);
30
+ }
31
+ /** Moves mentions along with an edit and drops any the edit touched. `hint` is the caret before the edit, which settles repeated characters. */
32
+ function reconcile(previous, text, hint) {
33
+ const a = previous.text;
34
+ let prefix = 0;
35
+ const maxPrefix = Math.min(a.length, text.length, Math.max(0, hint));
36
+ while (prefix < maxPrefix && a[prefix] === text[prefix])
37
+ prefix++;
38
+ let suffix = 0;
39
+ while (suffix < a.length - prefix && suffix < text.length - prefix && a[a.length - 1 - suffix] === text[text.length - 1 - suffix])
40
+ suffix++;
41
+ const oldEnd = a.length - suffix, delta = text.length - a.length;
42
+ return previous.mentions.flatMap(mention => {
43
+ if (mention.end <= prefix)
44
+ return [mention];
45
+ if (mention.start >= oldEnd)
46
+ return [{ ...mention, start: mention.start + delta, end: mention.end + delta }];
47
+ return [];
48
+ }).filter(mention => text.slice(mention.start, mention.end) === mentionText(mention.kind, mention.label));
49
+ }
50
+ function findTrigger(text, caret, mentions, kinds) {
51
+ for (let index = caret - 1; index >= 0 && index >= caret - 48; index--) {
52
+ const char = text[index];
53
+ if (char === "\n")
54
+ return null;
55
+ if (char !== "@" && char !== "#")
56
+ continue;
57
+ const kind = char === "@" ? "person" : "channel";
58
+ if (!kinds[kind])
59
+ return null;
60
+ if (index > 0 && !/[\s([{"']/.test(text[index - 1]))
61
+ return null;
62
+ if (mentions.some(mention => index >= mention.start && index < mention.end))
63
+ return null;
64
+ const query = text.slice(index + 1, caret);
65
+ const shape = kind === "person" ? /^[^\s@#]*( [^\s@#]*)?$/ : /^[^\s@#]*$/;
66
+ return shape.test(query) ? { kind, start: index, query } : null;
67
+ }
68
+ return null;
69
+ }
70
+ function rank(label, extra, query) {
71
+ const name = label.toLowerCase();
72
+ if (!query)
73
+ return 1;
74
+ if (name.startsWith(query))
75
+ return 4;
76
+ if (name.split(/[\s\-_.]+/).some(word => word.startsWith(query)))
77
+ return 3;
78
+ if (name.includes(query))
79
+ return 2;
80
+ return extra.toLowerCase().includes(query) ? 1 : 0;
81
+ }
82
+ function initials(name) {
83
+ return name.split(/\s+/).filter(Boolean).slice(0, 2).map(part => part[0]?.toUpperCase()).join("");
84
+ }
85
+ /** The matched part of a label reads in full contrast; the rest stays quiet. */
86
+ function Highlight({ text, query }) {
87
+ const at = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;
88
+ if (at < 0)
89
+ return _jsx(_Fragment, { children: text });
90
+ return _jsxs(_Fragment, { children: [text.slice(0, at), _jsx("mark", { className: styles.match, children: text.slice(at, at + query.length) }), text.slice(at + query.length)] });
91
+ }
92
+ /**
93
+ * A textarea with `@` people and `#` channel mentions. Mentions are atomic: the caret steps over them and one Backspace removes the whole token.
94
+ * The value carries plain text plus a structured `mentions` array with offsets, ready to store or notify.
95
+ */
96
+ export const MentionInput = forwardRef(function MentionInput({ value: valueProp, defaultValue, onChange, people, channels, onMentionAdd, onSubmit, submitOnEnter = false, placeholder, minRows = 1, maxRows = 8, placement = "auto", maxSuggestions = 6, disabled, name, id, "aria-label": ariaLabel, "aria-describedby": describedBy, className, }, ref) {
97
+ const reduced = !!useReducedMotion();
98
+ const uid = useId();
99
+ const listId = `${uid}-list`;
100
+ const [inner, setInner] = useState(defaultValue ?? EMPTY);
101
+ const value = valueProp ?? inner;
102
+ const live = useRef(value);
103
+ useLayoutEffect(() => { live.current = value; });
104
+ const rootRef = useRef(null);
105
+ const fieldRef = useRef(null);
106
+ const areaRef = useRef(null);
107
+ const backdropRef = useRef(null);
108
+ const lastCaret = useRef(0);
109
+ const pending = useRef(null);
110
+ const [caret, setCaret] = useState(null);
111
+ const [focused, setFocused] = useState(false);
112
+ const [dismissed, setDismissed] = useState(null);
113
+ const [active, setActive] = useState(0);
114
+ const commit = useCallback((next) => {
115
+ if (valueProp === undefined)
116
+ setInner(next);
117
+ onChange?.(next);
118
+ }, [onChange, valueProp]);
119
+ const kinds = useMemo(() => ({ person: !!people?.length, channel: !!channels?.length }), [channels, people]);
120
+ const trigger = useMemo(() => caret === null ? null : findTrigger(value.text, caret, value.mentions, kinds), [caret, kinds, value.mentions, value.text]);
121
+ const suggestions = useMemo(() => {
122
+ if (!trigger)
123
+ return [];
124
+ const query = trigger.query.toLowerCase().trim();
125
+ const scored = trigger.kind === "person"
126
+ ? (people ?? []).map(item => ({ entry: { kind: "person", item }, score: rank(item.name, item.role ?? "", query) }))
127
+ : (channels ?? []).map(item => ({ entry: { kind: "channel", item }, score: rank(item.name, item.description ?? "", query) }));
128
+ return scored.filter(entry => entry.score > 0).sort((a, b) => b.score - a.score).slice(0, maxSuggestions).map(entry => entry.entry);
129
+ }, [channels, maxSuggestions, people, trigger]);
130
+ // A sentence that simply continues after an unmatched name closes the list instead of showing an empty state.
131
+ const open = focused && !disabled && !!trigger && trigger.start !== dismissed && (suggestions.length > 0 || !trigger.query.includes(" "));
132
+ const activeIndex = Math.min(active, Math.max(0, suggestions.length - 1));
133
+ const queryKey = trigger ? `${trigger.start}:${trigger.query}` : "";
134
+ const [lastQueryKey, setLastQueryKey] = useState(queryKey);
135
+ if (lastQueryKey !== queryKey) {
136
+ setLastQueryKey(queryKey);
137
+ setActive(0);
138
+ }
139
+ if (dismissed !== null && trigger?.start !== dismissed)
140
+ setDismissed(null);
141
+ /* Selection: the caret never rests inside a token. Arrow keys hop over it; a click lands on the nearest edge. */
142
+ const onSelect = () => {
143
+ const area = areaRef.current;
144
+ if (!area)
145
+ return;
146
+ let start = area.selectionStart, end = area.selectionEnd;
147
+ const collapsed = start === end;
148
+ for (const mention of live.current.mentions) {
149
+ if (collapsed && start > mention.start && start < mention.end) {
150
+ const stepped = Math.abs(start - lastCaret.current) === 1;
151
+ const forward = start > lastCaret.current;
152
+ start = end = stepped ? (forward ? mention.end : mention.start) : (start - mention.start < mention.end - start ? mention.start : mention.end);
153
+ }
154
+ else if (!collapsed) {
155
+ if (start > mention.start && start < mention.end)
156
+ start = mention.start;
157
+ if (end > mention.start && end < mention.end)
158
+ end = mention.end;
159
+ }
160
+ }
161
+ if (start !== area.selectionStart || end !== area.selectionEnd)
162
+ area.setSelectionRange(start, end, area.selectionDirection);
163
+ lastCaret.current = area.selectionStart;
164
+ setCaret(collapsed ? start : null);
165
+ };
166
+ const onInput = (event) => {
167
+ const text = event.target.value;
168
+ const mentions = reconcile(live.current, text, lastCaret.current);
169
+ const added = pending.current;
170
+ pending.current = null;
171
+ if (added && text.slice(added.start, added.end) === mentionText(added.kind, added.label)) {
172
+ mentions.push(added);
173
+ mentions.sort((a, b) => a.start - b.start);
174
+ }
175
+ const next = { text, mentions };
176
+ live.current = next;
177
+ commit(next);
178
+ if (added)
179
+ onMentionAdd?.(added);
180
+ lastCaret.current = event.target.selectionStart;
181
+ setCaret(event.target.selectionStart === event.target.selectionEnd ? event.target.selectionStart : null);
182
+ };
183
+ /** Types through the browser so the change joins the native undo stack; falls back to a direct edit. */
184
+ const typeText = useCallback((text, from, to) => {
185
+ const area = areaRef.current;
186
+ if (!area)
187
+ return;
188
+ area.focus({ preventScroll: true });
189
+ if (from !== undefined)
190
+ area.setSelectionRange(from, to ?? from);
191
+ lastCaret.current = area.selectionStart;
192
+ const typed = typeof document.execCommand === "function" && document.execCommand("insertText", false, text);
193
+ if (typed)
194
+ return;
195
+ const start = area.selectionStart, end = area.selectionEnd, current = live.current;
196
+ const nextText = current.text.slice(0, start) + text + current.text.slice(end);
197
+ const mentions = reconcile(current, nextText, start);
198
+ const added = pending.current;
199
+ pending.current = null;
200
+ if (added)
201
+ mentions.push(added);
202
+ mentions.sort((a, b) => a.start - b.start);
203
+ const next = { text: nextText, mentions };
204
+ live.current = next;
205
+ commit(next);
206
+ if (added)
207
+ onMentionAdd?.(added);
208
+ requestAnimationFrame(() => { area.setSelectionRange(start + text.length, start + text.length); setCaret(start + text.length); });
209
+ }, [commit, onMentionAdd]);
210
+ const choose = (suggestion) => {
211
+ if (!suggestion || !trigger || caret === null)
212
+ return;
213
+ const label = suggestion.item.name;
214
+ const token = mentionText(suggestion.kind, label);
215
+ pending.current = { kind: suggestion.kind, id: suggestion.item.id, label, start: trigger.start, end: trigger.start + token.length };
216
+ const after = value.text[caret];
217
+ typeText(after === undefined || !/\s/.test(after) ? `${token} ` : token, trigger.start, caret);
218
+ };
219
+ const onKeyDown = (event) => {
220
+ const area = event.currentTarget;
221
+ lastCaret.current = area.selectionStart;
222
+ if (open) {
223
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
224
+ event.preventDefault();
225
+ if (suggestions.length)
226
+ setActive((activeIndex + (event.key === "ArrowDown" ? 1 : -1) + suggestions.length) % suggestions.length);
227
+ return;
228
+ }
229
+ if ((event.key === "Enter" || event.key === "Tab") && suggestions.length && !event.shiftKey) {
230
+ event.preventDefault();
231
+ choose(suggestions[activeIndex]);
232
+ return;
233
+ }
234
+ if (event.key === "Escape") {
235
+ event.preventDefault();
236
+ event.stopPropagation();
237
+ setDismissed(trigger?.start ?? null);
238
+ return;
239
+ }
240
+ }
241
+ const start = area.selectionStart, end = area.selectionEnd;
242
+ if (start === end && !event.metaKey && (event.key === "Backspace" || event.key === "Delete")) {
243
+ // Select the whole token and let the native delete run, so one keystroke removes it and undo restores it.
244
+ const hit = live.current.mentions.find(mention => event.key === "Backspace" ? start > mention.start && start <= mention.end : start >= mention.start && start < mention.end);
245
+ if (hit)
246
+ area.setSelectionRange(hit.start, hit.end);
247
+ return;
248
+ }
249
+ if (event.key === "Enter" && submitOnEnter && !event.shiftKey && !event.nativeEvent.isComposing) {
250
+ event.preventDefault();
251
+ if (live.current.text.trim())
252
+ onSubmit?.(live.current);
253
+ }
254
+ };
255
+ useImperativeHandle(ref, () => ({
256
+ focus: () => areaRef.current?.focus(),
257
+ insert: text => typeText(text),
258
+ openSuggestions: kind => {
259
+ const area = areaRef.current;
260
+ if (!area)
261
+ return;
262
+ const at = document.activeElement === area ? area.selectionStart : live.current.text.length;
263
+ const before = live.current.text[at - 1];
264
+ setDismissed(null);
265
+ typeText(`${before && !/\s/.test(before) ? " " : ""}${SYMBOL[kind]}`, at, document.activeElement === area ? area.selectionEnd : at);
266
+ },
267
+ clear: () => {
268
+ commit(EMPTY);
269
+ live.current = EMPTY;
270
+ setCaret(0);
271
+ },
272
+ get textarea() { return areaRef.current; },
273
+ }), [commit, typeText]);
274
+ /* Autosize: the backdrop mirrors the text, so its height is the content height. The field springs to it within the row limits. */
275
+ const height = useMotionValue("auto");
276
+ const [limits, setLimits] = useState({ min: 0, max: Infinity });
277
+ const measured = useRef(false);
278
+ useLayoutEffect(() => {
279
+ const backdrop = backdropRef.current;
280
+ if (!backdrop)
281
+ return;
282
+ const style = getComputedStyle(backdrop);
283
+ const line = parseFloat(style.lineHeight) || 24;
284
+ const pad = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
285
+ const next = { min: line * minRows + pad, max: line * Math.max(minRows, maxRows) + pad };
286
+ setLimits(current => current.min === next.min && current.max === next.max ? current : next);
287
+ }, [maxRows, minRows]);
288
+ useLayoutEffect(() => {
289
+ const backdrop = backdropRef.current;
290
+ if (!backdrop || !limits.min)
291
+ return;
292
+ const fit = () => {
293
+ const target = Math.min(limits.max, Math.max(limits.min, backdrop.offsetHeight));
294
+ const current = height.get();
295
+ if (!measured.current || reduced || typeof current !== "number") {
296
+ animate(height, target, INSTANT);
297
+ measured.current = true;
298
+ return;
299
+ }
300
+ if (Math.abs(current - target) > .5)
301
+ animate(height, target, GROW);
302
+ };
303
+ fit();
304
+ if (typeof ResizeObserver === "undefined")
305
+ return;
306
+ const observer = new ResizeObserver(fit);
307
+ observer.observe(backdrop);
308
+ return () => observer.disconnect();
309
+ }, [height, limits, reduced]);
310
+ /* The popover hangs from the trigger character, measured from a marker in the mirrored text. */
311
+ const [anchor, setAnchor] = useState(null);
312
+ const measureAnchor = useCallback(() => {
313
+ const marker = backdropRef.current?.querySelector("[data-anchor]");
314
+ const root = rootRef.current, field = fieldRef.current, area = areaRef.current;
315
+ if (!marker || !root || !field || !area) {
316
+ setAnchor(null);
317
+ return;
318
+ }
319
+ const line = parseFloat(getComputedStyle(marker.parentElement ?? marker).lineHeight) || 24;
320
+ const x = Math.max(0, Math.min(field.offsetLeft + marker.offsetLeft - 10, root.offsetWidth - POPOVER_WIDTH));
321
+ const top = field.offsetTop + marker.offsetTop - area.scrollTop;
322
+ const box = root.getBoundingClientRect();
323
+ const roomBelow = window.innerHeight - (box.top + top + line);
324
+ const above = placement === "top" || (placement === "auto" && roomBelow < 300 && box.top + top > roomBelow);
325
+ const y = above ? top - 6 : top + line + 6;
326
+ setAnchor(current => current && current.x === x && current.y === y && current.above === above && current.line === line ? current : { x, y, line, above });
327
+ }, [placement]);
328
+ useLayoutEffect(() => { if (open)
329
+ measureAnchor(); }, [measureAnchor, open, trigger?.start, value.text]);
330
+ // The popover mounts one commit after `open`, once the anchor is measured; the effects below wait for it.
331
+ const shown = open && !!anchor;
332
+ const onScroll = (event) => {
333
+ const backdrop = backdropRef.current;
334
+ if (backdrop)
335
+ backdrop.style.transform = `translateY(${-event.currentTarget.scrollTop}px)`;
336
+ measureAnchor();
337
+ };
338
+ /* The list's highlight glides between rows; the panel springs its height as matches come and go. */
339
+ const listRef = useRef(null);
340
+ const hy = useMotionValue(0), hh = useMotionValue(0);
341
+ const placed = useRef(false);
342
+ const panelHeight = useMotionValue("auto");
343
+ useLayoutEffect(() => {
344
+ const row = listRef.current?.querySelector(`[data-index="${activeIndex}"]`);
345
+ if (!row)
346
+ return;
347
+ if (reduced || !placed.current) {
348
+ animate(hy, row.offsetTop, INSTANT);
349
+ animate(hh, row.offsetHeight, INSTANT);
350
+ placed.current = true;
351
+ return;
352
+ }
353
+ animate(hy, row.offsetTop, GLIDE);
354
+ animate(hh, row.offsetHeight, GLIDE);
355
+ }, [activeIndex, hh, hy, reduced, shown, suggestions]);
356
+ const bodyRef = useRef(null);
357
+ const panelSized = useRef(false);
358
+ useLayoutEffect(() => {
359
+ // The leaving list keeps its highlight while it fades; the next open places it fresh.
360
+ if (!shown) {
361
+ panelSized.current = false;
362
+ placed.current = false;
363
+ return;
364
+ }
365
+ const body = bodyRef.current;
366
+ if (!body)
367
+ return;
368
+ const fit = () => {
369
+ const target = body.offsetHeight;
370
+ if (!panelSized.current || reduced) {
371
+ animate(panelHeight, target, INSTANT);
372
+ panelSized.current = true;
373
+ return;
374
+ }
375
+ animate(panelHeight, target, GROW);
376
+ };
377
+ fit();
378
+ if (typeof ResizeObserver === "undefined")
379
+ return;
380
+ const observer = new ResizeObserver(fit);
381
+ observer.observe(body);
382
+ return () => observer.disconnect();
383
+ }, [panelHeight, reduced, shown, trigger?.start]);
384
+ /* Mirror: the same text as the textarea, with tokens drawn in place and a marker at the open trigger. */
385
+ const mirror = useMemo(() => {
386
+ const parts = [];
387
+ const markAt = open && trigger ? trigger.start : -1;
388
+ let at = 0;
389
+ const pushText = (from, to) => {
390
+ if (markAt >= from && markAt < to) {
391
+ parts.push(value.text.slice(from, markAt), _jsx("span", { "data-anchor": "" }, "anchor"), value.text.slice(markAt, to));
392
+ }
393
+ else
394
+ parts.push(value.text.slice(from, to));
395
+ };
396
+ for (const mention of value.mentions) {
397
+ pushText(at, mention.start);
398
+ parts.push(_jsx("span", { className: styles.token, "data-kind": mention.kind, children: value.text.slice(mention.start, mention.end) }, `${mention.start}-${mention.id}`));
399
+ at = mention.end;
400
+ }
401
+ pushText(at, value.text.length);
402
+ return parts;
403
+ }, [open, trigger, value.mentions, value.text]);
404
+ const optionId = (index) => `${uid}-option-${index}`;
405
+ const popIn = reduced ? { opacity: 0 } : { opacity: 0, scale: .96, y: anchor?.above ? 4 : -4 };
406
+ const countLabel = open ? (suggestions.length ? `${suggestions.length} ${trigger?.kind === "person" ? (suggestions.length === 1 ? "person" : "people") : (suggestions.length === 1 ? "channel" : "channels")}` : "No matches") : "";
407
+ return _jsxs("div", { ref: rootRef, className: [styles.root, className].filter(Boolean).join(" "), "data-disabled": disabled || undefined, children: [_jsxs(motion.div, { ref: fieldRef, className: styles.field, style: { height }, "data-focused": focused || undefined, children: [_jsxs("div", { ref: backdropRef, className: styles.backdrop, "aria-hidden": "true", children: [mirror, "\u200b"] }), _jsx("textarea", { ref: areaRef, id: id, name: name, className: styles.textarea, value: value.text, placeholder: placeholder, disabled: disabled, rows: minRows, spellCheck: true, role: "combobox", "aria-label": ariaLabel, "aria-describedby": describedBy, "aria-autocomplete": "list", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listId : undefined, "aria-activedescendant": open && suggestions.length ? optionId(activeIndex) : undefined, onChange: onInput, onSelect: onSelect, onKeyDown: onKeyDown, onScroll: onScroll, onFocus: () => setFocused(true), onBlur: () => { setFocused(false); setCaret(null); } })] }), _jsx("span", { className: styles.srOnly, "aria-live": "polite", children: countLabel }), _jsx(AnimatePresence, { children: open && anchor && _jsx(motion.div, { className: styles.popover, "data-above": anchor.above || undefined, style: { left: anchor.x, top: anchor.y, height: panelHeight, transformOrigin: anchor.above ? "14px 100%" : "14px 0" }, initial: popIn, animate: { opacity: 1, scale: 1, y: 0, transition: reduced ? { duration: duration.fast } : { ...spring.snappy, opacity: { duration: duration.fast, ease: enter } } }, exit: { ...popIn, transition: { duration: duration.exit * .7, ease: standard } }, onMouseDown: event => event.preventDefault(), children: _jsx("div", { ref: bodyRef, className: styles.popoverBody, children: suggestions.length ? _jsxs("ul", { ref: listRef, id: listId, role: "listbox", "aria-label": trigger?.kind === "person" ? "People" : "Channels", className: styles.list, children: [_jsx(motion.li, { "aria-hidden": "true", className: styles.highlight, style: { y: hy, height: hh } }), suggestions.map((suggestion, index) => _jsxs("li", { id: optionId(index), role: "option", "aria-selected": index === activeIndex, "data-index": index, className: styles.option, onPointerMove: () => { if (index !== activeIndex)
408
+ setActive(index); }, onClick: () => choose(suggestion), children: [suggestion.kind === "person"
409
+ ? _jsx("span", { className: styles.avatar, "aria-hidden": "true", children: suggestion.item.avatar
410
+ // eslint-disable-next-line @next/next/no-img-element -- registry components stay framework agnostic
411
+ ? _jsx("img", { src: suggestion.item.avatar, alt: "", width: 28, height: 28, loading: "lazy" })
412
+ : initials(suggestion.item.name) })
413
+ : _jsx("span", { className: styles.glyph, "aria-hidden": "true", children: _jsx(Hash, { size: 16, strokeWidth: 1.75 }) }), _jsxs("span", { className: styles.optionText, children: [_jsx("span", { className: styles.optionLabel, children: _jsx(Highlight, { text: suggestion.item.name, query: trigger?.query.trim() ?? "" }) }), suggestion.kind === "person"
414
+ ? suggestion.item.role && _jsx("span", { className: styles.optionMeta, children: suggestion.item.role })
415
+ : suggestion.item.description && _jsx("span", { className: styles.optionMeta, children: suggestion.item.description })] }), suggestion.kind === "channel" && suggestion.item.members !== undefined && _jsx("span", { className: styles.count, children: suggestion.item.members })] }, `${suggestion.kind}-${suggestion.item.id}`))] }) : _jsxs("p", { className: styles.empty, children: ["No ", trigger?.kind === "person" ? "people" : "channels", " match \u201C", trigger?.query, "\u201D"] }) }) }, trigger?.start) })] });
416
+ });
417
+ export default MentionInput;
@@ -0,0 +1,134 @@
1
+ .root {
2
+ position: relative;
3
+ width: 100%;
4
+ color: var(--foreground);
5
+ font-family: var(--font-body);
6
+ letter-spacing: var(--tracking-body);
7
+ }
8
+ .root[data-disabled] { opacity: .55; }
9
+
10
+ /* The field's height springs to its text. The mirror and the textarea share every metric so tokens sit exactly under their characters. */
11
+ .field {
12
+ position: relative;
13
+ box-sizing: content-box;
14
+ overflow: hidden;
15
+ border: 1px solid var(--border);
16
+ border-radius: var(--radius-control); corner-shape: var(--corner-shape);
17
+ background: var(--surface);
18
+ transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard);
19
+ }
20
+ .field[data-focused] { border-color: var(--border-strong); }
21
+
22
+ .backdrop,
23
+ .textarea {
24
+ box-sizing: border-box;
25
+ width: 100%;
26
+ margin: 0;
27
+ padding: 11px 14px;
28
+ border: 0;
29
+ font: inherit;
30
+ font-size: var(--text-base);
31
+ letter-spacing: var(--tracking-body);
32
+ line-height: 24px;
33
+ overflow-wrap: break-word;
34
+ white-space: pre-wrap;
35
+ word-break: break-word;
36
+ tab-size: 4;
37
+ }
38
+
39
+ .backdrop {
40
+ position: absolute;
41
+ top: 0;
42
+ left: 0;
43
+ color: var(--foreground);
44
+ pointer-events: none;
45
+ will-change: transform;
46
+ }
47
+
48
+ .textarea {
49
+ position: absolute;
50
+ inset: 0;
51
+ height: 100%;
52
+ display: block;
53
+ resize: none;
54
+ overflow-y: auto;
55
+ background: transparent;
56
+ color: transparent;
57
+ caret-color: var(--foreground);
58
+ outline: none;
59
+ scrollbar-width: none;
60
+ -webkit-text-fill-color: transparent;
61
+ }
62
+ .textarea::-webkit-scrollbar { display: none; }
63
+ .textarea::placeholder { color: var(--text-muted); -webkit-text-fill-color: var(--text-muted); opacity: 1; }
64
+ .textarea::selection { background: color-mix(in oklab, var(--accent) 24%, transparent); }
65
+ .textarea:disabled { cursor: not-allowed; }
66
+
67
+ /* A token is tinted around its own glyphs; the negative margin keeps every character where the textarea put it. */
68
+ .token {
69
+ margin: 0 -3px;
70
+ padding: 1px 3px;
71
+ border-radius: var(--radius-detail); corner-shape: var(--corner-shape);
72
+ background: var(--accent-subtle);
73
+ color: var(--accent-strong);
74
+ box-decoration-break: clone;
75
+ -webkit-box-decoration-break: clone;
76
+ }
77
+ .token[data-kind="channel"] { background: color-mix(in oklab, var(--foreground) 7%, transparent); color: var(--foreground); }
78
+
79
+ .popover {
80
+ position: absolute;
81
+ z-index: 30;
82
+ width: 272px;
83
+ max-width: 100%;
84
+ overflow: hidden;
85
+ border: 1px solid var(--border);
86
+ border-radius: var(--radius-control); corner-shape: var(--corner-shape);
87
+ background: var(--surface-raised);
88
+ box-shadow: var(--shadow-floating);
89
+ }
90
+ .popover[data-above] { translate: 0 -100%; }
91
+ .popoverBody { padding: 6px; }
92
+
93
+ .list { position: relative; display: grid; margin: 0; padding: 0; list-style: none; }
94
+ .highlight { position: absolute; top: 0; right: 0; left: 0; border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: color-mix(in oklab, var(--foreground) 6.5%, transparent); pointer-events: none; }
95
+
96
+ .option {
97
+ position: relative;
98
+ display: flex;
99
+ min-width: 0;
100
+ align-items: center;
101
+ gap: 10px;
102
+ padding: 7px 10px 7px 8px;
103
+ border-radius: var(--radius-control); corner-shape: var(--corner-shape);
104
+ cursor: pointer;
105
+ font-size: var(--text-sm);
106
+ -webkit-tap-highlight-color: transparent;
107
+ }
108
+
109
+ .avatar {
110
+ display: grid;
111
+ width: 28px;
112
+ height: 28px;
113
+ flex: none;
114
+ overflow: hidden;
115
+ place-items: center;
116
+ border-radius: 50%; corner-shape: round;
117
+ background: var(--surface-muted);
118
+ color: var(--text-secondary);
119
+ font-size: var(--text-xs);
120
+ font-weight: 500;
121
+ }
122
+ .avatar img { width: 100%; height: 100%; object-fit: cover; }
123
+ .glyph { display: grid; width: 28px; height: 28px; flex: none; place-items: center; color: var(--text-secondary); }
124
+
125
+ .optionText { display: grid; min-width: 0; flex: 1; }
126
+ .optionLabel { overflow: hidden; color: var(--text-secondary); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
127
+ .option[aria-selected="true"] .optionLabel { color: var(--foreground); }
128
+ .match { background: none; color: var(--foreground); }
129
+ .optionMeta { overflow: hidden; color: var(--text-muted); font-size: var(--text-xs); text-overflow: ellipsis; white-space: nowrap; }
130
+ .count { flex: none; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
131
+
132
+ .empty { margin: 0; padding: 10px 10px; color: var(--text-secondary); font-size: var(--text-sm); }
133
+
134
+ .srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@@ -0,0 +1,8 @@
1
+ export interface MetricCardProps {
2
+ label: string;
3
+ value: number;
4
+ suffix?: string;
5
+ context: string;
6
+ change?: string;
7
+ }
8
+ export declare function MetricCard({ label, value, suffix, context, change }: MetricCardProps): import("react").JSX.Element;
@@ -0,0 +1,44 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
5
+ import { motionTokens } from "../../lib/motion-tokens.js";
6
+ import { AnimatedCounter } from "../animated-counter/animated-counter.js";
7
+ import styles from "./metric-card.module.css";
8
+ /** Copy that holds a number enters from the side it moved toward: a larger value rises from below, a smaller one drops from above. */
9
+ const rise = { hidden: (direction) => ({ opacity: 0, y: `${.3 * direction}em`, filter: `blur(${motionTokens.blur.soft}px)` }), shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }, gone: (direction) => ({ opacity: 0, y: `${-.3 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }) };
10
+ const fade = { hidden: { opacity: 0, y: 0, filter: "blur(0px)" }, shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.instant } } };
11
+ const amountIn = (text) => Number(text.replace(/,/g, "").match(/-?\d+(?:\.\d+)?/)?.[0] ?? NaN);
12
+ /** New copy rises in while the old copy leaves; `morph` springs the wrapper to the new text's width instead of letting it snap. */
13
+ function Swap({ text, morph = false, block = false }) {
14
+ const reduceMotion = !!useReducedMotion();
15
+ const sizer = useRef(null);
16
+ const width = useMotionValue("auto");
17
+ const [shown, setShown] = useState({ text, direction: 1 });
18
+ if (shown.text !== text)
19
+ setShown({ text, direction: amountIn(text) < amountIn(shown.text) ? -1 : 1 });
20
+ useEffect(() => {
21
+ const node = sizer.current;
22
+ if (!node || typeof ResizeObserver === "undefined")
23
+ return;
24
+ let measured = null;
25
+ // Layout size, not the transformed rect, so a scaling parent never leaves the text clipped. Only a new text springs; font loads jump.
26
+ const observer = new ResizeObserver(([entry]) => {
27
+ if (!entry)
28
+ return;
29
+ const next = entry.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth;
30
+ if (next && measured !== null && measured !== node.textContent && !reduceMotion)
31
+ animate(width, next, motionTokens.spring.morph);
32
+ else
33
+ width.jump(next || "auto");
34
+ measured = next ? node.textContent : null;
35
+ });
36
+ observer.observe(node);
37
+ return () => observer.disconnect();
38
+ }, [morph, reduceMotion, width]);
39
+ return _jsxs(motion.span, { className: block ? styles.swapBlock : styles.swap, style: morph ? { width } : undefined, children: [morph && _jsx("span", { ref: sizer, className: styles.sizer, "aria-hidden": "true", children: text }), _jsx(AnimatePresence, { mode: "popLayout", initial: false, custom: shown.direction, children: _jsx(motion.span, { className: styles.text, custom: shown.direction, variants: reduceMotion ? fade : rise, initial: "hidden", animate: "shown", exit: "gone", children: text }, text) })] });
40
+ }
41
+ export function MetricCard({ label, value, suffix, context, change }) {
42
+ const reduceMotion = !!useReducedMotion();
43
+ return _jsxs("article", { className: styles.card, children: [_jsxs("div", { className: styles.top, children: [_jsx("span", { children: _jsx(Swap, { text: label, block: true }) }), _jsx(AnimatePresence, { initial: false, children: change && _jsx(motion.small, { "data-trend": /^[+]/.test(change) ? "up" : /^[-−]/.test(change) ? "down" : undefined, initial: { opacity: 0, scale: reduceMotion ? 1 : .96 }, animate: { opacity: 1, scale: 1 }, exit: { opacity: 0, scale: reduceMotion ? 1 : .96, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }, transition: reduceMotion ? { duration: 0 } : motionTokens.spring.snappy, children: _jsx(Swap, { text: change, morph: true }) }, "change") })] }), _jsx(AnimatedCounter, { value: value, suffix: suffix, animateOnView: true }), _jsx("p", { children: _jsx(Swap, { text: context, block: true }) })] });
44
+ }
@@ -0,0 +1,18 @@
1
+ .card { min-width: 0; border: 1px solid var(--border); border-radius: var(--radius-overlay); corner-shape: var(--corner-shape); padding: var(--space-section); background: var(--surface); box-shadow: var(--shadow-resting); }
2
+ .top { display: flex; align-items: center; justify-content: space-between; gap: var(--space-group); margin-bottom: var(--space-block); }
3
+ .top span { color: var(--text-secondary); font-size: var(--text-sm); }
4
+ .top small { display: inline-flex; flex: 0 0 auto; border: 1px solid var(--border); border-radius: var(--radius-full); corner-shape: round; padding: 3px 8px; color: var(--text-secondary); font-size: var(--text-sm); font-variant-numeric: tabular-nums; }
5
+ .card p { margin: var(--space-inset) 0 0; color: var(--text-muted); font-size: var(--text-sm); line-height: var(--leading-body); }
6
+ @media (max-width: 380px) { .card { padding: var(--space-inset); } .top { align-items: flex-start; flex-wrap: wrap; margin-bottom: var(--space-section); } }
7
+ /* Text swaps: the outgoing copy is popped out of flow so the wrapper never holds both widths. */
8
+ .swap, .swapBlock { position: relative; max-width: 100%; }
9
+ .swap { display: inline-flex; overflow-x: clip; white-space: nowrap; vertical-align: top; }
10
+ .swapBlock { display: block; }
11
+ .swap > .text { display: inline-block; }
12
+ .swapBlock > .text { display: block; }
13
+ .sizer { position: absolute; top: 0; left: 0; visibility: hidden; white-space: nowrap; pointer-events: none; }
14
+ /* A signed change reads its direction in color as well as sign: a faint tint behind, the color on the text. */
15
+ .top small[data-trend] span { color: inherit; }
16
+ .top small[data-trend] { border-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
17
+ .top small[data-trend="up"] { background: color-mix(in oklab, var(--success) 11%, transparent); color: var(--success); }
18
+ .top small[data-trend="down"] { background: color-mix(in oklab, var(--danger) 11%, transparent); color: var(--danger); }