@sableui/react 0.13.2 → 0.15.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 (203) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +172 -0
  3. package/CONTRIBUTING.md +6 -4
  4. package/DESIGN.md +10 -3
  5. package/dist/blocks/approval-block.js +93 -0
  6. package/dist/blocks/approval-block.meta.js +23 -0
  7. package/dist/blocks/index.d.ts +27 -1
  8. package/dist/blocks/index.js +8 -0
  9. package/dist/blocks/message-reactions-block.js +87 -0
  10. package/dist/blocks/message-reactions-block.meta.js +23 -0
  11. package/dist/components/accordion/accordion.meta.js +8 -2
  12. package/dist/components/area-chart/area-chart.meta.js +10 -3
  13. package/dist/components/attachment/attachment.meta.js +26 -0
  14. package/dist/components/bar-chart/bar-chart.meta.js +11 -4
  15. package/dist/components/bottom-nav/bottom-nav.js +3 -1
  16. package/dist/components/bottom-nav/bottom-nav.meta.js +32 -0
  17. package/dist/components/breadcrumbs/breadcrumbs.meta.js +26 -0
  18. package/dist/components/bubble/bubble.js +105 -5
  19. package/dist/components/bubble/bubble.meta.js +42 -6
  20. package/dist/components/bubble/bubble.variants.js +1 -1
  21. package/dist/components/bubble/index.js +2 -0
  22. package/dist/components/button/button.js +21 -4
  23. package/dist/components/button/button.meta.js +7 -7
  24. package/dist/components/calendar/calendar.js +18 -1
  25. package/dist/components/calendar/calendar.meta.js +38 -0
  26. package/dist/components/carousel/carousel.js +6 -4
  27. package/dist/components/carousel/carousel.meta.js +42 -0
  28. package/dist/components/chat-composer/chat-composer.js +21 -21
  29. package/dist/components/chat-composer/chat-composer.meta.js +38 -0
  30. package/dist/components/chat-composer/use-eased-growth.js +70 -0
  31. package/dist/components/checkbox/checkbox-mark.js +8 -1
  32. package/dist/components/checkbox/checkbox.js +3 -1
  33. package/dist/components/checkbox/checkbox.meta.js +32 -0
  34. package/dist/components/checkbox-card/checkbox-card.js +3 -0
  35. package/dist/components/checkbox-card/checkbox-card.meta.js +32 -0
  36. package/dist/components/code-block/code-block.js +14 -3
  37. package/dist/components/code-block/code-block.meta.js +28 -1
  38. package/dist/components/collapsible/collapsible.meta.js +32 -0
  39. package/dist/components/color-field/color-field.meta.js +26 -0
  40. package/dist/components/combobox/combobox.meta.js +44 -0
  41. package/dist/components/command-menu/command-menu.js +8 -3
  42. package/dist/components/command-menu/command-menu.meta.js +38 -0
  43. package/dist/components/confirm-button/confirm-button.js +179 -0
  44. package/dist/components/confirm-button/confirm-button.meta.js +143 -0
  45. package/dist/components/confirm-button/index.js +7 -0
  46. package/dist/components/data-table/data-table.js +6 -2
  47. package/dist/components/data-table/data-table.meta.js +32 -0
  48. package/dist/components/entity/entity.meta.js +26 -0
  49. package/dist/components/feedback/feedback.js +120 -47
  50. package/dist/components/feedback/feedback.meta.js +55 -4
  51. package/dist/components/input-group/input-group.meta.js +27 -1
  52. package/dist/components/like-button/index.js +7 -0
  53. package/dist/components/like-button/like-button.js +89 -0
  54. package/dist/components/like-button/like-button.meta.js +132 -0
  55. package/dist/components/line-chart/line-chart.meta.js +10 -3
  56. package/dist/components/middle-truncate/middle-truncate.meta.js +1 -1
  57. package/dist/components/note/note.meta.js +32 -0
  58. package/dist/components/notification-badge/notification-badge.js +17 -5
  59. package/dist/components/notification-badge/notification-badge.meta.js +34 -2
  60. package/dist/components/number-ticker/index.js +7 -0
  61. package/dist/components/number-ticker/number-ticker.js +246 -0
  62. package/dist/components/number-ticker/number-ticker.meta.js +159 -0
  63. package/dist/components/otp-field/otp-field.js +23 -3
  64. package/dist/components/otp-field/otp-field.meta.js +23 -2
  65. package/dist/components/panel-group/panel-group.meta.js +26 -0
  66. package/dist/components/panel-section/panel-section.js +37 -10
  67. package/dist/components/panel-section/panel-section.meta.js +32 -0
  68. package/dist/components/pie-chart/pie-chart.js +20 -11
  69. package/dist/components/pie-chart/pie-chart.meta.js +17 -4
  70. package/dist/components/pill/pill.meta.js +32 -0
  71. package/dist/components/radar-chart/radar-chart.js +4 -3
  72. package/dist/components/radar-chart/radar-chart.meta.js +10 -3
  73. package/dist/components/radial-chart/radial-chart.js +22 -22
  74. package/dist/components/radial-chart/radial-chart.meta.js +17 -4
  75. package/dist/components/radio/radio.meta.js +26 -0
  76. package/dist/components/radio-card/radio-card.meta.js +38 -0
  77. package/dist/components/reaction-picker/index.js +13 -0
  78. package/dist/components/reaction-picker/reaction-picker.js +104 -0
  79. package/dist/components/reaction-picker/reaction-picker.meta.js +156 -0
  80. package/dist/components/relative-time/relative-time.meta.js +32 -0
  81. package/dist/components/scroller/scroller.meta.js +32 -0
  82. package/dist/components/scrub-field/scrub-field.meta.js +32 -0
  83. package/dist/components/search-input/search-input.js +9 -7
  84. package/dist/components/search-input/search-input.meta.js +26 -0
  85. package/dist/components/show-more/show-more.js +1 -4
  86. package/dist/components/show-more/show-more.meta.js +38 -0
  87. package/dist/components/side-nav/side-nav.js +21 -1
  88. package/dist/components/side-nav/side-nav.meta.js +32 -0
  89. package/dist/components/side-nav/side-nav.variants.js +3 -1
  90. package/dist/components/sidebar/sidebar.js +31 -8
  91. package/dist/components/sidebar/sidebar.meta.js +38 -0
  92. package/dist/components/slider/slider.meta.js +26 -0
  93. package/dist/components/sparkline/sparkline.js +7 -4
  94. package/dist/components/sparkline/sparkline.meta.js +9 -2
  95. package/dist/components/table/table.meta.js +26 -0
  96. package/dist/components/tabs/tabs.js +27 -8
  97. package/dist/components/tabs/tabs.meta.js +46 -3
  98. package/dist/components/theme-selector/theme-selector.meta.js +32 -0
  99. package/dist/components/theme-switcher/theme-switcher.js +4 -1
  100. package/dist/components/theme-switcher/theme-switcher.meta.js +42 -0
  101. package/dist/components/toast/toast.js +25 -13
  102. package/dist/components/toast/toast.meta.js +7 -1
  103. package/dist/components/toggle/toggle.js +28 -5
  104. package/dist/components/toggle/toggle.meta.js +44 -2
  105. package/dist/components/tool-call/index.js +8 -0
  106. package/dist/components/tool-call/tool-call.js +117 -0
  107. package/dist/components/tool-call/tool-call.meta.js +147 -0
  108. package/dist/components/tool-call/tool-call.variants.js +33 -0
  109. package/dist/components/tooltip/index.js +3 -1
  110. package/dist/components/tooltip/tooltip.js +84 -0
  111. package/dist/components/tooltip/tooltip.meta.js +16 -1
  112. package/dist/components/tree/tree.js +71 -6
  113. package/dist/components/tree/tree.meta.js +32 -0
  114. package/dist/components/video/video.meta.js +32 -0
  115. package/dist/file-hashes.json +130 -104
  116. package/dist/foundations/motion.js +69 -3
  117. package/dist/icon-sets/reicon.js +11 -1
  118. package/dist/index.d.ts +907 -104
  119. package/dist/index.js +6 -1
  120. package/dist/lib/chart/cartesian.js +9 -10
  121. package/dist/lib/chart/chart-center.js +30 -0
  122. package/dist/lib/chart/chart-frame.js +15 -8
  123. package/dist/lib/chart/dim.js +13 -0
  124. package/dist/lib/chart/format.js +7 -1
  125. package/dist/lib/chart/motion.js +6 -4
  126. package/dist/lib/highlight-glide.js +68 -10
  127. package/dist/lib/taxonomy.js +5 -0
  128. package/dist/motion/Burst.js +79 -0
  129. package/dist/motion/MotionProvider.js +45 -3
  130. package/dist/motion/Reveal.js +1 -1
  131. package/dist/motion/TextReveal.js +1 -1
  132. package/dist/motion/index.js +17 -2
  133. package/dist/motion/useAutoSize.js +42 -0
  134. package/dist/motion/useShake.js +25 -0
  135. package/dist/styles.css +439 -5
  136. package/llms/blocks/approval-block.md +128 -0
  137. package/llms/blocks/message-reactions-block.md +116 -0
  138. package/llms/components/accordion.md +3 -2
  139. package/llms/components/area-chart.md +4 -3
  140. package/llms/components/attachment.md +7 -0
  141. package/llms/components/bar-chart.md +5 -4
  142. package/llms/components/bottom-nav.md +8 -0
  143. package/llms/components/breadcrumbs.md +7 -0
  144. package/llms/components/bubble.md +17 -7
  145. package/llms/components/button.md +6 -6
  146. package/llms/components/calendar.md +9 -0
  147. package/llms/components/carousel.md +17 -0
  148. package/llms/components/chat-composer.md +9 -0
  149. package/llms/components/checkbox-card.md +8 -0
  150. package/llms/components/checkbox.md +8 -0
  151. package/llms/components/code-block.md +8 -0
  152. package/llms/components/collapsible.md +8 -0
  153. package/llms/components/color-field.md +7 -0
  154. package/llms/components/combobox.md +10 -0
  155. package/llms/components/command-menu.md +9 -0
  156. package/llms/components/confirm-button.md +92 -0
  157. package/llms/components/data-table.md +8 -0
  158. package/llms/components/entity.md +7 -0
  159. package/llms/components/feedback.md +23 -3
  160. package/llms/components/input-group.md +8 -1
  161. package/llms/components/like-button.md +91 -0
  162. package/llms/components/line-chart.md +4 -3
  163. package/llms/components/middle-truncate.md +1 -1
  164. package/llms/components/note.md +8 -0
  165. package/llms/components/notification-badge.md +10 -2
  166. package/llms/components/number-ticker.md +86 -0
  167. package/llms/components/otp-field.md +15 -1
  168. package/llms/components/panel-group.md +7 -0
  169. package/llms/components/panel-section.md +8 -0
  170. package/llms/components/pie-chart.md +6 -4
  171. package/llms/components/pill.md +8 -0
  172. package/llms/components/radar-chart.md +4 -3
  173. package/llms/components/radial-chart.md +6 -4
  174. package/llms/components/radio-card.md +9 -0
  175. package/llms/components/radio.md +7 -0
  176. package/llms/components/reaction-picker.md +115 -0
  177. package/llms/components/relative-time.md +8 -0
  178. package/llms/components/scroller.md +8 -0
  179. package/llms/components/scrub-field.md +8 -0
  180. package/llms/components/search-input.md +7 -0
  181. package/llms/components/show-more.md +9 -0
  182. package/llms/components/side-nav.md +8 -0
  183. package/llms/components/sidebar.md +9 -0
  184. package/llms/components/slider.md +7 -0
  185. package/llms/components/sparkline.md +3 -2
  186. package/llms/components/table.md +7 -0
  187. package/llms/components/tabs.md +20 -2
  188. package/llms/components/theme-selector.md +8 -0
  189. package/llms/components/theme-switcher.md +17 -0
  190. package/llms/components/toast.md +2 -1
  191. package/llms/components/toggle.md +19 -2
  192. package/llms/components/tool-call.md +115 -0
  193. package/llms/components/tooltip.md +11 -1
  194. package/llms/components/tree.md +8 -0
  195. package/llms/components/video.md +8 -0
  196. package/llms/foundations/motion.md +64 -3
  197. package/llms/reference/utilities.md +26 -1
  198. package/llms/tasks/reviewing-motion.md +14 -0
  199. package/llms-full.txt +1337 -69
  200. package/llms.txt +19 -11
  201. package/package.json +1 -1
  202. package/registry.json +3040 -124
  203. package/targets/ios.json +45 -0
