@sableui/react 0.14.0 → 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 (42) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/DESIGN.md +3 -1
  3. package/dist/blocks/index.d.ts +12 -1
  4. package/dist/blocks/index.js +4 -0
  5. package/dist/blocks/message-reactions-block.js +87 -0
  6. package/dist/blocks/message-reactions-block.meta.js +23 -0
  7. package/dist/components/bubble/bubble.js +105 -5
  8. package/dist/components/bubble/bubble.meta.js +42 -6
  9. package/dist/components/bubble/bubble.variants.js +1 -1
  10. package/dist/components/bubble/index.js +2 -0
  11. package/dist/components/feedback/feedback.js +8 -1
  12. package/dist/components/feedback/feedback.meta.js +6 -0
  13. package/dist/components/like-button/index.js +7 -0
  14. package/dist/components/like-button/like-button.js +89 -0
  15. package/dist/components/like-button/like-button.meta.js +132 -0
  16. package/dist/components/reaction-picker/index.js +13 -0
  17. package/dist/components/reaction-picker/reaction-picker.js +104 -0
  18. package/dist/components/reaction-picker/reaction-picker.meta.js +156 -0
  19. package/dist/components/toast/toast.js +25 -13
  20. package/dist/components/toast/toast.meta.js +7 -1
  21. package/dist/file-hashes.json +26 -17
  22. package/dist/foundations/motion.js +21 -1
  23. package/dist/icon-sets/reicon.js +11 -1
  24. package/dist/index.d.ts +295 -62
  25. package/dist/index.js +3 -1
  26. package/dist/lib/taxonomy.js +2 -0
  27. package/dist/motion/Burst.js +79 -0
  28. package/dist/motion/index.js +2 -0
  29. package/dist/styles.css +141 -0
  30. package/llms/blocks/message-reactions-block.md +116 -0
  31. package/llms/components/bubble.md +17 -7
  32. package/llms/components/feedback.md +1 -0
  33. package/llms/components/like-button.md +91 -0
  34. package/llms/components/reaction-picker.md +115 -0
  35. package/llms/components/toast.md +2 -1
  36. package/llms/foundations/motion.md +18 -2
  37. package/llms/reference/utilities.md +4 -1
  38. package/llms-full.txt +374 -12
  39. package/llms.txt +9 -6
  40. package/package.json +1 -1
  41. package/registry.json +573 -12
  42. package/targets/ios.json +18 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
1
1
  # What's new
2
2
 
