@bearmenu/ui 0.8.3 → 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 (39) 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/lib/icon-registry.ts +139 -0
  39. package/src/test/setup.ts +5 -1
@@ -0,0 +1,163 @@
1
+ "use client";
2
+
3
+ /**
4
+ * MotionIcon — the root for character-animated icons.
5
+ *
6
+ * An animated icon performs a behaviour the object it depicts would actually
7
+ * perform (a roof jumps, utensils uncross, a pin drops and plants) rather than
8
+ * tweening a property. The rules that make container motion feel right — short
9
+ * durations, one or two properties, restraint — produce dead icons, so this
10
+ * primitive is built for the opposite:
11
+ *
12
+ * - Gestures run 400–700ms in five or six beats (anticipation, action, apex,
13
+ * impact, settle), which deliberately exceeds the usual UI ceiling. An icon
14
+ * gesture is a performance the user chose to trigger, not a transition they
15
+ * are waiting through.
16
+ * - Amplitudes are 25–40% of the icon box. At a 22px render on a 24-unit grid
17
+ * one unit is 0.92px, so anything under ~6 units is physically invisible.
18
+ * - The silhouette must change. If the outline is the same shape throughout,
19
+ * nothing reads at icon size.
20
+ *
21
+ * Three variants, not two:
22
+ * rest — the plain glyph.
23
+ * active — the held pose. Equal to `rest` for gestures that return
24
+ * (a roof that lands again); a genuinely different pose for ones
25
+ * that stay changed (uncrossed utensils, a filled pin).
26
+ * play — the performance: keyframe arrays that land on `active`.
27
+ *
28
+ * `play` is driven by `replayToken`, which callers bump on POINTER DOWN rather
29
+ * than on the resulting state change. Waiting for state means waiting for
30
+ * navigation or a network write, which disconnects the gesture from the finger
31
+ * and reads as dead at any amplitude.
32
+ */
33
+
34
+ import * as React from "react";
35
+ import { motion, useAnimationControls, type Transition, type Variants } from "framer-motion";
36
+
37
+ import { cn } from "../lib/utils";
38
+
39
+ export type MotionIconProps = {
40
+ /** The held state. Drives `active` vs `rest`, and the stroke weight. */
41
+ active?: boolean;
42
+ /**
43
+ * Bump on pointer down to perform the gesture. Changing this value replays
44
+ * `play` even when `active` has not changed, which is what makes the icon
45
+ * respond to the touch rather than to its consequence.
46
+ */
47
+ replayToken?: number;
48
+ className?: string;
49
+ };
50
+
51
+ export type MotionIconVariant = "rest" | "active" | "play";
52
+
53
+ /**
54
+ * A gesture is a performance with beats, so it is a tween with explicit
55
+ * `times`. A spring interpolates two keyframes and puts its overshoot at the
56
+ * end, which cannot express anticipation.
57
+ */
58
+ export function beats(times: number[], duration = 0.62): Transition {
59
+ return { duration, times, ease: "easeInOut" };
60
+ }
61
+
62
+ /** Leaving is not a performance — it returns the glyph, firmly and fast. */
63
+ export const returnTransition: Transition = { type: "spring", stiffness: 320, damping: 26 };
64
+
65
+ type PoseValues = Record<string, number>;
66
+
67
+ /**
68
+ * Builds the three variants for a moving part.
69
+ * `play` values are keyframe arrays; `rest`/`active` are the two held poses.
70
+ */
71
+ export function poses(
72
+ rest: PoseValues,
73
+ active: PoseValues,
74
+ play: Record<string, number[]>,
75
+ transition: Transition
76
+ ): Variants {
77
+ return {
78
+ rest: { ...rest, transition: returnTransition },
79
+ active: { ...active, transition: returnTransition },
80
+ play: { ...play, transition },
81
+ };
82
+ }
83
+
84
+ const rootVariants: Variants = {
85
+ rest: { strokeWidth: 1.75, transition: { duration: 0.18 } },
86
+ active: { strokeWidth: 2.25, transition: { duration: 0.18 } },
87
+ play: { strokeWidth: 2.25, transition: { duration: 0.12 } },
88
+ };
89
+
90
+ /**
91
+ * Mount poses without playing; pointer down plays; arriving plays unless the
92
+ * pointer already started it; leaving returns.
93
+ *
94
+ * Both effects compare against a ref rather than trusting the dependency array,
95
+ * so they fire once per actual change and not once per render.
96
+ */
97
+ export function useIconGesture(active: boolean, replayToken: number) {
98
+ const controls = useAnimationControls();
99
+ const lastPlayAt = React.useRef(0);
100
+ const seenToken = React.useRef(0);
101
+ // `null` until mounted, so the pose effect can tell a real change from the
102
+ // first run and never performs the gesture on page load.
103
+ const seenActive = React.useRef<boolean | null>(null);
104
+
105
+ React.useEffect(() => {
106
+ controls.set(active ? "active" : "rest");
107
+ seenActive.current = active;
108
+ // Mount only — the two effects below own everything after that.
109
+ // eslint-disable-next-line react-hooks/exhaustive-deps
110
+ }, []);
111
+
112
+ React.useEffect(() => {
113
+ if (replayToken === seenToken.current) return;
114
+ seenToken.current = replayToken;
115
+ lastPlayAt.current = Date.now();
116
+ void controls.start("play");
117
+ }, [replayToken, controls]);
118
+
119
+ React.useEffect(() => {
120
+ if (seenActive.current === null || seenActive.current === active) return;
121
+ seenActive.current = active;
122
+ if (!active) {
123
+ void controls.start("rest");
124
+ return;
125
+ }
126
+ // The pointer that caused this state change already started the gesture.
127
+ if (Date.now() - lastPlayAt.current < 600) return;
128
+ void controls.start("play");
129
+ }, [active, controls]);
130
+
131
+ return controls;
132
+ }
133
+
134
+ export function MotionIcon({
135
+ active = false,
136
+ replayToken = 0,
137
+ className,
138
+ children,
139
+ }: MotionIconProps & { children: React.ReactNode }) {
140
+ const controls = useIconGesture(active, replayToken);
141
+
142
+ return (
143
+ <motion.svg
144
+ viewBox="0 0 24 24"
145
+ // Parts leave the 24-unit box at the apex of a jump. Callers give the
146
+ // icon padding rather than clipping the gesture.
147
+ overflow="visible"
148
+ fill="none"
149
+ stroke="currentColor"
150
+ strokeLinecap="round"
151
+ strokeLinejoin="round"
152
+ variants={rootVariants}
153
+ // Without this the active icon replays on hydration and every other one
154
+ // flashes through its pose on first paint.
155
+ initial={false}
156
+ animate={controls}
157
+ className={cn("size-6", className)}
158
+ aria-hidden
159
+ >
160
+ {children}
161
+ </motion.svg>
162
+ );
163
+ }
@@ -10,7 +10,7 @@ const meta = {
10
10
  } satisfies Meta<typeof MultiSelectCombobox>;
