@bearmenu/ui 0.8.2 → 0.8.4

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 (40) hide show
  1. package/dist/chunk-2WDJR6K7.js +76 -0
  2. package/dist/components/icon.d.ts +18 -0
  3. package/dist/components/icon.js +31 -0
  4. package/dist/components/motion-icon-controls.d.ts +26 -0
  5. package/dist/components/motion-icon-controls.js +380 -0
  6. package/dist/components/motion-icon-set.d.ts +27 -0
  7. package/dist/components/motion-icon-set.js +139 -0
  8. package/dist/components/motion-icon.d.ts +44 -0
  9. package/dist/components/motion-icon.js +3 -0
  10. package/dist/lib/icon-registry.d.ts +58 -0
  11. package/dist/lib/icon-registry.js +39 -0
  12. package/package.json +2 -1
  13. package/src/components/accordion.stories.tsx +1 -1
  14. package/src/components/combobox.stories.tsx +1 -1
  15. package/src/components/currency-input.stories.tsx +3 -3
  16. package/src/components/empty-state.stories.tsx +3 -3
  17. package/src/components/form.stories.tsx +1 -1
  18. package/src/components/icon.tsx +57 -0
  19. package/src/components/input-otp.stories.tsx +1 -1
  20. package/src/components/markdown-renderer.stories.tsx +1 -1
  21. package/src/components/motion-icon-controls.test.tsx +78 -0
  22. package/src/components/motion-icon-controls.tsx +552 -0
  23. package/src/components/motion-icon-set.stories.tsx +185 -0
  24. package/src/components/motion-icon-set.tsx +219 -0
  25. package/src/components/motion-icon.test.tsx +159 -0
  26. package/src/components/motion-icon.tsx +163 -0
  27. package/src/components/multi-select-combobox.stories.tsx +1 -1
  28. package/src/components/phone-frame.stories.tsx +1 -1
  29. package/src/components/rating.stories.tsx +3 -3
  30. package/src/components/searchable-select.stories.tsx +1 -1
  31. package/src/components/selection-bar.stories.tsx +1 -1
  32. package/src/components/skeleton-card.stories.tsx +3 -3
  33. package/src/components/sliding-panels.stories.tsx +1 -1
  34. package/src/components/stepper.stories.tsx +1 -1
  35. package/src/components/sticky-container.stories.tsx +3 -3
  36. package/src/components/toggle-group.stories.tsx +1 -1
  37. package/src/components/tooltip.stories.tsx +2 -2
  38. package/src/globals.css +162 -40
  39. package/src/lib/icon-registry.ts +139 -0
  40. package/src/test/setup.ts +5 -1
