@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.
- package/dist/chunk-2WDJR6K7.js +76 -0
- package/dist/components/icon.d.ts +18 -0
- package/dist/components/icon.js +31 -0
- package/dist/components/motion-icon-controls.d.ts +26 -0
- package/dist/components/motion-icon-controls.js +380 -0
- package/dist/components/motion-icon-set.d.ts +27 -0
- package/dist/components/motion-icon-set.js +139 -0
- package/dist/components/motion-icon.d.ts +44 -0
- package/dist/components/motion-icon.js +3 -0
- package/dist/lib/icon-registry.d.ts +58 -0
- package/dist/lib/icon-registry.js +39 -0
- package/package.json +2 -1
- package/src/components/accordion.stories.tsx +1 -1
- package/src/components/combobox.stories.tsx +1 -1
- package/src/components/currency-input.stories.tsx +3 -3
- package/src/components/empty-state.stories.tsx +3 -3
- package/src/components/form.stories.tsx +1 -1
- package/src/components/icon.tsx +57 -0
- package/src/components/input-otp.stories.tsx +1 -1
- package/src/components/markdown-renderer.stories.tsx +1 -1
- package/src/components/motion-icon-controls.test.tsx +78 -0
- package/src/components/motion-icon-controls.tsx +552 -0
- package/src/components/motion-icon-set.stories.tsx +185 -0
- package/src/components/motion-icon-set.tsx +219 -0
- package/src/components/motion-icon.test.tsx +159 -0
- package/src/components/motion-icon.tsx +163 -0
- package/src/components/multi-select-combobox.stories.tsx +1 -1
- package/src/components/phone-frame.stories.tsx +1 -1
- package/src/components/rating.stories.tsx +3 -3
- package/src/components/searchable-select.stories.tsx +1 -1
- package/src/components/selection-bar.stories.tsx +1 -1
- package/src/components/skeleton-card.stories.tsx +3 -3
- package/src/components/sliding-panels.stories.tsx +1 -1
- package/src/components/stepper.stories.tsx +1 -1
- package/src/components/sticky-container.stories.tsx +3 -3
- package/src/components/toggle-group.stories.tsx +1 -1
- package/src/components/tooltip.stories.tsx +2 -2
- package/src/lib/icon-registry.ts +139 -0
- 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
|
+
}
|
|
@@ -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
|
-
}
|
|
8
|
+
};
|
|
9
9
|
|
|
10
10
|
export default meta;
|
|
11
|
-
type Story = StoryObj<typeof
|
|
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
|
|
13
|
+
type Story = StoryObj<typeof SearchableSelect>;
|
|
14
14
|
|
|
15
15
|
const frameworks = [
|
|
16
16
|
{ value: "next", label: "Next.js", description: "The React Framework" },
|
|
@@ -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
|
-
}
|
|
8
|
+
};
|
|
9
9
|
|
|
10
10
|
export default meta;
|
|
11
|
-
type Story = StoryObj<typeof
|
|
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
|
|
13
|
+
type Story = StoryObj<typeof SlidingPanels>;
|
|
14
14
|
|
|
15
15
|
function SlidingPanelsDemo() {
|
|
16
16
|
const [currentPanel, setCurrentPanel] = useState("panel-1");
|
|
@@ -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
|
-
}
|
|
8
|
+
};
|
|
9
9
|
|
|
10
10
|
export default meta;
|
|
11
|
-
type Story = StoryObj<typeof
|
|
11
|
+
type Story = StoryObj<typeof StickyContainer>;
|
|
12
12
|
|
|
13
13
|
export const Default: Story = {
|
|
14
14
|
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
|
-
}
|
|
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
|
|
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() {}
|