11
11
 
12
12
  export default meta;
13
- type Story = StoryObj<typeof meta>;
13
+ type Story = StoryObj<typeof MultiSelectCombobox>;
14
14
 
15
15
  const cuisines = [
16
16
  { value: "italian", label: "Italian" },
@@ -11,7 +11,7 @@ const meta = {
11
11
  } satisfies Meta<typeof PhoneFrame>;
12
12
 
13
13
  export default meta;
14
- type Story = StoryObj<typeof meta>;
14
+ type Story = StoryObj<typeof PhoneFrame>;
15
15
 
16
16
  export const Default: Story = {
17
17
  args: { width: 220 },
@@ -1,14 +1,14 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { Rating } from "./rating";
3
3
 
4
- const meta = {
4
+ const meta: Meta<typeof Rating> = {
5
5
  title: "Components/Rating",
6
6
  component: Rating,
7
7
  tags: ["autodocs"],
8
- } satisfies Meta<typeof Rating>;
8
+ };
9
9
 
10
10
  export default meta;
11
- type Story = StoryObj<typeof meta>;
11
+ type Story = StoryObj<typeof Rating>;
12
12
 
13
13
  export const Default: Story = {
14
14
  render: () => <Rating value={4} reviewCount={128} />,
@@ -10,7 +10,7 @@ const meta = {
10
10
  } satisfies Meta<typeof SearchableSelect>;
11
11
 
12
12
  export default meta;
13
- type Story = StoryObj<typeof meta>;
13
+ type Story = StoryObj<typeof SearchableSelect>;
14
14
 
15
15
  const frameworks = [
16
16
  { value: "next", label: "Next.js", description: "The React Framework" },
@@ -9,7 +9,7 @@ const meta = {
9
9
  } satisfies Meta<typeof SelectionBar>;
10
10
 
11
11
  export default meta;
12
- type Story = StoryObj<typeof meta>;
12
+ type Story = StoryObj<typeof SelectionBar>;
13
13
 
14
14
  export const Default: Story = {
15
15
  render: () => (
@@ -1,14 +1,14 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { SkeletonCard } from "./skeleton-card";
3
3
 
4
- const meta = {
4
+ const meta: Meta<typeof SkeletonCard> = {
5
5
  title: "Components/SkeletonCard",
6
6
  component: SkeletonCard,
7
7
  tags: ["autodocs"],
8
- } satisfies Meta<typeof SkeletonCard>;
8
+ };
9
9
 
10
10
  export default meta;
11
- type Story = StoryObj<typeof meta>;
11
+ type Story = StoryObj<typeof SkeletonCard>;
12
12
 
13
13
  export const Experience: Story = {
14
14
  render: () => <SkeletonCard variant="experience" className="w-[350px]" />,
@@ -10,7 +10,7 @@ const meta = {
10
10
  } satisfies Meta<typeof SlidingPanels>;
11
11
 
12
12
  export default meta;
13
- type Story = StoryObj<typeof meta>;
13
+ type Story = StoryObj<typeof SlidingPanels>;
14
14
 
15
15
  function SlidingPanelsDemo() {
16
16
  const [currentPanel, setCurrentPanel] = useState("panel-1");
@@ -10,7 +10,7 @@ const meta = {
10
10
  } satisfies Meta<typeof Stepper>;
11
11
 
12
12
  export default meta;
13
- type Story = StoryObj<typeof meta>;
13
+ type Story = StoryObj<typeof Stepper>;
14
14
 
15
15
  const steps = [
16
16
  { id: "account", title: "Account" },
@@ -1,14 +1,14 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { StickyContainer } from "./sticky-container";
3
3
 
4
- const meta = {
4
+ const meta: Meta<typeof StickyContainer> = {
5
5
  title: "Components/StickyContainer",
6
6
  component: StickyContainer,
7
7
  tags: ["autodocs"],
8
- } satisfies Meta<typeof StickyContainer>;
8
+ };
9
9
 
10
10
  export default meta;
11
- type Story = StoryObj<typeof meta>;
11
+ type Story = StoryObj<typeof StickyContainer>;
12
12
 
13
13
  export const Default: Story = {
14
14
  render: () => (
@@ -9,7 +9,7 @@ const meta = {
9
9
  } satisfies Meta<typeof ToggleGroup>;
10
10
 
11
11
  export default meta;
12
- type Story = StoryObj<typeof meta>;
12
+ type Story = StoryObj<typeof ToggleGroup>;
13
13
 
14
14
  export const Default: Story = {
15
15
  render: () => (
@@ -3,7 +3,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tool
3
3
  import { Button } from "./button";
4
4
  import { Plus } from "lucide-react";
5
5
 
6
- const meta = {
6
+ const meta: Meta<typeof Tooltip> = {
7
7
  title: "Components/Tooltip",
8
8
  component: Tooltip,
9
9
  tags: ["autodocs"],
@@ -14,7 +14,7 @@ const meta = {
14
14
  </TooltipProvider>
15
15
  ),
16
16
  ],
17
- } satisfies Meta<typeof Tooltip>;
17
+ };
18
18
 
19
19
  export default meta;
20
20
  type Story = StoryObj<typeof meta>;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The purpose → glyph registry.
3
+ *
4
+ * One place recording which glyph means what. Before this existed, ~10 ad-hoc
5
+ * glyph maps were scattered across the app and the same purpose ended up drawn
6
+ * two different ways: back as both an arrow and a chevron, "open filters" as
7
+ * both a funnel and sliders (one surface used both, chosen by which view you
8
+ * were in), clear and close sharing a bare X.
9
+ *
10
+ * Only GENERIC action vocabulary lives here. Product taxonomy — cuisine
11
+ * shortcuts, facet dimensions, digest categories — stays in the consuming app.
12
+ *
13
+ * ## The rules this encodes
14
+ *
15
+ * 1. **Arrow leaves, chevron stays.** `ArrowRight` means "you are going to a new
16
+ * destination" (see-all links, banner CTAs). `ChevronRight` means "reveal
17
+ * more, in place" and is scoped to exactly two jobs: carousel paging and
18
+ * row-contained drill-in. Collapsing these is what produced a glyph with
19
+ * four meanings.
20
+ * 2. **Sliders, not a funnel.** A funnel reads as "narrow a list" and collides
21
+ * with search and sort iconography at 24px; three sliders read
22
+ * unambiguously as "adjust parameters" and carry a badge count cleanly.
23
+ * 3. **Containment separates clear from close.** A bare `X` leaves a layer. A
24
+ * contained `XCircle` resets a value in place without leaving. In a dense
25
+ * filter row, bare Xs beside each other cannot say which layer they close.
26
+ * 4. **Tappable has a container or a colour state; information is muted and
27
+ * bare.** This is what disambiguates the overloaded glyphs (`MapPin`,
28
+ * `Star`) rather than picking different glyphs for each sense. An
29
+ * interactive instance sits in a chip or button and takes an active colour;
30
+ * a decorative one is `text-muted-foreground`, `aria-hidden`, outside any
31
+ * button, with no hover or active transition.
32
+ * 5. **Loved and want-to-try are two actions, not one.** `Heart` is
33
+ * retrospective ("I have had this and I love it"), `Bookmark` is prospective
34
+ * ("I want to try this"). They feed different lists and are mutually
35
+ * exclusive per item.
36
+ */
37
+
38
+ import {
39
+ ArrowLeft,
40
+ ArrowRight,
41
+ ArrowUpDown,
42
+ Bookmark,
43
+ Check,
44
+ ChevronDown,
45
+ ChevronLeft,
46
+ ChevronRight,
47
+ ExternalLink,
48
+ Flag,
49
+ Heart,
50
+ Info,
51
+ Navigation,
52
+ Phone,
53
+ Plus,
54
+ RefreshCw,
55
+ Search,
56
+ Share2,
57
+ SlidersHorizontal,
58
+ Trash2,
59
+ X,
60
+ XCircle,
61
+ type LucideIcon,
62
+ } from "lucide-react";
63
+
64
+ export type IconPurpose =
65
+ // navigation
66
+ | "back"
67
+ | "seeAll"
68
+ | "drillIn"
69
+ | "carouselPrev"
70
+ | "carouselNext"
71
+ | "directions"
72
+ | "externalLink"
73
+ // layers and values
74
+ | "close"
75
+ | "clear"
76
+ | "expand"
77
+ // list controls
78
+ | "filter"
79
+ | "sort"
80
+ | "search"
81
+ // actions
82
+ | "share"
83
+ | "favorite"
84
+ | "wantToTry"
85
+ | "confirm"
86
+ | "add"
87
+ | "remove"
88
+ | "refresh"
89
+ | "call"
90
+ | "report"
91
+ | "info";
92
+
93
+ export const iconFor: Record<IconPurpose, LucideIcon> = {
94
+ back: ArrowLeft,
95
+ seeAll: ArrowRight,
96
+ drillIn: ChevronRight,
97
+ carouselPrev: ChevronLeft,
98
+ carouselNext: ChevronRight,
99
+ directions: Navigation,
100
+ externalLink: ExternalLink,
101
+
102
+ close: X,
103
+ clear: XCircle,
104
+ expand: ChevronDown,
105
+
106
+ filter: SlidersHorizontal,
107
+ sort: ArrowUpDown,
108
+ search: Search,
109
+
110
+ share: Share2,
111
+ favorite: Heart,
112
+ wantToTry: Bookmark,
113
+ confirm: Check,
114
+ add: Plus,
115
+ remove: Trash2,
116
+ refresh: RefreshCw,
117
+ call: Phone,
118
+ report: Flag,
119
+ info: Info,
120
+ };
121
+
122
+ /**
123
+ * Purposes whose glyph must rotate to show state, and by how much.
124
+ * `expand` is the disclosure chevron: pointing down at rest, up when open.
125
+ */
126
+ export const iconRotation: Partial<Record<IconPurpose, number>> = {
127
+ expand: 180,
128
+ };
129
+
130
+ /**
131
+ * Retired glyphs and what replaced them. Kept so a reviewer can tell a
132
+ * deliberate choice from a regression, and so lint/codemods have a source.
133
+ */
134
+ export const retiredIcons = {
135
+ Filter: "filter → SlidersHorizontal",
136
+ ChevronRightAsSeeAll: "seeAll → ArrowRight",
137
+ ChevronLeftAsBack: "back → ArrowLeft",
138
+ XAsClear: "clear → XCircle",
139
+ } as const;
package/src/test/setup.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import "@testing-library/jest-dom";
2
2
 
3
- global.ResizeObserver = class ResizeObserver {
3
+ // `globalThis`, not `global`: the latter is a Node-only global, and this
4
+ // project's `lib` is dom / dom.iterable / esnext with no `@types/node`, so it
5
+ // did not typecheck. This file already reaches for `window` below — the test
6
+ // environment is jsdom, and `globalThis` is the spelling that is typed in both.
7
+ globalThis.ResizeObserver = class ResizeObserver {
4
8
  observe() {}
5
9
  unobserve() {}
6
10
  disconnect() {}