@@ -0,0 +1,185 @@
1
+ import * as React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+
4
+ import { MotionHouse, MotionUtensils, MotionMapPin, MotionCalendar } from "./motion-icon-set";
5
+ import {
6
+ MotionHeart,
7
+ MotionBookmark,
8
+ MotionSliders,
9
+ MotionSearch,
10
+ MotionShare,
11
+ MotionSort,
12
+ MotionCheck,
13
+ MotionTheme,
14
+ MotionChevron,
15
+ MotionPlayPause,
16
+ MotionEye,
17
+ MotionStar,
18
+ MotionCopy,
19
+ MotionRefresh,
20
+ } from "./motion-icon-controls";
21
+ import type { MotionIconProps } from "./motion-icon";
22
+
23
+ /**
24
+ * The gallery is the point of keeping these in the design system.
25
+ *
26
+ * A character-animated set has to hold one duration budget and one spring
27
+ * across every glyph, and that is only checkable with them side by side,
28
+ * playing together, at the size they actually render. It cannot be judged by
29
+ * tapping through an app minutes apart.
30
+ */
31
+
32
+ type Glyph = { name: string; Icon: React.ComponentType<MotionIconProps>; gesture: string };
33
+
34
+ const OBJECTS: Glyph[] = [
35
+ { name: "House", Icon: MotionHouse, gesture: "roof jumps, walls take the impact" },
36
+ { name: "Utensils", Icon: MotionUtensils, gesture: "uncrosses into a place setting · holds" },
37
+ { name: "MapPin", Icon: MotionMapPin, gesture: "jumps, fills on the descent · holds" },
38
+ { name: "Calendar", Icon: MotionCalendar, gesture: "rolls four pages" },
39
+ ];
40
+
41
+ const CONTROLS: Glyph[] = [
42
+ { name: "Heart", Icon: MotionHeart, gesture: "squashes, then inflates · holds" },
43
+ { name: "Bookmark", Icon: MotionBookmark, gesture: "lifts, drops, tucks · holds" },
44
+ { name: "Sliders", Icon: MotionSliders, gesture: "handles travel their tracks · holds" },
45
+ { name: "Search", Icon: MotionSearch, gesture: "lens sweeps, finds something" },
46
+ { name: "Share", Icon: MotionShare, gesture: "outer nodes depart and return" },
47
+ { name: "Sort", Icon: MotionSort, gesture: "arrows trade ends" },
48
+ { name: "Check", Icon: MotionCheck, gesture: "draws itself on · holds" },
49
+ { name: "Theme", Icon: MotionTheme, gesture: "rays retract, crescent swings in · holds" },
50
+ { name: "Chevron", Icon: MotionChevron, gesture: "turns over, tips past, settles · holds" },
51
+ { name: "PlayPause", Icon: MotionPlayPause, gesture: "triangle folds into two bars · holds" },
52
+ { name: "Eye", Icon: MotionEye, gesture: "lid narrows, slash draws across · holds" },
53
+ { name: "Star", Icon: MotionStar, gesture: "pulls in, springs its points out · holds" },
54
+ { name: "Copy", Icon: MotionCopy, gesture: "sheet peels off, check lands · holds" },
55
+ { name: "Refresh", Icon: MotionRefresh, gesture: "one full turn, overshoots, settles" },
56
+ ];
57
+
58
+ const ALL = [...OBJECTS, ...CONTROLS];
59
+
60
+ const meta = {
61
+ title: "Motion/Animated icons",
62
+ component: MotionHouse,
63
+ parameters: { layout: "centered" },
64
+ } satisfies Meta<typeof MotionHouse>;
65
+
66
+ export default meta;
67
+ type Story = StoryObj<typeof meta>;
68
+
69
+ function Cell({
70
+ glyph,
71
+ size,
72
+ token,
73
+ active,
74
+ onPlay,
75
+ }: {
76
+ glyph: Glyph;
77
+ size: number;
78
+ token: number;
79
+ active: boolean;
80
+ onPlay: () => void;
81
+ }) {
82
+ const { name, Icon, gesture } = glyph;
83
+ return (
84
+ <button
85
+ type="button"
86
+ onPointerDown={onPlay}
87
+ className="flex w-32 cursor-pointer flex-col items-center gap-2 rounded-xl p-3 text-center hover:bg-muted/60"
88
+ >
89
+ <span
90
+ className={active ? "text-accent" : "text-muted-foreground"}
91
+ style={{ display: "block", width: size, height: size }}
92
+ >
93
+ <Icon active={active} replayToken={token} className="size-full" />
94
+ </span>
95
+ <span className="text-xs font-medium text-foreground">{name}</span>
96
+ <span className="text-[10px] leading-tight text-muted-foreground">{gesture}</span>
97
+ </button>
98
+ );
99
+ }
100
+
101
+ function Gallery({
102
+ glyphs,
103
+ size,
104
+ startAllActive = false,
105
+ }: {
106
+ glyphs: Glyph[];
107
+ size: number;
108
+ startAllActive?: boolean;
109
+ }) {
110
+ const [tokens, setTokens] = React.useState<number[]>(() => glyphs.map(() => 0));
111
+ const [activeIdx, setActiveIdx] = React.useState<number | null>(null);
112
+
113
+ const play = (i: number) => {
114
+ setTokens((t) => t.map((v, j) => (j === i ? v + 1 : v)));
115
+ setActiveIdx((cur) => (cur === i ? null : i));
116
+ };
117
+
118
+ return (
119
+ <div className="flex flex-col items-center gap-5 p-4">
120
+ <div className="flex flex-wrap items-start justify-center gap-1">
121
+ {glyphs.map((g, i) => (
122
+ <Cell
123
+ key={g.name}
124
+ glyph={g}
125
+ size={size}
126
+ token={tokens[i]}
127
+ active={startAllActive || activeIdx === i}
128
+ onPlay={() => play(i)}
129
+ />
130
+ ))}
131
+ </div>
132
+ <button
133
+ type="button"
134
+ onClick={() => setTokens((t) => t.map((v) => v + 1))}
135
+ className="rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-muted"
136
+ >
137
+ Play all together
138
+ </button>
139
+ </div>
140
+ );
141
+ }
142
+
143
+ /**
144
+ * Every glyph at once, at the size the mobile tab bar renders. This is the
145
+ * consistency check: play them together and the set should read as one family.
146
+ */
147
+ export const Family: Story = {
148
+ render: () => (
149
+ <div className="flex flex-col gap-2">
150
+ <Gallery glyphs={ALL} size={22} />
151
+ <p className="max-w-xl px-4 text-center text-xs text-muted-foreground">
152
+ Tap a glyph to select and perform it; tap again to deselect and watch it return.
153
+ &ldquo;Play all&rdquo; replays every gesture at once — use it to check the set shares one
154
+ duration budget. Glyphs marked &ldquo;holds&rdquo; keep a changed pose while selected; the
155
+ rest return to where they started.
156
+ </p>
157
+ </div>
158
+ ),
159
+ };
160
+
161
+ /** The objects and the controls separately, when tuning one group at a time. */
162
+ export const Objects: Story = { render: () => <Gallery glyphs={OBJECTS} size={28} /> };
163
+ export const Controls: Story = { render: () => <Gallery glyphs={CONTROLS} size={28} /> };
164
+
165
+ /**
166
+ * The legibility check. A gesture that reads at 48px and vanishes at 16px has
167
+ * an amplitude problem, not a timing problem.
168
+ */
169
+ export const Sizes: Story = {
170
+ render: () => (
171
+ <div className="flex flex-col gap-6 p-4">
172
+ {[16, 22, 32, 48].map((size) => (
173
+ <div key={size} className="flex flex-col gap-1">
174
+ <span className="px-4 text-xs font-semibold text-muted-foreground">{size}px</span>
175
+ <Gallery glyphs={ALL} size={size} />
176
+ </div>
177
+ ))}
178
+ </div>
179
+ ),
180
+ };
181
+
182
+ /** Every held pose at once, so the "holds" group can be compared side by side. */
183
+ export const HeldPoses: Story = {
184
+ render: () => <Gallery glyphs={ALL} size={32} startAllActive />,
185
+ };
@@ -0,0 +1,219 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The character-animated glyph set.
5
+ *
6
+ * Geometry is vendored from lucide (ISC) and kept on its 24-unit grid with the
7
+ * same 2px round-cap stroke, so these sit beside stock lucide icons without a
8
+ * visible seam. Vendoring is what lets a path be split (a roof off its walls)
9
+ * or driven independently, which the packaged components cannot do.
10
+ *
11
+ * Each glyph names the object, not the role — a product decides that a house
12
+ * means "home". See `motion-icon.tsx` for the three-variant model.
13
+ */
14
+
15
+ import * as React from "react";
16
+ import { motion, type Variants } from "framer-motion";
17
+
18
+ import {
19
+ MotionIcon,
20
+ beats,
21
+ returnTransition,
22
+ type MotionIconProps,
23
+ } from "./motion-icon";
24
+
25
+ // ─── House — the roof jumps (detach and return) ───────────────────────────────
26
+
27
+ const HOUSE_TIMES = [0, 0.16, 0.44, 0.72, 1];
28
+
29
+ const houseRoof: Variants = {
30
+ rest: { y: 0, scaleY: 1, transition: returnTransition },
31
+ active: { y: 0, scaleY: 1, transition: returnTransition },
32
+ play: {
33
+ // Dips into the walls, launches 9 units clear and stretches, falls, lands
34
+ // 0.9 low onto walls that compress under it, settles.
35
+ y: [0, 1.5, -9, 0.9, 0],
36
+ scaleY: [1, 0.92, 1.06, 0.86, 1],
37
+ transition: beats(HOUSE_TIMES),
38
+ },
39
+ };
40
+
41
+ const houseWalls: Variants = {
42
+ rest: { scaleY: 1, transition: returnTransition },
43
+ active: { scaleY: 1, transition: returnTransition },
44
+ play: { scaleY: [1, 1, 1, 0.9, 1], transition: beats(HOUSE_TIMES) },
45
+ };
46
+
47
+ /**
48
+ * Lucide draws roof and walls as one closed path; it is cut at the eaves
49
+ * (y=10) so the roof can leave. At rest the round caps of the two open paths
50
+ * overlap and the seam is invisible.
51
+ */
52
+ export function MotionHouse(props: MotionIconProps) {
53
+ return (
54
+ <MotionIcon {...props}>
55
+ <motion.path
56
+ variants={houseRoof}
57
+ style={{ originX: "12px", originY: "10px" }}
58
+ d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10"
59
+ />
60
+ <motion.g variants={houseWalls} style={{ originX: "12px", originY: "21px" }}>
61
+ <path d="M21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V10" />
62
+ <path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
63
+ </motion.g>
64
+ </MotionIcon>
65
+ );
66
+ }
67
+
68
+ // ─── Utensils — the X uncrosses into a place setting (articulate) ─────────────
69
+
70
+ const UTENSILS_TIMES = [0, 0.18, 0.72, 1];
71
+
72
+ /**
73
+ * 5 units of separation each way. The fork head is ~6 units across and the
74
+ * knife blade ~5, so their half-widths alone eat 5.5 units of the gap; at ±3.5
75
+ * the two silhouettes nearly touch and read as one cluttered shape.
76
+ */
77
+ const SEPARATION = 5;
78
+
79
+ const fork: Variants = {
80
+ rest: { rotate: 0, x: 0, transition: returnTransition },
81
+ active: { rotate: -44, x: -SEPARATION, transition: returnTransition },
82
+ play: {
83
+ rotate: [0, 6, -50, -44],
84
+ x: [0, 0.8, -6, -SEPARATION],
85
+ transition: beats(UTENSILS_TIMES, 0.6),
86
+ },
87
+ };
88
+
89
+ const knife: Variants = {
90
+ rest: { rotate: 0, x: 0, transition: returnTransition },
91
+ active: { rotate: 45, x: SEPARATION, transition: returnTransition },
92
+ play: {
93
+ rotate: [0, -6, 51, 45],
94
+ x: [0, -0.8, 6, SEPARATION],
95
+ transition: beats(UTENSILS_TIMES, 0.6),
96
+ },
97
+ };
98
+
99
+ const CROSSING = { originX: "12px", originY: "12px" } as const;
100
+
101
+ /**
102
+ * The one gesture that HOLDS: uncrossed is the active pose. ~89° of relative
103
+ * separation, so the outline changes completely and the state is readable from
104
+ * the silhouette alone.
105
+ */
106
+ export function MotionUtensils(props: MotionIconProps) {
107
+ return (
108
+ <MotionIcon {...props}>
109
+ <motion.path
110
+ variants={knife}
111
+ style={CROSSING}
112
+ d="M15 15 3.3 3.3a4.2 4.2 0 0 0 0 6l7.3 7.3c.7.7 2 .7 2.8 0L15 15Zm0 0 7 7"
113
+ />
114
+ <motion.g variants={fork} style={CROSSING}>
115
+ <path d="m16 2-2.3 2.3a3 3 0 0 0 0 4.2l1.8 1.8a3 3 0 0 0 4.2 0L22 8" />
116
+ <path d="m2.1 21.8 6.4-6.3" />
117
+ <path d="m19 5-7 7" />
118
+ </motion.g>
119
+ </MotionIcon>
120
+ );
121
+ }
122
+
123
+ // ─── Map pin — jumps, and fills on the way down (fall and land) ───────────────
124
+
125
+ const PIN_TIMES = [0, 0.16, 0.44, 0.72, 1];
126
+
127
+ /** Origin at the tip (12,22) — the pin flexes on its point, it does not slide. */
128
+ const pinBody: Variants = {
129
+ rest: { y: 0, scaleY: 1, transition: returnTransition },
130
+ active: { y: 0, scaleY: 1, transition: returnTransition },
131
+ play: {
132
+ y: [0, 1.2, -9, 0, 0],
133
+ scaleY: [1, 0.86, 1.08, 0.84, 1],
134
+ transition: beats(PIN_TIMES),
135
+ },
136
+ };
137
+
138
+ /**
139
+ * fillOpacity runs 0 → 1 between the apex (0.44) and the landing (0.72), so it
140
+ * is filling for the whole descent and full at the moment of impact. The fill
141
+ * is the payoff of the landing, not a decoration beside it.
142
+ */
143
+ const pinDot: Variants = {
144
+ rest: { fillOpacity: 0, scale: 1, transition: { duration: 0.18 } },
145
+ active: { fillOpacity: 1, scale: 1, transition: { duration: 0.18 } },
146
+ play: {
147
+ fillOpacity: [0, 0, 0, 1, 1],
148
+ scale: [1, 1, 1, 1.35, 1],
149
+ transition: beats(PIN_TIMES),
150
+ },
151
+ };
152
+
153
+ export function MotionMapPin(props: MotionIconProps) {
154
+ return (
155
+ <MotionIcon {...props}>
156
+ <motion.g variants={pinBody} style={{ originX: "12px", originY: "22px" }}>
157
+ <path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" />
158
+ <motion.circle
159
+ variants={pinDot}
160
+ style={{ originX: "12px", originY: "10px" }}
161
+ fill="currentColor"
162
+ fillOpacity={0}
163
+ cx="12"
164
+ cy="10"
165
+ r="3"
166
+ />
167
+ </motion.g>
168
+ </MotionIcon>
169
+ );
170
+ }
171
+
172
+ // ─── Calendar — rolls its pages (cycle content) ───────────────────────────────
173
+
174
+ /** 4-unit pitch. Rows 1–2 are the visible pose; 3–6 roll in from below. */
175
+ const ROWS = [14, 18, 22, 26, 30, 34];
176
+ const COLS = [8, 12, 16];
177
+
178
+ const reel: Variants = {
179
+ rest: { y: 0, transition: returnTransition },
180
+ active: { y: 0, transition: returnTransition },
181
+ play: {
182
+ // Exactly four rows (16 units, ~15px), so arriving rows land where the
183
+ // departing ones began and the end pose is identical to the rest pose.
184
+ y: [0, -4, -8, -12, -17, -16],
185
+ transition: beats([0, 0.18, 0.34, 0.5, 0.85, 1], 0.65),
186
+ },
187
+ };
188
+
189
+ /**
190
+ * Lucide's calendar has no pages — it is a box, a rule and six dots, and
191
+ * nothing in that geometry can roll. So the day dots become the page content,
192
+ * clipped to the body interior. The frame and binder rings hold perfectly
193
+ * still, which is what sells the contents moving.
194
+ */
195
+ export function MotionCalendar(props: MotionIconProps) {
196
+ // React's useId contains colons, which are not safe in an SVG id / url(#…).
197
+ const clipId = `cal-body-${React.useId().replace(/:/g, "")}`;
198
+
199
+ return (
200
+ <MotionIcon {...props}>
201
+ <defs>
202
+ <clipPath id={clipId}>
203
+ <rect x="3.5" y="10.5" width="17" height="11" rx="1.5" />
204
+ </clipPath>
205
+ </defs>
206
+
207
+ <path d="M8 2v4" />
208
+ <path d="M16 2v4" />
209
+ <rect width="18" height="18" x="3" y="4" rx="2" />
210
+ <path d="M3 10h18" />
211
+
212
+ <g clipPath={`url(#${clipId})`}>
213
+ <motion.g variants={reel}>
214
+ {ROWS.map((y) => COLS.map((x) => <path key={`${x}-${y}`} d={`M${x} ${y}h.01`} />))}
215
+ </motion.g>
216
+ </g>
217
+ </MotionIcon>
218
+ );
219
+ }
@@ -0,0 +1,159 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { render } from "@testing-library/react";
3
+ import * as React from "react";
4
+
5
+ import { MotionIcon } from "./motion-icon";
6
+
7
+ /**
8
+ * The primitive drives its gesture through animation controls rather than a
9
+ * declarative `animate` value, so the mock records every variant set or
10
+ * started. These assertions are the contract that keeps the icon from
11
+ * replaying on every page load or every unrelated re-render — both of which
12
+ * were real bugs during development.
13
+ */
14
+ const variantsSet: string[] = [];
15
+ const variantsStarted: string[] = [];
16
+
17
+ vi.mock("framer-motion", () => ({
18
+ useAnimationControls: () => ({
19
+ set: (v: string) => variantsSet.push(v),
20
+ start: (v: string) => {
21
+ variantsStarted.push(v);
22
+ return Promise.resolve();
23
+ },
24
+ }),
25
+ motion: {
26
+ svg: ({ children }: { children?: React.ReactNode }) => <svg>{children}</svg>,
27
+ g: ({ children }: { children?: React.ReactNode }) => <g>{children}</g>,
28
+ path: () => <path />,
29
+ circle: () => <circle />,
30
+ },
31
+ }));
32
+
33
+ beforeEach(() => {
34
+ variantsSet.length = 0;
35
+ variantsStarted.length = 0;
36
+ });
37
+
38
+ describe("MotionIcon", () => {
39
+ it("poses on mount without performing the gesture", () => {
40
+ render(
41
+ <MotionIcon active>
42
+ <path />
43
+ </MotionIcon>
44
+ );
45
+
46
+ // Replaying on mount would animate the selected icon on every page load.
47
+ expect(variantsSet).toEqual(["active"]);
48
+ expect(variantsStarted).toEqual([]);
49
+ });
50
+
51
+ it("rests on mount when inactive", () => {
52
+ render(
53
+ <MotionIcon active={false}>
54
+ <path />
55
+ </MotionIcon>
56
+ );
57
+
58
+ expect(variantsSet).toEqual(["rest"]);
59
+ expect(variantsStarted).toEqual([]);
60
+ });
61
+
62
+ it("performs the gesture when the replay token changes, with `active` untouched", () => {
63
+ const { rerender } = render(
64
+ <MotionIcon active={false} replayToken={0}>
65
+ <path />
66
+ </MotionIcon>
67
+ );
68
+ variantsStarted.length = 0;
69
+
70
+ rerender(
71
+ <MotionIcon active={false} replayToken={1}>
72
+ <path />
73
+ </MotionIcon>
74
+ );
75
+
76
+ // This is the whole point of the token: the gesture answers the pointer,
77
+ // not the state change that the pointer eventually causes.
78
+ expect(variantsStarted).toEqual(["play"]);
79
+ });
80
+
81
+ it("does not re-fire on a re-render that changes nothing", () => {
82
+ const { rerender } = render(
83
+ <MotionIcon active={false} replayToken={1}>
84
+ <path />
85
+ </MotionIcon>
86
+ );
87
+ variantsStarted.length = 0;
88
+
89
+ rerender(
90
+ <MotionIcon active={false} replayToken={1}>
91
+ <path />
92
+ </MotionIcon>
93
+ );
94
+ rerender(
95
+ <MotionIcon active={false} replayToken={1}>
96
+ <path />
97
+ </MotionIcon>
98
+ );
99
+
100
+ expect(variantsStarted).toEqual([]);
101
+ });
102
+
103
+ it("returns to rest when it stops being active", () => {
104
+ const { rerender } = render(
105
+ <MotionIcon active>
106
+ <path />
107
+ </MotionIcon>
108
+ );
109
+ variantsStarted.length = 0;
110
+
111
+ rerender(
112
+ <MotionIcon active={false}>
113
+ <path />
114
+ </MotionIcon>
115
+ );
116
+
117
+ expect(variantsStarted).toEqual(["rest"]);
118
+ });
119
+
120
+ it("plays when it becomes active without a preceding pointer", () => {
121
+ const { rerender } = render(
122
+ <MotionIcon active={false}>
123
+ <path />
124
+ </MotionIcon>
125
+ );
126
+ variantsStarted.length = 0;
127
+
128
+ rerender(
129
+ <MotionIcon active>
130
+ <path />
131
+ </MotionIcon>
132
+ );
133
+
134
+ // Arriving by some route other than tapping this icon still performs it.
135
+ expect(variantsStarted).toEqual(["play"]);
136
+ });
137
+
138
+ it("does not double-play when the pointer already started the gesture", () => {
139
+ const { rerender } = render(
140
+ <MotionIcon active={false} replayToken={0}>
141
+ <path />
142
+ </MotionIcon>
143
+ );
144
+
145
+ // Pointer down, then the resulting navigation flips `active` a moment later.
146
+ rerender(
147
+ <MotionIcon active={false} replayToken={1}>
148
+ <path />
149
+ </MotionIcon>
150
+ );
151
+ rerender(
152
+ <MotionIcon active replayToken={1}>
153
+ <path />
154
+ </MotionIcon>
155
+ );
156
+
157
+ expect(variantsStarted).toEqual(["play"]);
158
+ });
159
+ });