3
+ ## 0.15.0
4
+
5
+ Motion with some life in it: small things that turn on now land like events,
6
+ told through the theme rather than one-off styles, and chat reactions that
7
+ answer you the way a messaging app does.
8
+
9
+ - **New: `LikeButton`.** A pressed-or-not toggle for a like, a star or a
10
+ save, with its count. Pressing it on fills the glyph, which blooms past
11
+ its size and settles while a ring opens and dots spray out in the theme's
12
+ palette families, and the count rolls up a digit; pressing it off is quiet.
13
+ It shows the viewer's like at once and goes back to `count` exactly
14
+ whenever `count` changes, so pass the server's total. `icon`, `tone` (a
15
+ palette family) and `label` make it a star or a save; `motion="subtle"`
16
+ keeps the fill and drops the flourish.
17
+ - **New recipe: `motion-bloom`.** A small glyph landing in its on state grows
18
+ from nothing to 1.2 and settles. Remount the glyph (a changing `key`) to
19
+ replay it; `--motion-bloom-delay` lets a ring lead. Expressive.
20
+ - **New: `<Burst>`.** The follow-through behind a bloom — a disc that hollows
21
+ into a ring, then dots thrown outward — coloured by palette families, so it
22
+ re-themes. Plays when its `trigger` changes, never on mount, and renders
23
+ nothing below full motion.
24
+ - **Toast: a status glyph instead of an accent bar.** A typed toast now leads
25
+ with the same glyph and ink as Note and InlineStatus (success, info,
26
+ warning, and the destructive glyph for errors), and it blooms in once the
27
+ card has landed — and again in the spinner's place when a loading toast
28
+ settles. The coloured bar down the leading edge told the type by colour
29
+ alone. A stacked deck now springs as cards step back and fan out; leaving
30
+ toasts still take the standard curve.
31
+ - **Feedback:** the chosen rating mark blooms as it is picked.
32
+ - **Icons: `replay` and `motion`.** A single circular arrow for watching
33
+ something again from its start (`refresh` reads as fetching again), and
34
+ a play mark in a dashed ring for a motion or playback setting (`sparkles`
35
+ reads as AI).
36
+ - **New: `ReactionPicker` and `MenuReactions`.** The quick row of emoji you
37
+ react to a message with. `ReactionPicker` is a toolbar for a popover or a
38
+ hover bar; `MenuReactions` is the same row as checkbox menu items, for the
39
+ top of the menu a message opens on right click or long press. The emoji
40
+ bloom in one after another as it opens and magnify under the pointer.
41
+ - **Bubble reactions celebrate.** When a `BubbleReaction` becomes the
42
+ reader's own — pressed, or added already on to a row that has rendered —
43
+ the emoji blooms and copies of it spray out, and the count rolls. Taking
44
+ a reaction back is quiet, and the reactions a page loads with stay still.
45
+ New `label` names a pill ("Party, 3"); new `BubbleReactionAdd` is the
46
+ smiley pill that opens a picker. `BubbleReactions` now hides itself while
47
+ empty, so render it for every message: staying mounted is how the first
48
+ reaction still celebrates. A Bubble shows a focus ring when it is made
49
+ focusable as a menu trigger.
50
+ - **New block: `MessageReactionsBlock`.** A thread you can react to from a
51
+ right-click / long-press menu or the add-reaction pill.
52
+ - **`<Burst>`: `particle` and `playOnMount`.** Throw copies of a glyph
53
+ instead of dots (no ring, travelling upright), and play once on mount for
54
+ a glyph that exists because of the action.
55
+ - **Icon: `emoji`.** A smiley, for an add-reaction control.
56
+ - **Motion guidance:** amplitude scales with smallness (a glyph travels far, a
57
+ surface barely), a moment is staged (main object first, follow-through
58
+ after), and only a person's own choice is celebrated. In `sable docs
59
+ motion` and DESIGN.md.
60
+
3
61
  ## 0.14.0
4
62
 
5
63
  Motion that is crafted by default and calm on request, and the first pieces for agent products.
package/DESIGN.md CHANGED
@@ -481,6 +481,8 @@ Motion is a first-class token layer, not an afterthought. **Crafted by default,
481
481
 
482
482
  **The principle behind the craft: one thing travels; two things do not blink.** When a selection, a value or a mark changes in place, move the element that represents it. And finish the story — a refused answer shakes *and* says why, a success confirms where it happened, an armed action asks again before it acts.
483
483
 
484
+ **Amplitude scales with smallness.** Motion felt lifeless when one amplitude served everything: the 4% a dialog needs is invisible on a 16px icon, so a heart turning on read as a colour swap. Small glyphs that a person turns on now travel far — grow from nothing past their size and settle — while surfaces keep their 0.96 → 1 and presses their 0.97. A moment is staged rather than flashed: the main object lands, then its follow-through (LikeButton's ring opens, the heart blooms 90ms later, dots are thrown after it). And only a person's own choice is celebrated, only at full motion: a burst on a system event or on load is a notification wearing confetti. The look of all of it still comes from the theme — palette families, tone glyphs, the radius scale — never from one-off values.
485
+
484
486
  **Two levels.** *Essential* motion answers the user — colour and focus feedback, press, overlay arrival, disclosure height, loaders, chart hover. *Expressive* motion narrates — glides, rolling numbers, drawn ticks, shakes, staggers, chart arrival and morph, scroll reveals, spring overshoot. Expressive durations are multiplied by a second factor, `--motion-expressive`, which `data-motion="subtle"` sets to 0 (and which takes the overshoot out of `ease-spring`). So the policy ladder is `full` (default: both) → `subtle` (essential only) → `off` (nothing moves; every state still changes). Set it app-wide on `<MotionProvider policy>`, for a region with `<MotionScope level>`, or per component with the `motion` prop that every component owning a flourish takes. Scopes and props can only turn motion **down** — `full` on a component means "inherit", so nothing below the provider can override a reader's reduce-motion setting.
485
487
 
486
488
  **Three parts:**
@@ -492,7 +494,7 @@ Motion is a first-class token layer, not an afterthought. **Crafted by default,
492
494
 
493
495
  2. **Recipes** (`styles.css`) — named, reusable behaviours a component opts into with one class instead of a bespoke transition string:
494
496
  - `motion-interactive` (hover/focus colour + transform transitions, including `rotate`) · `motion-press` (tactile press settle) · `motion-fade` (scrims) · `motion-pop` (anchored/centred surfaces — gentle spring on open) · `motion-slide-{up,down,left,right}` (directional fade-slide) · `motion-disclosure` (height) · `motion-draw` (SVG line-art draw-on — a `pathLength="1"` path inside a `<Reveal>` draws in when scrolled into view; stagger siblings via `--motion-draw-delay`) · loaders `motion-spin`/`motion-pulse`/`motion-shimmer`/`motion-dot-bounce`. Enter/exit recipes drive off Base UI's `data-[starting-style]`/`data-[ending-style]`.
495
- - Expressive: `motion-glide` (one indicator travelling to the selection) · `motion-shake` (a refused answer, driven by `useShake()`) · `motion-tick` (a mark that draws itself in) · `motion-appear` (a small control mounting via `@starting-style`) · `motion-arrive` (items a user action adds to a list, staggered) · `motion-size` (easing between intrinsic sizes via `interpolate-size`). `NumberTicker` is the rolling-number primitive.
497
+ - Expressive: `motion-glide` (one indicator travelling to the selection) · `motion-shake` (a refused answer, driven by `useShake()`) · `motion-tick` (a mark that draws itself in) · `motion-appear` (a small control mounting via `@starting-style`) · `motion-arrive` (items a user action adds to a list, staggered) · `motion-size` (easing between intrinsic sizes via `interpolate-size`) · `motion-bloom` (a small glyph landing in its on state, 0 → 1.2 → 1). `NumberTicker` is the rolling-number primitive; `<Burst>` is the follow-through behind a bloom (a ring and thrown dots in the theme's palette families), used by `LikeButton`.
496
498
 
497
499
  3. **Policy** — `prefers-reduced-motion` and `<MotionProvider>` collapse `--motion-scale` to 0 in one place: transitions resolve instantly, loaders hold their first frame. `subtle` zeroes `--motion-expressive` instead, so essential motion keeps playing. In-app, `useMotion()` reads and sets the policy; `useMotionLevel(motion)` resolves a component's own prop against everything around it, and JS-driven flourishes gate on its `expressive`.
498
500
 
@@ -170,6 +170,17 @@ declare function MediaPlayerBlock({ className, framed, onPlayingChange, onPrevio
170
170
 
171
171
  declare const mediaPlayerBlockMeta: BlockMeta;
172
172
 
173
+ interface MessageReactionsBlockProps {
174
+ className?: string;
175
+ /** false drops the card's surface and border. Keep it framed on a muted page: incoming bubbles are the muted surface too. */
176
+ framed?: boolean;
177
+ /** Called when the reader adds or removes a reaction, with the message, the emoji and whether it is now on. */
178
+ onReact?: (messageId: string, emoji: string, on: boolean) => void;
179
+ }
180
+ declare function MessageReactionsBlock({ className, framed, onReact }: MessageReactionsBlockProps): react.JSX.Element;
181
+
182
+ declare const messageReactionsBlockMeta: BlockMeta;
183
+
173
184
  interface NewsletterBlockProps {
174
185
  className?: string;
175
186
  /** false drops the card's surface and border — for a footer or muted section that already frames it. */
@@ -408,4 +419,4 @@ declare function WalletBlock({ className, framed, onSend, onReceive }: WalletBlo
408
419
 
409
420
  declare const walletBlockMeta: BlockMeta;
410
421
 
411
- export { ApiKeysBlock, ApprovalBlock, ChatThreadBlock, CommandPaletteBlock, CtaBandBlock, DangerZoneBlock, DeviceComparisonBlock, EventCardBlock, FaqBlock, FeatureGridBlock, FeedbackFormBlock, GoalProgressBlock, HeroBlock, InviteTeamBlock, InvoiceHistoryBlock, KpiSparklineBlock, MediaPlayerBlock, NewsletterBlock, NotFoundBlock, NotificationListBlock, NotificationSettingsBlock, OrderTrackingBlock, PricingCardBlock, PricingTableBlock, ProductCardBlock, ProfileCardBlock, ProfileFormBlock, RevenueBreakdownBlock, SignInBlock, SignUpBlock, SiteFooterBlock, StatRowBlock, StepsBlock, TaskListBlock, TeamMembersBlock, TestimonialBlock, TrafficSourcesBlock, TrafficTrendBlock, UploadListBlock, VerifyCodeBlock, WalletBlock, apiKeysBlockMeta, approvalBlockMeta, chatThreadBlockMeta, commandPaletteBlockMeta, ctaBandBlockMeta, dangerZoneBlockMeta, deviceComparisonBlockMeta, eventCardBlockMeta, faqBlockMeta, featureGridBlockMeta, feedbackFormBlockMeta, goalProgressBlockMeta, heroBlockMeta, inviteTeamBlockMeta, invoiceHistoryBlockMeta, kpiSparklineBlockMeta, mediaPlayerBlockMeta, newsletterBlockMeta, notFoundBlockMeta, notificationListBlockMeta, notificationSettingsBlockMeta, orderTrackingBlockMeta, pricingCardBlockMeta, pricingTableBlockMeta, productCardBlockMeta, profileCardBlockMeta, profileFormBlockMeta, revenueBreakdownBlockMeta, signInBlockMeta, signUpBlockMeta, siteFooterBlockMeta, statRowBlockMeta, stepsBlockMeta, taskListBlockMeta, teamMembersBlockMeta, testimonialBlockMeta, trafficSourcesBlockMeta, trafficTrendBlockMeta, uploadListBlockMeta, verifyCodeBlockMeta, walletBlockMeta };
422
+ export { ApiKeysBlock, ApprovalBlock, ChatThreadBlock, CommandPaletteBlock, CtaBandBlock, DangerZoneBlock, DeviceComparisonBlock, EventCardBlock, FaqBlock, FeatureGridBlock, FeedbackFormBlock, GoalProgressBlock, HeroBlock, InviteTeamBlock, InvoiceHistoryBlock, KpiSparklineBlock, MediaPlayerBlock, MessageReactionsBlock, NewsletterBlock, NotFoundBlock, NotificationListBlock, NotificationSettingsBlock, OrderTrackingBlock, PricingCardBlock, PricingTableBlock, ProductCardBlock, ProfileCardBlock, ProfileFormBlock, RevenueBreakdownBlock, SignInBlock, SignUpBlock, SiteFooterBlock, StatRowBlock, StepsBlock, TaskListBlock, TeamMembersBlock, TestimonialBlock, TrafficSourcesBlock, TrafficTrendBlock, UploadListBlock, VerifyCodeBlock, WalletBlock, apiKeysBlockMeta, approvalBlockMeta, chatThreadBlockMeta, commandPaletteBlockMeta, ctaBandBlockMeta, dangerZoneBlockMeta, deviceComparisonBlockMeta, eventCardBlockMeta, faqBlockMeta, featureGridBlockMeta, feedbackFormBlockMeta, goalProgressBlockMeta, heroBlockMeta, inviteTeamBlockMeta, invoiceHistoryBlockMeta, kpiSparklineBlockMeta, mediaPlayerBlockMeta, messageReactionsBlockMeta, newsletterBlockMeta, notFoundBlockMeta, notificationListBlockMeta, notificationSettingsBlockMeta, orderTrackingBlockMeta, pricingCardBlockMeta, pricingTableBlockMeta, productCardBlockMeta, profileCardBlockMeta, profileFormBlockMeta, revenueBreakdownBlockMeta, signInBlockMeta, signUpBlockMeta, siteFooterBlockMeta, statRowBlockMeta, stepsBlockMeta, taskListBlockMeta, teamMembersBlockMeta, testimonialBlockMeta, trafficSourcesBlockMeta, trafficTrendBlockMeta, uploadListBlockMeta, verifyCodeBlockMeta, walletBlockMeta };
@@ -33,6 +33,8 @@ import { KpiSparklineBlock } from "./kpi-sparkline-block.js";
33
33
  import { kpiSparklineBlockMeta } from "./kpi-sparkline-block.meta.js";
34
34
  import { MediaPlayerBlock } from "./media-player-block.js";
35
35
  import { mediaPlayerBlockMeta } from "./media-player-block.meta.js";
36
+ import { MessageReactionsBlock } from "./message-reactions-block.js";
37
+ import { messageReactionsBlockMeta } from "./message-reactions-block.meta.js";
36
38
  import { NewsletterBlock } from "./newsletter-block.js";
37
39
  import { newsletterBlockMeta } from "./newsletter-block.meta.js";
38
40
  import { NotFoundBlock } from "./not-found-block.js";
@@ -99,6 +101,7 @@ export {
99
101
  InvoiceHistoryBlock,
100
102
  KpiSparklineBlock,
101
103
  MediaPlayerBlock,
104
+ MessageReactionsBlock,
102
105
  NewsletterBlock,
103
106
  NotFoundBlock,
104
107
  NotificationListBlock,
@@ -140,6 +143,7 @@ export {
140
143
  invoiceHistoryBlockMeta,
141
144
  kpiSparklineBlockMeta,
142
145
  mediaPlayerBlockMeta,
146
+ messageReactionsBlockMeta,
143
147
  newsletterBlockMeta,
144
148
  notFoundBlockMeta,
145
149
  notificationListBlockMeta,
@@ -0,0 +1,87 @@
1
+ "use client";
2
+
3
+ // src/blocks/message-reactions-block.tsx
4
+ import { useState } from "react";
5
+ import { Bubble, BubbleReaction, BubbleReactionAdd, BubbleReactions } from "../components/bubble/index.js";
6
+ import { Card, CardContent, CardFooter } from "../components/card/index.js";
7
+ import {
8
+ ContextMenu,
9
+ ContextMenuContent,
10
+ ContextMenuItem,
11
+ ContextMenuSeparator,
12
+ ContextMenuTrigger
13
+ } from "../components/context-menu/index.js";
14
+ import { Message } from "../components/message/index.js";
15
+ import { DEFAULT_REACTIONS, MenuReactions } from "../components/reaction-picker/index.js";
16
+ import { cn } from "../lib/cn.js";
17
+ import { jsx, jsxs } from "react/jsx-runtime";
18
+ var messages = [
19
+ { id: "m1", text: "The new onboarding flow is live on staging \u{1F680}", mine: false, reactions: { "\u{1F389}": 2, "\u{1F440}": 1 } },
20
+ { id: "m2", text: "Clicked through it twice. The progress bar is so smooth.", mine: true, reactions: {} },
21
+ { id: "m3", text: "Ship it Friday?", mine: false, reactions: { "\u{1F44D}": 1 } }
22
+ ];
23
+ var hint = "Right-click or long-press a message to react.";
24
+ var labels = {
25
+ ...Object.fromEntries(DEFAULT_REACTIONS.map((r) => [r.emoji, r.label])),
26
+ "\u{1F440}": "Eyes"
27
+ };
28
+ function MessageReactionsBlock({ className, framed = true, onReact }) {
29
+ const [mine, setMine] = useState({ m1: ["\u{1F389}"] });
30
+ const toggle = (messageId, emoji) => {
31
+ const on = !(mine[messageId] ?? []).includes(emoji);
32
+ setMine((prev) => {
33
+ const current = prev[messageId] ?? [];
34
+ const next = current.includes(emoji) ? current.filter((e) => e !== emoji) : [...current, emoji];
35
+ return { ...prev, [messageId]: next };
36
+ });
37
+ onReact?.(messageId, emoji, on);
38
+ };
39
+ return /* @__PURE__ */ jsxs(Card, { variant: framed ? "default" : "flat", className: cn("w-full max-w-sm", className), children: [
40
+ /* @__PURE__ */ jsx(CardContent, { className: "flex flex-col gap-3", children: messages.map((m) => {
41
+ const align = m.mine ? "end" : "start";
42
+ const chosen = mine[m.id] ?? [];
43
+ const counts = { ...m.reactions };
44
+ for (const emoji of chosen) counts[emoji] = counts[emoji] ?? 0;
45
+ const pills = Object.entries(counts).map(([emoji, others]) => ({
46
+ emoji,
47
+ active: chosen.includes(emoji),
48
+ count: others + (chosen.includes(emoji) ? 1 : 0)
49
+ }));
50
+ return /* @__PURE__ */ jsxs(Message, { messageId: m.id, align: m.mine ? "end" : void 0, children: [
51
+ /* @__PURE__ */ jsxs(ContextMenu, { children: [
52
+ /* @__PURE__ */ jsx(
53
+ ContextMenuTrigger,
54
+ {
55
+ render: /* @__PURE__ */ jsx(Bubble, { tabIndex: 0, variant: m.mine ? "primary" : void 0, align }),
56
+ children: m.text
57
+ }
58
+ ),
59
+ /* @__PURE__ */ jsxs(ContextMenuContent, { children: [
60
+ /* @__PURE__ */ jsx(MenuReactions, { selected: chosen, onSelect: (emoji) => toggle(m.id, emoji) }),
61
+ /* @__PURE__ */ jsx(ContextMenuSeparator, {}),
62
+ /* @__PURE__ */ jsx(ContextMenuItem, { children: "Reply" }),
63
+ /* @__PURE__ */ jsx(ContextMenuItem, { children: "Copy text" })
64
+ ] })
65
+ ] }),
66
+ /* @__PURE__ */ jsxs(BubbleReactions, { align, children: [
67
+ pills.map((p) => /* @__PURE__ */ jsx(
68
+ BubbleReaction,
69
+ {
70
+ emoji: p.emoji,
71
+ label: labels[p.emoji],
72
+ count: p.count,
73
+ active: p.active,
74
+ onClick: () => toggle(m.id, p.emoji)
75
+ },
76
+ p.emoji
77
+ )),
78
+ pills.length > 0 ? /* @__PURE__ */ jsx(BubbleReactionAdd, { selected: chosen, onSelect: (emoji) => toggle(m.id, emoji) }) : null
79
+ ] })
80
+ ] }, m.id);
81
+ }) }),
82
+ /* @__PURE__ */ jsx(CardFooter, { className: "text-caption text-muted-foreground", children: hint })
83
+ ] });
84
+ }
85
+ export {
86
+ MessageReactionsBlock
87
+ };
@@ -0,0 +1,23 @@
1
+ // src/blocks/message-reactions-block.meta.ts
2
+ var messageReactionsBlockMeta = {
3
+ name: "MessageReactionsBlock",
4
+ family: "Social & messaging",
5
+ kind: "card",
6
+ width: { min: "18rem", max: "24rem" },
7
+ summary: "A short thread you can react to: right-click or long-press a bubble for a menu with a row of quick reactions above Reply and Copy, or use the add-reaction pill under a message. A new reaction blooms in and sprays copies of its emoji.",
8
+ intent: "Reach for this when a conversation excerpt should be reactable \u2014 a team thread, a comment card, a support transcript. A read-only excerpt is ChatThreadBlock; a working chat with a composer and history is the ChatPanel page shell.",
9
+ notes: [
10
+ "Two ways in, on purpose: the right-click / long-press menu is fast once you know it is there, and the add-reaction pill is how a first-time reader finds reactions at all.",
11
+ "The quick row in the menu is MenuReactions (checkbox menu items) and the one in the pill's popover is ReactionPicker (a toolbar), so each is reachable by the keyboard model of the surface it sits in.",
12
+ "Only the reader's own reaction celebrates. Counts from other people roll quietly, and the reactions a page loads with never play.",
13
+ "The add-reaction pill appears only on messages that already have reactions; a message with none is reacted to from its menu, which keeps quiet rows quiet."
14
+ ],
15
+ a11y: [
16
+ "Each bubble is focusable, and its menu opens on right click, long press, Shift+F10 or the Menu key; its reactions are checkbox items reporting whether the reader has used each one.",
17
+ 'Each reaction pill is a toggle named by its meaning and count ("Party, 3") with `aria-pressed`; the emoji and the burst are hidden from assistive tech.',
18
+ "Under reduced motion the reaction appears and the count changes at once; nothing else is lost."
19
+ ]
20
+ };
21
+ export {
22
+ messageReactionsBlockMeta
23
+ };
@@ -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
  };
@@ -109,7 +109,14 @@ function Feedback({
109
109
  value: option.value,
110
110
  "aria-label": option.value,
111
111
  className: "flex-1 text-base",
112
- children: option.label
112
+ children: /* @__PURE__ */ jsx(
113
+ "span",
114
+ {
115
+ className: cn("inline-flex", rating === option.value && "motion-bloom"),
116
+ children: option.label
117
+ },
118
+ rating === option.value ? "chosen" : "rest"
119
+ )
113
120
  },
114
121
  option.value
115
122
  ))
@@ -113,6 +113,12 @@ var feedbackMeta = {
113
113
  part: "Rating toggles",
114
114
  why: "The rating row is a choice among a few similar marks. The eased selected fill is what makes the pick read as made, not just hovered."
115
115
  },
116
+ {
117
+ slot: "response",
118
+ recipe: "motion-bloom",
119
+ part: "Chosen rating mark",
120
+ why: "The mark itself answers the pick: it blooms past its size and settles, so choosing reads as a small decision landing rather than a fill changing. Only the chosen mark, only on choosing \u2014 nothing blooms when the form opens or a pick is let go. Expressive: under subtle the fill alone answers."
121
+ },
116
122
  {
117
123
  slot: "transition",
118
124
  recipe: "motion-tick",
@@ -0,0 +1,7 @@
1
+ // src/components/like-button/index.ts
2
+ import { LikeButton } from "./like-button.js";
3
+ import { likeButtonMeta } from "./like-button.meta.js";
4
+ export {
5
+ LikeButton,
6
+ likeButtonMeta
7
+ };
@@ -0,0 +1,89 @@
1
+ "use client";
2
+
3
+ // src/components/like-button/like-button.tsx
4
+ import { useMemo, useState } from "react";
5
+ import { Icon } from "../../icon/index.js";
6
+ import { cn } from "../../lib/cn.js";
7
+ import { Burst } from "../../motion/Burst.js";
8
+ import { motionAttribute } from "../../motion/MotionProvider.js";
9
+ import { Button } from "../button/button.js";
10
+ import { NumberTicker } from "../number-ticker/number-ticker.js";
11
+ import { jsx, jsxs } from "react/jsx-runtime";
12
+ var ICON_SIZE = { xs: "icon-xs", sm: "icon-sm", md: "icon" };
13
+ function LikeButton({
14
+ className,
15
+ count,
16
+ format,
17
+ icon = "heart",
18
+ tone = "red",
19
+ label = "Like",
20
+ variant = "ghost",
21
+ size = "sm",
22
+ motion,
23
+ pressed: pressedProp,
24
+ defaultPressed = false,
25
+ onPressedChange,
26
+ onClick,
27
+ ...props
28
+ }) {
29
+ const [innerPressed, setInnerPressed] = useState(defaultPressed);
30
+ const pressed = pressedProp ?? innerPressed;
31
+ const [baseline, setBaseline] = useState({ count, pressed });
32
+ if (!Object.is(count, baseline.count)) setBaseline({ count, pressed });
33
+ const shown = count === void 0 ? void 0 : count + Number(pressed) - Number(baseline.pressed);
34
+ const [likes, setLikes] = useState(0);
35
+ const [armed, setArmed] = useState(false);
36
+ if (armed) {
37
+ setArmed(false);
38
+ if (pressed) setLikes((n) => n + 1);
39
+ }
40
+ const formatter = useMemo(() => new Intl.NumberFormat(void 0, format), [format]);
41
+ return /* @__PURE__ */ jsxs(
42
+ Button,
43
+ {
44
+ ...props,
45
+ variant,
46
+ size: shown === void 0 ? ICON_SIZE[size] : size,
47
+ className: cn("tabular-nums", className),
48
+ "aria-pressed": pressed,
49
+ "data-pressed": pressed ? "" : void 0,
50
+ onClick: (event) => {
51
+ onClick?.(event);
52
+ if (event.defaultPrevented) return;
53
+ const next = !pressed;
54
+ if (pressedProp === void 0) setInnerPressed(next);
55
+ if (next) setArmed(true);
56
+ onPressedChange?.(next, event);
57
+ },
58
+ "aria-label": props["aria-label"] ?? (shown === void 0 ? label : `${label} ${formatter.format(shown)}`),
59
+ "data-motion": motionAttribute(motion),
60
+ children: [
61
+ /* @__PURE__ */ jsxs("span", { className: "relative inline-flex", children: [
62
+ /* @__PURE__ */ jsx(
63
+ "span",
64
+ {
65
+ style: { "--motion-bloom-delay": "90ms" },
66
+ className: cn("inline-flex", pressed && likes > 0 && "motion-bloom"),
67
+ "data-palette": pressed ? tone : void 0,
68
+ children: /* @__PURE__ */ jsx(
69
+ Icon,
70
+ {
71
+ name: icon,
72
+ variant: pressed ? "solid" : "regular",
73
+ className: cn("size-(--button-icon)", pressed && "text-palette-dot")
74
+ }
75
+ )
76
+ },
77
+ likes
78
+ ),
79
+ /* @__PURE__ */ jsx("span", { "data-palette": tone, className: "contents text-palette-dot", children: /* @__PURE__ */ jsx(Burst, { trigger: likes, motion, className: "[--burst-size:calc(var(--button-icon)*2.25)]" }) })
80
+ ] }),
81
+ shown === void 0 ? null : /* @__PURE__ */ jsx(NumberTicker, { value: shown, format, motion })
82
+ ]
83
+ }
84
+ );
85
+ }
86
+ LikeButton.displayName = "LikeButton";
87
+ export {
88
+ LikeButton
89
+ };