@@ -122,6 +122,12 @@ var accordionMeta = {
122
122
  part: "AccordionPanel",
123
123
  why: "Panel height is the only cue that content came from THIS header rather than appearing at the bottom of the page. Snapping it open moves everything below by a screenful with nothing connecting cause to effect."
124
124
  },
125
+ {
126
+ slot: "transition",
127
+ recipe: "motion-interactive",
128
+ part: "Chevron",
129
+ why: "The chevron turning is the header's half of the same event as the panel opening. motion-interactive lists `rotate` (what Tailwind's rotate-* writes), so it turns with the panel instead of flipping 180\xB0 in one frame beside an easing panel. A custom `indicator` that rotates should carry motion-interactive itself."
130
+ },
125
131
  {
126
132
  slot: "activity",
127
133
  recipe: "none",
@@ -134,7 +140,7 @@ var accordionMeta = {
134
140
  "Arrow keys move roving focus between triggers; Home/End jump to the first/last; loopFocus wraps by default.",
135
141
  "Each trigger toggles its panel with Enter/Space; the panel is announced as a labelled region.",
136
142
  "A disabled AccordionItem dims and is skipped by keyboard focus while neighbouring items stay interactive.",
137
- "The chevron rotation is driven by data-[panel-open] and the focus ring by focus-visible \u2014 both honor reduced-motion via the consumer's transitions."
143
+ "The chevron rotation is driven by data-[panel-open] and the focus ring by focus-visible; both ride motion-interactive, so reduced motion stills them with everything else."
138
144
  ],
139
145
  examples: [
140
146
  {
@@ -147,7 +153,7 @@ var accordionMeta = {
147
153
  },
148
154
  {
149
155
  label: "Custom indicator (plus-becomes-\xD7)",
150
- code: '<Accordion defaultValue={["a"]}><AccordionItem value="a"><AccordionTrigger indicator={<Icon name="plus" className="text-xs text-muted-foreground transition-transform [[data-panel-open]_&]:rotate-45" />}>Can I cancel anytime?</AccordionTrigger><AccordionPanel>Yes \u2014 from billing settings.</AccordionPanel></AccordionItem></Accordion>'
156
+ code: '<Accordion defaultValue={["a"]}><AccordionItem value="a"><AccordionTrigger indicator={<Icon name="plus" className="text-xs text-muted-foreground motion-interactive [[data-panel-open]_&]:rotate-45" />}>Can I cancel anytime?</AccordionTrigger><AccordionPanel>Yes \u2014 from billing settings.</AccordionPanel></AccordionItem></Accordion>'
151
157
  }
152
158
  ]
153
159
  };
@@ -117,11 +117,18 @@ var areaChartMeta = {
117
117
  default: "false for columns; true for horizontal bars",
118
118
  description: "The vertical axis."
119
119
  },
120
+ {
121
+ name: "motion",
122
+ type: '"full" | "subtle" | "off"',
123
+ default: '"full"',
124
+ description: "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down \u2014 it never overrides a reader's reduce-motion setting. The arrival (draw-on, rising fill) and the data-change morph are the flourish; the gliding cursor and tooltip are the feedback `subtle` keeps."
125
+ },
120
126
  {
121
127
  name: "animate",
122
128
  type: "boolean",
123
129
  default: "true",
124
- description: "Play the arrival once the chart scrolls into view. Reduced motion shows the finished chart."
130
+ description: 'Play the arrival once the chart scrolls into view. Reduced motion shows the finished chart. `animate={false}` is `motion="subtle"`.',
131
+ deprecated: 'motion="subtle" (what animate={false} now maps to: no arrival, hover kept) or motion="off"'
125
132
  },
126
133
  {
127
134
  name: "activeIndex",
@@ -176,7 +183,7 @@ var areaChartMeta = {
176
183
  slot: "arrival",
177
184
  recipe: "custom",
178
185
  part: "Area + line",
179
- why: "Once on screen, each line draws on (stroke-dashoffset) and its fill rises from the baseline, series staggered \u2014 the trend is read as it is drawn. On the motion tokens; reduced motion shows the finished chart."
186
+ why: 'Once on screen, each line draws on (stroke-dashoffset) and its fill rises from the baseline, series staggered \u2014 the trend is read as it is drawn. On the motion tokens; reduced motion shows the finished chart. The arrival is expressive: it plays at the full motion level only, so `motion="subtle"` (or a subtle app policy) shows the finished chart while hover feedback stays.'
180
187
  },
181
188
  {
182
189
  slot: "response",
@@ -188,7 +195,7 @@ var areaChartMeta = {
188
195
  slot: "transition",
189
196
  recipe: "custom",
190
197
  part: "Area + line",
191
- why: "Geometry eases from the old shape to the new one through a CSS `d` transition on the motion tokens (no recipe animates path geometry); browsers without `d` transitions update in place."
198
+ why: "Geometry eases from the old shape to the new one through a CSS `d` transition on the motion tokens (no recipe animates path geometry); browsers without `d` transitions update in place. Expressive, like the arrival: under `subtle` new data swaps in place."
192
199
  },
193
200
  {
194
201
  slot: "activity",
@@ -148,6 +148,32 @@ var attachmentMeta = {
148
148
  "overlay-foreground",
149
149
  "ring"
150
150
  ],
151
+ motion: [
152
+ {
153
+ slot: "arrival",
154
+ recipe: "none",
155
+ part: "Root",
156
+ why: "An attachment lands as part of a composer or a message, and that surface owns the entrance. A card that animated in on its own would read as a second, separate event \u2014 as if another file had been added."
157
+ },
158
+ {
159
+ slot: "response",
160
+ recipe: "motion-interactive",
161
+ part: "Root",
162
+ why: "With `onOpen`, the edge steps up under the pointer; it is the only cue that the card itself opens the file, not just its actions. Eased, it reads as the card answering the cursor rather than a border flickering as the pointer crosses a busy composer."
163
+ },
164
+ {
165
+ slot: "transition",
166
+ recipe: "motion-interactive",
167
+ part: "Root",
168
+ why: "Uploading to failed re-colours the edge and fill without changing the shape. The ease keeps it legible as the same file turning red; snapped, a failed card looks like a different card replaced the one that was sent."
169
+ },
170
+ {
171
+ slot: "activity",
172
+ recipe: "motion-pulse",
173
+ part: "Progress",
174
+ why: "While uploading with no known `progress`, the bar is indeterminate and pulses (Progress's own recipe); with a number, the fill eases toward each new value. A still bar at a fixed width is indistinguishable from an upload that has stalled."
175
+ }
176
+ ],
151
177
  a11y: [
152
178
  "The full-card trigger is a real stretched `<button>` with an accessible label, not a click handler on a div \u2014 so it is one tab stop, keyboard-activatable, and focus-ringed.",
153
179
  "Actions sit above that trigger in the stacking order, which keeps them individually focusable and stops a click on `remove` from also opening the file. Text is only made click-through when a trigger is present, so a filename stays selectable otherwise.",
@@ -117,11 +117,18 @@ var barChartMeta = {
117
117
  default: "false for columns; true for horizontal bars",
118
118
  description: "The vertical axis."
119
119
  },
120
+ {
121
+ name: "motion",
122
+ type: '"full" | "subtle" | "off"',
123
+ default: '"full"',
124
+ description: "How much motion this component uses: `full` (default) inherits the app's policy, `subtle` keeps feedback and drops the flourish, `off` stills it. It can only turn motion down \u2014 it never overrides a reader's reduce-motion setting. The arrival (bars rising in sequence) and the data-change morph are the flourish; the hover dim and gliding cursor are the feedback `subtle` keeps."
125
+ },
120
126
  {
121
127
  name: "animate",
122
128
  type: "boolean",
123
129
  default: "true",
124
- description: "Play the arrival once the chart scrolls into view. Reduced motion shows the finished chart."
130
+ description: 'Play the arrival once the chart scrolls into view. Reduced motion shows the finished chart. `animate={false}` is `motion="subtle"`.',
131
+ deprecated: 'motion="subtle" (what animate={false} now maps to: no arrival, hover kept) or motion="off"'
125
132
  },
126
133
  {
127
134
  name: "activeIndex",
@@ -199,19 +206,19 @@ var barChartMeta = {
199
206
  slot: "arrival",
200
207
  recipe: "custom",
201
208
  part: "Bar",
202
- why: "Once on screen, bars rise from the baseline in sequence on the emphasized easing (scale from the zero line) and their labels fade in. On the motion tokens; reduced motion shows the finished chart."
209
+ why: 'Once on screen, bars rise from the baseline in sequence on the emphasized easing (scale from the zero line) and their labels fade in. On the motion tokens; reduced motion shows the finished chart. The arrival is expressive: it plays at the full motion level only, so `motion="subtle"` (or a subtle app policy) shows the finished chart while hover feedback stays.'
203
210
  },
204
211
  {
205
212
  slot: "response",
206
213
  recipe: "none",
207
214
  part: "Cursor + tooltip",
208
- why: "The hovered category gets a band behind it and the other bars dim at once \u2014 a lagging highlight reads as a slow chart."
215
+ why: "The hovered category gets a band behind it and the other bars dim at once \u2014 a lagging highlight reads as a slow chart. The dim mixes each bar toward the surface instead of fading it, so gridlines never strike through a dimmed column and stacked segments stay solid. Essential: `subtle` keeps it."
209
216
  },
210
217
  {
211
218
  slot: "transition",
212
219
  recipe: "custom",
213
220
  part: "Bar",
214
- why: "Geometry eases from the old shape to the new one through a CSS `d` transition on the motion tokens (no recipe animates path geometry); browsers without `d` transitions update in place."
221
+ why: "Geometry eases from the old shape to the new one through a CSS `d` transition on the motion tokens (no recipe animates path geometry); browsers without `d` transitions update in place. Expressive, like the arrival: under `subtle` new data swaps in place."
215
222
  },
216
223
  {
217
224
  slot: "activity",
@@ -38,8 +38,10 @@ function BottomNavItem({
38
38
  "data-active": active ? "" : void 0,
39
39
  "aria-current": active ? "page" : void 0,
40
40
  className: cn(
41
+ // motion-press: on a phone there is no hover to say the tap landed, and
42
+ // a route change can take a beat to paint — the settle is the receipt.
41
43
  // design-lint-disable-next-line raw-control-height -- the WCAG 2.5.5 touch-target floor, deliberately density-independent: a compact register still owes a 44px hit area.
42
- "relative flex min-h-[44px] min-w-[44px] flex-col items-center justify-center gap-0.5 rounded-lg text-muted-foreground motion-interactive hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[active]:text-foreground",
44
+ "relative flex min-h-[44px] min-w-[44px] flex-col items-center justify-center gap-0.5 rounded-lg text-muted-foreground motion-interactive motion-press hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[active]:text-foreground",
43
45
  className
44
46
  ),
45
47
  children: /* @__PURE__ */ jsxs(Fragment, { children: [
@@ -57,6 +57,38 @@ var bottomNavMeta = {
57
57
  description: 'A single tab, placed as a child of BottomNav. `label` is the accessible name (aria-label) and optional visible label; `icon` is the glyph node (consumer swaps fill weight for the active state); `active` marks the current route (active ink + aria-current); `badge` overlays a corner dot/count on the icon; `showLabel` shows the label text beneath the icon \u2014 defaults to false (icon-only); turn it on when the icons alone are not self-evident; `render` renders as another element, e.g. render={<Link to="/x" />}.'
58
58
  }
59
59
  ],
60
+ motion: [
61
+ {
62
+ slot: "arrival",
63
+ recipe: "none",
64
+ part: "Root",
65
+ why: "Fixed app chrome, present from the first paint of every screen. Animating it in on navigation would make each route change look like the app restarting."
66
+ },
67
+ {
68
+ slot: "response",
69
+ recipe: "motion-interactive",
70
+ part: "BottomNavItem",
71
+ why: "The ink steps up on hover and the focus ring eases in, so a pointer or a keyboard on a tablet gets the same answer as every other control in the app."
72
+ },
73
+ {
74
+ slot: "response",
75
+ recipe: "motion-press",
76
+ part: "BottomNavItem",
77
+ why: "On a phone there is no hover to say a tap landed, and a route can take a beat to paint. The settle on press is the receipt; without it people tap the tab a second time."
78
+ },
79
+ {
80
+ slot: "transition",
81
+ recipe: "motion-interactive",
82
+ part: "BottomNavItem",
83
+ why: "Changing tab eases the active ink out of one item and into the next. It is a cross-fade, not a travelling indicator: each item owns its colour, so there is no single mark to glide."
84
+ },
85
+ {
86
+ slot: "activity",
87
+ recipe: "none",
88
+ part: "Root",
89
+ why: "Navigation has no working state of its own. A slow route shows its pending state in the page it is loading, not in the tab bar."
90
+ }
91
+ ],
60
92
  a11y: [
61
93
  "BottomNav renders a <nav> landmark.",
62
94
  "Each tab is a 44px minimum touch target with an aria-label.",
@@ -89,6 +89,32 @@ var breadcrumbsMeta = {
89
89
  }
90
90
  ],
91
91
  tokensUsed: ["muted-foreground", "foreground"],
92
+ motion: [
93
+ {
94
+ slot: "arrival",
95
+ recipe: "none",
96
+ part: "Root",
97
+ why: "The trail is page furniture, rendered with the page header. An entrance would replay on every navigation, which is exactly when the reader looks at it to get their bearings."
98
+ },
99
+ {
100
+ slot: "response",
101
+ recipe: "motion-interactive",
102
+ part: "BreadcrumbLink",
103
+ why: "Ancestors are muted until hovered or focused. Easing the ink makes the trail read as links answering the pointer; a hard snap across a row of small text looks like flicker."
104
+ },
105
+ {
106
+ slot: "transition",
107
+ recipe: "none",
108
+ part: "Root",
109
+ why: "A breadcrumb has no state of its own. The current page changes by navigation, which replaces the trail; animating between two trails would imply the path itself moved."
110
+ },
111
+ {
112
+ slot: "activity",
113
+ recipe: "none",
114
+ part: "Root",
115
+ why: "It never works in the background. A route that is still loading says so in the page, not in its address."
116
+ }
117
+ ],
92
118
  a11y: [
93
119
  'The <nav> carries aria-label="Breadcrumb" so assistive tech announces the landmark.',
94
120
  "Crumbs sit in an ordered list (<ol>) to convey hierarchy and position.",
@@ -1,7 +1,22 @@
1
+ "use client";
2
+
1
3
  // src/components/bubble/bubble.tsx
4
+ import {
5
+ Children,
6
+ createContext,
7
+ useContext,
8
+ useEffect,
9
+ useState
10
+ } from "react";
11
+ import { Icon } from "../../icon/index.js";
2
12
  import { cn } from "../../lib/cn.js";
3
13
  import { useTypeTier } from "../../lib/density.js";
14
+ import { Burst } from "../../motion/Burst.js";
15
+ import { motionAttribute } from "../../motion/MotionProvider.js";
16
+ import { NumberTicker } from "../number-ticker/number-ticker.js";
4
17
  import { Pill } from "../pill/pill.js";
18
+ import { Popover, PopoverContent, PopoverTrigger } from "../popover/popover.js";
19
+ import { DEFAULT_REACTIONS, ReactionPicker } from "../reaction-picker/reaction-picker.js";
5
20
  import {
6
21
  bubbleTailVariants,
7
22
  bubbleReactionsVariants,
@@ -60,28 +75,46 @@ function BubbleTail({ variant, align }) {
60
75
  );
61
76
  }
62
77
  Bubble.displayName = "Bubble";
78
+ var ReactionRowLive = createContext(false);
63
79
  function BubbleReactions({
64
80
  align = "start",
65
81
  className,
82
+ children,
66
83
  ...props
67
84
  }) {
68
- return /* @__PURE__ */ jsx(
85
+ const [live, setLive] = useState(false);
86
+ useEffect(() => setLive(true), []);
87
+ return /* @__PURE__ */ jsx(ReactionRowLive.Provider, { value: live, children: /* @__PURE__ */ jsx(
69
88
  "div",
70
89
  {
90
+ hidden: Children.toArray(children).length === 0 || void 0,
71
91
  className: cn(bubbleReactionsVariants({ align }), className),
72
- ...props
92
+ ...props,
93
+ children
73
94
  }
74
- );
95
+ ) });
75
96
  }
76
97
  BubbleReactions.displayName = "BubbleReactions";
77
98
  function BubbleReaction({
78
99
  emoji,
100
+ label,
79
101
  count,
80
102
  active = false,
103
+ motion,
81
104
  className,
82
105
  ...props
83
106
  }) {
84
107
  const tier = useTypeTier();
108
+ const live = useContext(ReactionRowLive);
109
+ const [arrived] = useState(() => live && active);
110
+ const [shots, setShots] = useState(arrived ? 1 : 0);
111
+ const [wasActive, setWasActive] = useState(active);
112
+ if (active !== wasActive) {
113
+ setWasActive(active);
114
+ if (active) setShots((n) => n + 1);
115
+ }
116
+ const shown = count !== void 0 && count > 1 ? count : void 0;
117
+ const named = label === void 0 ? void 0 : shown === void 0 ? label : `${label}, ${shown}`;
85
118
  return (
86
119
  // Pill's own quiet chip: outline (a hairline at rest, a primary tint when
87
120
  // it is the reader's) at the xs control height. No overrides — a reaction
@@ -92,19 +125,86 @@ function BubbleReaction({
92
125
  appearance: "outline",
93
126
  size: "sm",
94
127
  selected: active,
128
+ "aria-label": named,
129
+ "data-motion": motionAttribute(motion),
95
130
  className: cn(tier.secondary, className),
96
131
  ...props,
97
132
  children: [
98
- /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: emoji }),
99
- count !== void 0 && count > 1 ? /* @__PURE__ */ jsx("span", { className: "text-mono-caption tabular-nums", children: count }) : null
133
+ /* @__PURE__ */ jsxs("span", { className: "relative inline-flex", children: [
134
+ /* @__PURE__ */ jsx(
135
+ "span",
136
+ {
137
+ "aria-hidden": "true",
138
+ style: { "--motion-bloom-delay": "60ms" },
139
+ className: cn("inline-flex text-body-lg leading-none", active && shots > 0 && "motion-bloom"),
140
+ children: emoji
141
+ },
142
+ shots
143
+ ),
144
+ /* @__PURE__ */ jsx(
145
+ Burst,
146
+ {
147
+ trigger: shots,
148
+ playOnMount: arrived,
149
+ particle: emoji,
150
+ count: 5,
151
+ motion,
152
+ className: "[--burst-size:4em]"
153
+ }
154
+ )
155
+ ] }),
156
+ shown === void 0 ? null : /* @__PURE__ */ jsx(NumberTicker, { value: shown, motion, className: "text-mono-caption tabular-nums" })
100
157
  ]
101
158
  }
102
159
  )
103
160
  );
104
161
  }
105
162
  BubbleReaction.displayName = "BubbleReaction";
163
+ function BubbleReactionAdd({
164
+ reactions = DEFAULT_REACTIONS,
165
+ selected,
166
+ onSelect,
167
+ label = "Add reaction",
168
+ motion,
169
+ className,
170
+ ...props
171
+ }) {
172
+ const [open, setOpen] = useState(false);
173
+ return /* @__PURE__ */ jsxs(Popover, { open, onOpenChange: setOpen, children: [
174
+ /* @__PURE__ */ jsx(
175
+ PopoverTrigger,
176
+ {
177
+ render: /* @__PURE__ */ jsx(
178
+ Pill,
179
+ {
180
+ appearance: "outline",
181
+ size: "sm",
182
+ "aria-label": label,
183
+ className: cn("text-muted-foreground", className),
184
+ ...props
185
+ }
186
+ ),
187
+ children: /* @__PURE__ */ jsx(Icon, { name: "emoji", className: "size-4" })
188
+ }
189
+ ),
190
+ /* @__PURE__ */ jsx(PopoverContent, { side: "top", align: "start", sideOffset: 6, className: "w-auto rounded-full p-1", children: /* @__PURE__ */ jsx(
191
+ ReactionPicker,
192
+ {
193
+ reactions,
194
+ selected,
195
+ motion,
196
+ onSelect: (emoji) => {
197
+ setOpen(false);
198
+ onSelect?.(emoji);
199
+ }
200
+ }
201
+ ) })
202
+ ] });
203
+ }
204
+ BubbleReactionAdd.displayName = "BubbleReactionAdd";
106
205
  export {
107
206
  Bubble,
108
207
  BubbleReaction,
208
+ BubbleReactionAdd,
109
209
  BubbleReactions
110
210
  };
@@ -66,12 +66,17 @@ var bubbleMeta = {
66
66
  {
67
67
  name: "BubbleReaction",
68
68
  required: false,
69
- description: "One reaction pill \u2014 emoji, count (hidden at 1), and pressed state."
69
+ description: "One reaction pill \u2014 emoji, count (hidden at 1), and pressed state. Celebrates when it becomes the reader's own."
70
+ },
71
+ {
72
+ name: "BubbleReactionAdd",
73
+ required: false,
74
+ description: "The add-reaction pill at the end of the row: opens a ReactionPicker above it."
70
75
  }
71
76
  ]
72
77
  },
73
78
  related: {
74
- components: ["Message", "MessageScroller", "Marker", "Attachment"]
79
+ components: ["Message", "MessageScroller", "Marker", "Attachment", "ReactionPicker"]
75
80
  },
76
81
  props: [
77
82
  {
@@ -106,8 +111,13 @@ var bubbleMeta = {
106
111
  },
107
112
  {
108
113
  name: "BubbleReaction",
109
- type: '{ emoji: ReactNode; count?: number; active?: boolean } & ComponentPropsWithRef<"button">',
110
- description: 'One reaction pill. A toggle, not a label: `active` drives `aria-pressed` as well as the styling, and `count` is hidden at 1 because the pill already says "one".'
114
+ type: '{ emoji: ReactNode; label?: string; count?: number; active?: boolean; motion?: MotionLevel } & ComponentPropsWithRef<"button">',
115
+ description: 'One reaction pill. A toggle, not a label: `active` drives `aria-pressed` as well as the styling, `label` names it ("Party, 3"), and `count` is hidden at 1 because the pill already says "one". When it turns on \u2014 pressed, or newly added already on \u2014 the emoji blooms, copies of it burst out and the count rolls; pills the page loads with stay still.'
116
+ },
117
+ {
118
+ name: "BubbleReactionAdd",
119
+ type: '{ reactions?: Reaction[]; selected?: string[]; onSelect?: (emoji: string) => void; label?: string; motion?: MotionLevel } & ComponentPropsWithRef<"button">',
120
+ description: "The add-reaction pill: a smiley that opens a ReactionPicker above it. Choosing an emoji closes the picker and calls `onSelect`; add the emoji to the row and its new pill celebrates on arrival."
111
121
  }
112
122
  ],
113
123
  variants: {
@@ -128,9 +138,35 @@ var bubbleMeta = {
128
138
  "muted-foreground",
129
139
  "ring"
130
140
  ],
141
+ motion: [
142
+ {
143
+ slot: "arrival",
144
+ recipe: "none",
145
+ part: "Bubble",
146
+ why: "A bubble arrives with its Message row, which owns how a new message enters the transcript. A reaction pill that the reader just added arrives with its own celebration (below)."
147
+ },
148
+ {
149
+ slot: "response",
150
+ recipe: "motion-bloom",
151
+ part: "BubbleReaction",
152
+ why: "Reacting is the reader's choice, so it is answered like a like: the emoji grows from nothing past its size and settles, while copies of it spray outward and fade (Burst with the emoji as its particle, the way chat apps answer a reaction). Plays when a pill turns on and when a pill mounts already on into a row that has rendered \u2014 never for the pills a page loads with. Pills inherit Pill's motion-interactive and motion-press."
153
+ },
154
+ {
155
+ slot: "transition",
156
+ recipe: "custom",
157
+ part: "BubbleReaction",
158
+ why: "The count rolls with NumberTicker as people react. Turning a reaction off is quiet: the tint eases out and the count rolls back, with no burst. Under `subtle` or reduced motion the bloom and the spray are dropped and the tint and count alone answer."
159
+ },
160
+ {
161
+ slot: "activity",
162
+ recipe: "none",
163
+ part: "Bubble",
164
+ why: "A bubble is static content; a message still being written is StreamingText or a Marker, not a state of the surface."
165
+ }
166
+ ],
131
167
  a11y: [
132
168
  "The surface is a plain container with no imposed role \u2014 a bubble is presentation, and announcing one as a region would put a landmark around every line of a conversation.",
133
- "Reaction pills are real buttons reporting `aria-pressed`, so the reader's own reaction is state rather than colour alone; the emoji is hidden from AT and the count is left readable.",
169
+ 'Reaction pills are real buttons reporting `aria-pressed`, so the reader\'s own reaction is state rather than colour alone. Give each a `label`: it is named "Party, 3", with the emoji and its burst hidden from AT. The add-reaction pill is named "Add reaction" and opens a toolbar picker.',
134
170
  "`whitespace-pre-wrap` and `break-words` keep pasted logs, long URLs, and hard-wrapped text inside the surface instead of overflowing the conversation.",
135
171
  "Every variant meets AA against its own surface, including `primary`, which pairs the primary fill with its paired foreground token rather than a hardcoded white."
136
172
  ],
@@ -145,7 +181,7 @@ var bubbleMeta = {
145
181
  },
146
182
  {
147
183
  label: "With reactions",
148
- code: '<Message messageId="m3">\n <Bubble>Shipped 0.45.0.</Bubble>\n <BubbleReactions>\n <BubbleReaction emoji="\u{1F389}" count={3} active onClick={() => toggle("\u{1F389}")} />\n <BubbleReaction emoji="\u{1F440}" count={1} onClick={() => toggle("\u{1F440}")} />\n </BubbleReactions>\n</Message>'
184
+ code: '<Message messageId="m3">\n <Bubble>Shipped 0.45.0.</Bubble>\n <BubbleReactions>\n <BubbleReaction emoji="\u{1F389}" label="Party" count={3} active onClick={() => toggle("\u{1F389}")} />\n <BubbleReaction emoji="\u{1F440}" label="Eyes" count={1} onClick={() => toggle("\u{1F440}")} />\n <BubbleReactionAdd selected={mine} onSelect={toggle} />\n </BubbleReactions>\n</Message>'
149
185
  },
150
186
  {
151
187
  label: "A grouped run drops the repeated tail",
@@ -28,7 +28,7 @@ var bubbleTailVariants = cva(
28
28
  }
29
29
  );
30
30
  var bubbleVariants = cva(
31
- "relative w-fit max-w-[42rem] whitespace-pre-wrap break-words rounded-lg px-3.5 py-2.5 text-body-md",
31
+ "relative w-fit max-w-[42rem] whitespace-pre-wrap break-words rounded-lg px-3.5 py-2.5 text-body-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
32
32
  {
33
33
  variants: {
34
34
  variant: {
@@ -2,12 +2,14 @@
2
2
  import {
3
3
  Bubble,
4
4
  BubbleReaction,
5
+ BubbleReactionAdd,
5
6
  BubbleReactions
6
7
  } from "./bubble.js";
7
8
  import { bubbleMeta } from "./bubble.meta.js";
8
9
  export {
9
10
  Bubble,
10
11
  BubbleReaction,
12
+ BubbleReactionAdd,
11
13
  BubbleReactions,
12
14
  bubbleMeta
13
15
  };
@@ -21,6 +21,7 @@ var BLEED_CLASS = {
21
21
  end: "-me-(--button-pe,var(--button-px))",
22
22
  both: "-ms-(--button-ps,var(--button-px)) -me-(--button-pe,var(--button-px))"
23
23
  };
24
+ var LABEL_FADE = "[&>.sable-label-trim]:transition-opacity [&>.sable-label-trim]:duration-(--motion-duration-fast) [&>.sable-label-trim]:ease-standard";
24
25
  function Button({
25
26
  variant,
26
27
  size,
@@ -71,12 +72,19 @@ function Button({
71
72
  const stacked = useContext(StackedActionsContext) && !String(size ?? "md").startsWith("icon");
72
73
  const resolvedSize = stacked ? "cta" : size ?? "md";
73
74
  const hasLabel = children != null && children !== false;
74
- const spinner = /* @__PURE__ */ jsx(Spinner, { className: "size-(--button-icon)", thickness: 2 });
75
- const start = busy ? spinner : iconStart;
76
75
  const iconOnly = String(resolvedSize).startsWith("icon");
76
+ const overlay = busy && !iconOnly && hasLabel && iconStart == null && loadingLabel == null && badge == null;
77
+ const spinner = /* @__PURE__ */ jsx(
78
+ Spinner,
79
+ {
80
+ className: cn("size-(--button-icon) motion-appear", overlay && "absolute inset-0 m-auto"),
81
+ thickness: 2
82
+ }
83
+ );
84
+ const start = busy ? spinner : iconStart;
77
85
  const inner = busy && iconOnly ? spinner : /* @__PURE__ */ jsxs(Fragment, { children: [
78
86
  start,
79
- trimLabel(busy ? loadingLabel ?? children : children),
87
+ trimLabel(busy && !overlay ? loadingLabel ?? children : children),
80
88
  iconEnd
81
89
  ] });
82
90
  const content = badge != null ? /* @__PURE__ */ jsxs("span", { "data-slot": "button-badge-anchor", className: "relative inline-flex items-center gap-(--control-gap)", children: [
@@ -84,7 +92,9 @@ function Button({
84
92
  /* @__PURE__ */ jsx("span", { className: "pointer-events-none absolute -end-1 -top-1 flex", children: badge })
85
93
  ] }) : inner;
86
94
  const optical = cn(
87
- start && hasLabel && !iconOnly ? OPTICAL_START : null,
95
+ // An overlaid spinner is not a leading glyph: trimming the padding for it
96
+ // changed the width by 2px, the exact shift the overlay exists to avoid.
97
+ start && hasLabel && !iconOnly && !overlay ? OPTICAL_START : null,
88
98
  iconEnd && hasLabel && !iconOnly ? OPTICAL_END : null
89
99
  );
90
100
  const bleedable = BORDERLESS.has(variant ?? "primary") && !stacked;
@@ -104,6 +114,13 @@ function Button({
104
114
  // double-fire, but fading it to 50% made a working button read as an
105
115
  // unavailable one. It keeps full colour; the spinner says "working".
106
116
  busy ? "cursor-progress disabled:opacity-100 aria-disabled:opacity-100" : null,
117
+ // The label hands over to the spinner laid on top of it (see
118
+ // `overlay`). Its fade is declared at rest too, so the label also
119
+ // fades BACK when the work settles instead of snapping in. Only the
120
+ // trimLabel span is targeted: a caller's icon may carry a transition
121
+ // of its own (a chevron's rotate) that a child selector would clobber.
122
+ LABEL_FADE,
123
+ overlay ? "relative [&>:not([role=status])]:opacity-0" : null,
107
124
  className
108
125
  ),
109
126
  children: content,
@@ -33,7 +33,7 @@ var buttonMeta = {
33
33
  },
34
34
  {
35
35
  guidance: "do",
36
- description: "For a copy control, pair an icon Button with `useCopy(text, { timeout? })`. It takes the text when the hook is called and returns `{ copy, copied }`; `copy()` takes no argument, so pass the value the button copies to the hook, not to `copy`. Text that costs something to produce (a file to fetch) goes in as a function, `useCopy(() => fetch(url).then((r) => r.text()))`, called only on click."
36
+ description: 'For a copy control, pair an icon Button with `useCopy(text, { timeout? })`. It takes the text when the hook is called and returns `{ copy, copied }`; `copy()` takes no argument, so pass the value the button copies to the hook, not to `copy`. Text that costs something to produce (a file to fetch) goes in as a function, `useCopy(() => fetch(url).then((r) => r.text()))`, called only on click. Keep both glyphs mounted in a `motion-icon-swap` cell so the copy mark visibly becomes a check (a `copied ? "check" : "copy"` swap snaps between frames and is easy to miss), and say "Copied" in a polite `role="status"` region: the glyph change is silent to a screen reader.'
37
37
  },
38
38
  {
39
39
  guidance: "do",
@@ -126,7 +126,7 @@ var buttonMeta = {
126
126
  name: "loading",
127
127
  type: "boolean",
128
128
  default: "false",
129
- description: "Show a leading Spinner while an action is in flight and refuse presses. Sets aria-busy and aria-disabled, never the native disabled: disabling the focused button sent focus to <body>, so a sheet the action opened had nowhere to return it."
129
+ description: "Show a Spinner while an action is in flight and refuse presses. The spinner takes the iconStart slot when there is one; otherwise it lays over the label, which fades out in place, so the button keeps its width. Sets aria-busy and aria-disabled, never the native disabled: disabling the focused button sent focus to <body>, so a sheet the action opened had nowhere to return it."
130
130
  },
131
131
  {
132
132
  name: "onClick",
@@ -141,7 +141,7 @@ var buttonMeta = {
141
141
  {
142
142
  name: "loadingLabel",
143
143
  type: "ReactNode",
144
- description: "Optional text shown in place of children while loading."
144
+ description: "Optional text shown in place of children while loading, with the spinner beside it. The width follows the new text, so prefer no loadingLabel where the row must not move."
145
145
  },
146
146
  {
147
147
  name: "iconStart",
@@ -211,9 +211,9 @@ var buttonMeta = {
211
211
  },
212
212
  {
213
213
  slot: "transition",
214
- recipe: "none",
215
- part: "Root",
216
- why: "A button has no state of its own to move between. A control that toggles is a Switch or a Toggle, and those carry the transition."
214
+ recipe: "motion-appear",
215
+ part: "Spinner",
216
+ why: "Going busy is the one state change a button has. The spinner fades in where it mounts \u2014 in the icon slot, or laid over a plain label that fades out beneath it and keeps its place \u2014 so the width never changes and the row does not shift under the pointer that just pressed it. The label fades back when the work settles. A toggling control is a Switch or a Toggle; those carry their own transition."
217
217
  },
218
218
  {
219
219
  slot: "activity",
@@ -247,7 +247,7 @@ var buttonMeta = {
247
247
  },
248
248
  {
249
249
  label: "Copy to clipboard",
250
- code: 'const { copy, copied } = useCopy(address)\n<Button variant="ghost" size="icon-sm" aria-label="Copy address" onClick={copy}><Icon name={copied ? "check" : "copy"} /></Button>'
250
+ code: 'const { copy, copied } = useCopy(address)\n<Row gap={1}><Button variant="ghost" size="icon-sm" aria-label="Copy address" onClick={copy}><span aria-hidden="true" className="motion-icon-swap"><span data-active={!copied} className="inline-flex"><Icon name="copy" /></span><span data-active={copied} className="inline-flex"><Icon name="check" /></span></span></Button><span role="status" className="sr-only">{copied ? "Copied" : ""}</span></Row>'
251
251
  },
252
252
  {
253
253
  label: "As link",