@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,139 @@
|
|
|
1
|
+
import { beats, MotionIcon, returnTransition } from '../chunk-2WDJR6K7.js';
|
|
2
|
+
import '../chunk-FEK5XGZW.js';
|
|
3
|
+
import { __spreadProps, __spreadValues } from '../chunk-3GWWZKX7.js';
|
|
4
|
+
import * as React from 'react';
|
|
5
|
+
import { motion } from 'framer-motion';
|
|
6
|
+
import { jsxs, jsx } from 'react/jsx-runtime';
|
|
7
|
+
|
|
8
|
+
var HOUSE_TIMES = [0, 0.16, 0.44, 0.72, 1];
|
|
9
|
+
var houseRoof = {
|
|
10
|
+
rest: { y: 0, scaleY: 1, transition: returnTransition },
|
|
11
|
+
active: { y: 0, scaleY: 1, transition: returnTransition },
|
|
12
|
+
play: {
|
|
13
|
+
// Dips into the walls, launches 9 units clear and stretches, falls, lands
|
|
14
|
+
// 0.9 low onto walls that compress under it, settles.
|
|
15
|
+
y: [0, 1.5, -9, 0.9, 0],
|
|
16
|
+
scaleY: [1, 0.92, 1.06, 0.86, 1],
|
|
17
|
+
transition: beats(HOUSE_TIMES)
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
var houseWalls = {
|
|
21
|
+
rest: { scaleY: 1, transition: returnTransition },
|
|
22
|
+
active: { scaleY: 1, transition: returnTransition },
|
|
23
|
+
play: { scaleY: [1, 1, 1, 0.9, 1], transition: beats(HOUSE_TIMES) }
|
|
24
|
+
};
|
|
25
|
+
function MotionHouse(props) {
|
|
26
|
+
return /* @__PURE__ */ jsxs(MotionIcon, __spreadProps(__spreadValues({}, props), { children: [
|
|
27
|
+
/* @__PURE__ */ jsx(
|
|
28
|
+
motion.path,
|
|
29
|
+
{
|
|
30
|
+
variants: houseRoof,
|
|
31
|
+
style: { originX: "12px", originY: "10px" },
|
|
32
|
+
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"
|
|
33
|
+
}
|
|
34
|
+
),
|
|
35
|
+
/* @__PURE__ */ jsxs(motion.g, { variants: houseWalls, style: { originX: "12px", originY: "21px" }, children: [
|
|
36
|
+
/* @__PURE__ */ jsx("path", { d: "M21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V10" }),
|
|
37
|
+
/* @__PURE__ */ jsx("path", { d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" })
|
|
38
|
+
] })
|
|
39
|
+
] }));
|
|
40
|
+
}
|
|
41
|
+
var UTENSILS_TIMES = [0, 0.18, 0.72, 1];
|
|
42
|
+
var SEPARATION = 5;
|
|
43
|
+
var fork = {
|
|
44
|
+
rest: { rotate: 0, x: 0, transition: returnTransition },
|
|
45
|
+
active: { rotate: -44, x: -SEPARATION, transition: returnTransition },
|
|
46
|
+
play: {
|
|
47
|
+
rotate: [0, 6, -50, -44],
|
|
48
|
+
x: [0, 0.8, -6, -SEPARATION],
|
|
49
|
+
transition: beats(UTENSILS_TIMES, 0.6)
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
var knife = {
|
|
53
|
+
rest: { rotate: 0, x: 0, transition: returnTransition },
|
|
54
|
+
active: { rotate: 45, x: SEPARATION, transition: returnTransition },
|
|
55
|
+
play: {
|
|
56
|
+
rotate: [0, -6, 51, 45],
|
|
57
|
+
x: [0, -0.8, 6, SEPARATION],
|
|
58
|
+
transition: beats(UTENSILS_TIMES, 0.6)
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
var CROSSING = { originX: "12px", originY: "12px" };
|
|
62
|
+
function MotionUtensils(props) {
|
|
63
|
+
return /* @__PURE__ */ jsxs(MotionIcon, __spreadProps(__spreadValues({}, props), { children: [
|
|
64
|
+
/* @__PURE__ */ jsx(
|
|
65
|
+
motion.path,
|
|
66
|
+
{
|
|
67
|
+
variants: knife,
|
|
68
|
+
style: CROSSING,
|
|
69
|
+
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"
|
|
70
|
+
}
|
|
71
|
+
),
|
|
72
|
+
/* @__PURE__ */ jsxs(motion.g, { variants: fork, style: CROSSING, children: [
|
|
73
|
+
/* @__PURE__ */ jsx("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" }),
|
|
74
|
+
/* @__PURE__ */ jsx("path", { d: "m2.1 21.8 6.4-6.3" }),
|
|
75
|
+
/* @__PURE__ */ jsx("path", { d: "m19 5-7 7" })
|
|
76
|
+
] })
|
|
77
|
+
] }));
|
|
78
|
+
}
|
|
79
|
+
var PIN_TIMES = [0, 0.16, 0.44, 0.72, 1];
|
|
80
|
+
var pinBody = {
|
|
81
|
+
rest: { y: 0, scaleY: 1, transition: returnTransition },
|
|
82
|
+
active: { y: 0, scaleY: 1, transition: returnTransition },
|
|
83
|
+
play: {
|
|
84
|
+
y: [0, 1.2, -9, 0, 0],
|
|
85
|
+
scaleY: [1, 0.86, 1.08, 0.84, 1],
|
|
86
|
+
transition: beats(PIN_TIMES)
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
var pinDot = {
|
|
90
|
+
rest: { fillOpacity: 0, scale: 1, transition: { duration: 0.18 } },
|
|
91
|
+
active: { fillOpacity: 1, scale: 1, transition: { duration: 0.18 } },
|
|
92
|
+
play: {
|
|
93
|
+
fillOpacity: [0, 0, 0, 1, 1],
|
|
94
|
+
scale: [1, 1, 1, 1.35, 1],
|
|
95
|
+
transition: beats(PIN_TIMES)
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
function MotionMapPin(props) {
|
|
99
|
+
return /* @__PURE__ */ jsx(MotionIcon, __spreadProps(__spreadValues({}, props), { children: /* @__PURE__ */ jsxs(motion.g, { variants: pinBody, style: { originX: "12px", originY: "22px" }, children: [
|
|
100
|
+
/* @__PURE__ */ jsx("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" }),
|
|
101
|
+
/* @__PURE__ */ jsx(
|
|
102
|
+
motion.circle,
|
|
103
|
+
{
|
|
104
|
+
variants: pinDot,
|
|
105
|
+
style: { originX: "12px", originY: "10px" },
|
|
106
|
+
fill: "currentColor",
|
|
107
|
+
fillOpacity: 0,
|
|
108
|
+
cx: "12",
|
|
109
|
+
cy: "10",
|
|
110
|
+
r: "3"
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
] }) }));
|
|
114
|
+
}
|
|
115
|
+
var ROWS = [14, 18, 22, 26, 30, 34];
|
|
116
|
+
var COLS = [8, 12, 16];
|
|
117
|
+
var reel = {
|
|
118
|
+
rest: { y: 0, transition: returnTransition },
|
|
119
|
+
active: { y: 0, transition: returnTransition },
|
|
120
|
+
play: {
|
|
121
|
+
// Exactly four rows (16 units, ~15px), so arriving rows land where the
|
|
122
|
+
// departing ones began and the end pose is identical to the rest pose.
|
|
123
|
+
y: [0, -4, -8, -12, -17, -16],
|
|
124
|
+
transition: beats([0, 0.18, 0.34, 0.5, 0.85, 1], 0.65)
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
function MotionCalendar(props) {
|
|
128
|
+
const clipId = `cal-body-${React.useId().replace(/:/g, "")}`;
|
|
129
|
+
return /* @__PURE__ */ jsxs(MotionIcon, __spreadProps(__spreadValues({}, props), { children: [
|
|
130
|
+
/* @__PURE__ */ jsx("defs", { children: /* @__PURE__ */ jsx("clipPath", { id: clipId, children: /* @__PURE__ */ jsx("rect", { x: "3.5", y: "10.5", width: "17", height: "11", rx: "1.5" }) }) }),
|
|
131
|
+
/* @__PURE__ */ jsx("path", { d: "M8 2v4" }),
|
|
132
|
+
/* @__PURE__ */ jsx("path", { d: "M16 2v4" }),
|
|
133
|
+
/* @__PURE__ */ jsx("rect", { width: "18", height: "18", x: "3", y: "4", rx: "2" }),
|
|
134
|
+
/* @__PURE__ */ jsx("path", { d: "M3 10h18" }),
|
|
135
|
+
/* @__PURE__ */ jsx("g", { clipPath: `url(#${clipId})`, children: /* @__PURE__ */ jsx(motion.g, { variants: reel, children: ROWS.map((y) => COLS.map((x) => /* @__PURE__ */ jsx("path", { d: `M${x} ${y}h.01` }, `${x}-${y}`))) }) })
|
|
136
|
+
] }));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export { MotionCalendar, MotionHouse, MotionMapPin, MotionUtensils };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import * as framer_motion from 'framer-motion';
|
|
3
|
+
import { Transition, Variants } from 'framer-motion';
|
|
4
|
+
import * as React from 'react';
|
|
5
|
+
|
|
6
|
+
type MotionIconProps = {
|
|
7
|
+
/** The held state. Drives `active` vs `rest`, and the stroke weight. */
|
|
8
|
+
active?: boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Bump on pointer down to perform the gesture. Changing this value replays
|
|
11
|
+
* `play` even when `active` has not changed, which is what makes the icon
|
|
12
|
+
* respond to the touch rather than to its consequence.
|
|
13
|
+
*/
|
|
14
|
+
replayToken?: number;
|
|
15
|
+
className?: string;
|
|
16
|
+
};
|
|
17
|
+
type MotionIconVariant = "rest" | "active" | "play";
|
|
18
|
+
/**
|
|
19
|
+
* A gesture is a performance with beats, so it is a tween with explicit
|
|
20
|
+
* `times`. A spring interpolates two keyframes and puts its overshoot at the
|
|
21
|
+
* end, which cannot express anticipation.
|
|
22
|
+
*/
|
|
23
|
+
declare function beats(times: number[], duration?: number): Transition;
|
|
24
|
+
/** Leaving is not a performance — it returns the glyph, firmly and fast. */
|
|
25
|
+
declare const returnTransition: Transition;
|
|
26
|
+
type PoseValues = Record<string, number>;
|
|
27
|
+
/**
|
|
28
|
+
* Builds the three variants for a moving part.
|
|
29
|
+
* `play` values are keyframe arrays; `rest`/`active` are the two held poses.
|
|
30
|
+
*/
|
|
31
|
+
declare function poses(rest: PoseValues, active: PoseValues, play: Record<string, number[]>, transition: Transition): Variants;
|
|
32
|
+
/**
|
|
33
|
+
* Mount poses without playing; pointer down plays; arriving plays unless the
|
|
34
|
+
* pointer already started it; leaving returns.
|
|
35
|
+
*
|
|
36
|
+
* Both effects compare against a ref rather than trusting the dependency array,
|
|
37
|
+
* so they fire once per actual change and not once per render.
|
|
38
|
+
*/
|
|
39
|
+
declare function useIconGesture(active: boolean, replayToken: number): framer_motion.LegacyAnimationControls;
|
|
40
|
+
declare function MotionIcon({ active, replayToken, className, children, }: MotionIconProps & {
|
|
41
|
+
children: React.ReactNode;
|
|
42
|
+
}): react_jsx_runtime.JSX.Element;
|
|
43
|
+
|
|
44
|
+
export { MotionIcon, type MotionIconProps, type MotionIconVariant, beats, poses, returnTransition, useIconGesture };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { LucideIcon } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The purpose → glyph registry.
|
|
5
|
+
*
|
|
6
|
+
* One place recording which glyph means what. Before this existed, ~10 ad-hoc
|
|
7
|
+
* glyph maps were scattered across the app and the same purpose ended up drawn
|
|
8
|
+
* two different ways: back as both an arrow and a chevron, "open filters" as
|
|
9
|
+
* both a funnel and sliders (one surface used both, chosen by which view you
|
|
10
|
+
* were in), clear and close sharing a bare X.
|
|
11
|
+
*
|
|
12
|
+
* Only GENERIC action vocabulary lives here. Product taxonomy — cuisine
|
|
13
|
+
* shortcuts, facet dimensions, digest categories — stays in the consuming app.
|
|
14
|
+
*
|
|
15
|
+
* ## The rules this encodes
|
|
16
|
+
*
|
|
17
|
+
* 1. **Arrow leaves, chevron stays.** `ArrowRight` means "you are going to a new
|
|
18
|
+
* destination" (see-all links, banner CTAs). `ChevronRight` means "reveal
|
|
19
|
+
* more, in place" and is scoped to exactly two jobs: carousel paging and
|
|
20
|
+
* row-contained drill-in. Collapsing these is what produced a glyph with
|
|
21
|
+
* four meanings.
|
|
22
|
+
* 2. **Sliders, not a funnel.** A funnel reads as "narrow a list" and collides
|
|
23
|
+
* with search and sort iconography at 24px; three sliders read
|
|
24
|
+
* unambiguously as "adjust parameters" and carry a badge count cleanly.
|
|
25
|
+
* 3. **Containment separates clear from close.** A bare `X` leaves a layer. A
|
|
26
|
+
* contained `XCircle` resets a value in place without leaving. In a dense
|
|
27
|
+
* filter row, bare Xs beside each other cannot say which layer they close.
|
|
28
|
+
* 4. **Tappable has a container or a colour state; information is muted and
|
|
29
|
+
* bare.** This is what disambiguates the overloaded glyphs (`MapPin`,
|
|
30
|
+
* `Star`) rather than picking different glyphs for each sense. An
|
|
31
|
+
* interactive instance sits in a chip or button and takes an active colour;
|
|
32
|
+
* a decorative one is `text-muted-foreground`, `aria-hidden`, outside any
|
|
33
|
+
* button, with no hover or active transition.
|
|
34
|
+
* 5. **Loved and want-to-try are two actions, not one.** `Heart` is
|
|
35
|
+
* retrospective ("I have had this and I love it"), `Bookmark` is prospective
|
|
36
|
+
* ("I want to try this"). They feed different lists and are mutually
|
|
37
|
+
* exclusive per item.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
type IconPurpose = "back" | "seeAll" | "drillIn" | "carouselPrev" | "carouselNext" | "directions" | "externalLink" | "close" | "clear" | "expand" | "filter" | "sort" | "search" | "share" | "favorite" | "wantToTry" | "confirm" | "add" | "remove" | "refresh" | "call" | "report" | "info";
|
|
41
|
+
declare const iconFor: Record<IconPurpose, LucideIcon>;
|
|
42
|
+
/**
|
|
43
|
+
* Purposes whose glyph must rotate to show state, and by how much.
|
|
44
|
+
* `expand` is the disclosure chevron: pointing down at rest, up when open.
|
|
45
|
+
*/
|
|
46
|
+
declare const iconRotation: Partial<Record<IconPurpose, number>>;
|
|
47
|
+
/**
|
|
48
|
+
* Retired glyphs and what replaced them. Kept so a reviewer can tell a
|
|
49
|
+
* deliberate choice from a regression, and so lint/codemods have a source.
|
|
50
|
+
*/
|
|
51
|
+
declare const retiredIcons: {
|
|
52
|
+
readonly Filter: "filter → SlidersHorizontal";
|
|
53
|
+
readonly ChevronRightAsSeeAll: "seeAll → ArrowRight";
|
|
54
|
+
readonly ChevronLeftAsBack: "back → ArrowLeft";
|
|
55
|
+
readonly XAsClear: "clear → XCircle";
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export { type IconPurpose, iconFor, iconRotation, retiredIcons };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import '../chunk-3GWWZKX7.js';
|
|
2
|
+
import { Info, Flag, Phone, RefreshCw, Trash2, Plus, Check, Bookmark, Heart, Share2, Search, ArrowUpDown, SlidersHorizontal, ChevronDown, XCircle, X, ExternalLink, Navigation, ChevronRight, ChevronLeft, ArrowRight, ArrowLeft } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
var iconFor = {
|
|
5
|
+
back: ArrowLeft,
|
|
6
|
+
seeAll: ArrowRight,
|
|
7
|
+
drillIn: ChevronRight,
|
|
8
|
+
carouselPrev: ChevronLeft,
|
|
9
|
+
carouselNext: ChevronRight,
|
|
10
|
+
directions: Navigation,
|
|
11
|
+
externalLink: ExternalLink,
|
|
12
|
+
close: X,
|
|
13
|
+
clear: XCircle,
|
|
14
|
+
expand: ChevronDown,
|
|
15
|
+
filter: SlidersHorizontal,
|
|
16
|
+
sort: ArrowUpDown,
|
|
17
|
+
search: Search,
|
|
18
|
+
share: Share2,
|
|
19
|
+
favorite: Heart,
|
|
20
|
+
wantToTry: Bookmark,
|
|
21
|
+
confirm: Check,
|
|
22
|
+
add: Plus,
|
|
23
|
+
remove: Trash2,
|
|
24
|
+
refresh: RefreshCw,
|
|
25
|
+
call: Phone,
|
|
26
|
+
report: Flag,
|
|
27
|
+
info: Info
|
|
28
|
+
};
|
|
29
|
+
var iconRotation = {
|
|
30
|
+
expand: 180
|
|
31
|
+
};
|
|
32
|
+
var retiredIcons = {
|
|
33
|
+
Filter: "filter \u2192 SlidersHorizontal",
|
|
34
|
+
ChevronRightAsSeeAll: "seeAll \u2192 ArrowRight",
|
|
35
|
+
ChevronLeftAsBack: "back \u2192 ArrowLeft",
|
|
36
|
+
XAsClear: "clear \u2192 XCircle"
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export { iconFor, iconRotation, retiredIcons };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bearmenu/ui",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.4",
|
|
4
4
|
"description": "BearMenu design system — shared UI tokens, primitives, and components",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -102,6 +102,7 @@
|
|
|
102
102
|
"@radix-ui/react-tooltip": "^1.2.8",
|
|
103
103
|
"@storybook/addon-a11y": "^10.3.5",
|
|
104
104
|
"@storybook/addon-docs": "^10.3.5",
|
|
105
|
+
"@storybook/react": "^10.3.5",
|
|
105
106
|
"@storybook/react-vite": "^10.3.5",
|
|
106
107
|
"@tailwindcss/postcss": "^4.2.4",
|
|
107
108
|
"@tailwindcss/vite": "^4.2.4",
|
|
@@ -9,7 +9,7 @@ const meta = {
|
|
|
9
9
|
} satisfies Meta<typeof Combobox>;
|
|
10
10
|
|
|
11
11
|
export default meta;
|
|
12
|
-
type Story = StoryObj<typeof
|
|
12
|
+
type Story = StoryObj<typeof Combobox>;
|
|
13
13
|
|
|
14
14
|
const frameworks: ComboboxOption[] = [
|
|
15
15
|
{ value: "next", label: "Next.js", description: "React framework" },
|
|
@@ -3,14 +3,14 @@ import type { Meta, StoryObj } from "@storybook/react";
|
|
|
3
3
|
import { CurrencyInput } from "./currency-input";
|
|
4
4
|
import { Label } from "./label";
|
|
5
5
|
|
|
6
|
-
const meta = {
|
|
6
|
+
const meta: Meta<typeof CurrencyInput> = {
|
|
7
7
|
title: "Components/CurrencyInput",
|
|
8
8
|
component: CurrencyInput,
|
|
9
9
|
tags: ["autodocs"],
|
|
10
|
-
}
|
|
10
|
+
};
|
|
11
11
|
|
|
12
12
|
export default meta;
|
|
13
|
-
type Story = StoryObj<typeof
|
|
13
|
+
type Story = StoryObj<typeof CurrencyInput>;
|
|
14
14
|
|
|
15
15
|
function CurrencyInputDemo() {
|
|
16
16
|
const [value, setValue] = useState<number | null>(null);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { EmptyState, SearchEmptyState, ErrorEmptyState } from "./empty-state";
|
|
3
3
|
|
|
4
|
-
const meta = {
|
|
4
|
+
const meta: Meta<typeof EmptyState> = {
|
|
5
5
|
title: "Components/EmptyState",
|
|
6
6
|
component: EmptyState,
|
|
7
7
|
tags: ["autodocs"],
|
|
@@ -15,10 +15,10 @@ const meta = {
|
|
|
15
15
|
options: ["sm", "md", "lg"],
|
|
16
16
|
},
|
|
17
17
|
},
|
|
18
|
-
}
|
|
18
|
+
};
|
|
19
19
|
|
|
20
20
|
export default meta;
|
|
21
|
-
type Story = StoryObj<typeof
|
|
21
|
+
type Story = StoryObj<typeof EmptyState>;
|
|
22
22
|
|
|
23
23
|
export const Default: Story = {
|
|
24
24
|
args: {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Icon — the static iconography contract.
|
|
5
|
+
*
|
|
6
|
+
* - `strokeWidth` 1.75, locking the global stroke to a refined-but-robust weight
|
|
7
|
+
* - Three canonical sizes:
|
|
8
|
+
* "xs" → 12px inline with small text, filter pills, badge labels
|
|
9
|
+
* "sm" → 16px default: buttons, nav rows, card metadata
|
|
10
|
+
* "md" → 20px nav tabs, dialog headers
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* import { Icon } from "@bearmenu/ui/components/icon";
|
|
14
|
+
* <Icon icon={Star} size="sm" className="text-primary fill-primary" aria-hidden />
|
|
15
|
+
*
|
|
16
|
+
* Raw lucide imports remain acceptable where all three of strokeWidth, sizing
|
|
17
|
+
* and colouring are intentionally non-standard (an illustrated 48px empty
|
|
18
|
+
* state, a map marker). In those cases the caller sets `strokeWidth` itself.
|
|
19
|
+
*
|
|
20
|
+
* For icons that perform a gesture on interaction, see `motion-icon.tsx`.
|
|
21
|
+
* Which glyph means what is recorded in `lib/icon-registry.ts`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { type LucideIcon, type LucideProps } from "lucide-react";
|
|
25
|
+
|
|
26
|
+
import { cn } from "../lib/utils";
|
|
27
|
+
|
|
28
|
+
const SIZE_CLASSES = {
|
|
29
|
+
xs: "w-3 h-3",
|
|
30
|
+
sm: "w-4 h-4",
|
|
31
|
+
md: "w-5 h-5",
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
export type IconSize = keyof typeof SIZE_CLASSES;
|
|
35
|
+
|
|
36
|
+
export type IconProps = Omit<LucideProps, "size"> & {
|
|
37
|
+
/** The lucide icon component to render. */
|
|
38
|
+
icon: LucideIcon;
|
|
39
|
+
/** Canonical size stop. "xs" = 12px | "sm" = 16px (default) | "md" = 20px */
|
|
40
|
+
size?: IconSize;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function Icon({
|
|
44
|
+
icon: LucideIconComponent,
|
|
45
|
+
size = "sm",
|
|
46
|
+
strokeWidth = 1.75,
|
|
47
|
+
className,
|
|
48
|
+
...props
|
|
49
|
+
}: IconProps) {
|
|
50
|
+
return (
|
|
51
|
+
<LucideIconComponent
|
|
52
|
+
strokeWidth={strokeWidth}
|
|
53
|
+
className={cn(SIZE_CLASSES[size], className)}
|
|
54
|
+
{...props}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { render } from "@testing-library/react";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* First-paint correctness.
|
|
7
|
+
*
|
|
8
|
+
* `MotionIcon` poses itself with `controls.set()` inside an effect, which runs
|
|
9
|
+
* AFTER the first paint. Any sub-part whose rest pose is hidden therefore needs
|
|
10
|
+
* a matching static SVG attribute, or it paints one frame fully visible — a
|
|
11
|
+
* flash of a drawn check on every unselected row of a selection list, or of a
|
|
12
|
+
* moon behind a sun.
|
|
13
|
+
*
|
|
14
|
+
* The mock renders variants inertly, so what these assertions see is exactly
|
|
15
|
+
* what the browser paints before framer takes over.
|
|
16
|
+
*/
|
|
17
|
+
vi.mock("framer-motion", () => {
|
|
18
|
+
const passthrough =
|
|
19
|
+
(tag: string) =>
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
+
({ variants: _v, style: _s, ...rest }: any) =>
|
|
22
|
+
React.createElement(tag, rest);
|
|
23
|
+
return {
|
|
24
|
+
useAnimationControls: () => ({ set: () => {}, start: () => Promise.resolve() }),
|
|
25
|
+
motion: {
|
|
26
|
+
svg: passthrough("svg"),
|
|
27
|
+
g: passthrough("g"),
|
|
28
|
+
path: passthrough("path"),
|
|
29
|
+
circle: passthrough("circle"),
|
|
30
|
+
rect: passthrough("rect"),
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
MotionCheck,
|
|
37
|
+
MotionEye,
|
|
38
|
+
MotionCopy,
|
|
39
|
+
MotionTheme,
|
|
40
|
+
MotionSearch,
|
|
41
|
+
MotionPlayPause,
|
|
42
|
+
MotionHeart,
|
|
43
|
+
MotionBookmark,
|
|
44
|
+
MotionStar,
|
|
45
|
+
} from "./motion-icon-controls";
|
|
46
|
+
|
|
47
|
+
/** Every element that should be invisible until the icon becomes active. */
|
|
48
|
+
const HIDDEN_AT_REST: Array<[string, React.ComponentType<Record<string, never>>, number]> = [
|
|
49
|
+
["MotionCheck", MotionCheck, 1],
|
|
50
|
+
["MotionEye", MotionEye, 1],
|
|
51
|
+
["MotionCopy", MotionCopy, 1],
|
|
52
|
+
["MotionTheme", MotionTheme, 1],
|
|
53
|
+
["MotionSearch", MotionSearch, 1],
|
|
54
|
+
["MotionPlayPause", MotionPlayPause, 2],
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
describe("first paint, before the pose effect runs", () => {
|
|
58
|
+
it.each(HIDDEN_AT_REST)(
|
|
59
|
+
"%s hides its active-only parts with a static attribute",
|
|
60
|
+
(_name, Icon, expectedHidden) => {
|
|
61
|
+
const { container } = render(<Icon />);
|
|
62
|
+
const hidden = container.querySelectorAll('[opacity="0"]');
|
|
63
|
+
expect(hidden.length).toBe(expectedHidden);
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
it.each([
|
|
68
|
+
["MotionHeart", MotionHeart],
|
|
69
|
+
["MotionBookmark", MotionBookmark],
|
|
70
|
+
["MotionStar", MotionStar],
|
|
71
|
+
] as Array<[string, React.ComponentType<Record<string, never>>]>)(
|
|
72
|
+
"%s starts unfilled rather than solid",
|
|
73
|
+
(_name, Icon) => {
|
|
74
|
+
const { container } = render(<Icon />);
|
|
75
|
+
expect(container.querySelector('[fill-opacity="0"]')).not.toBeNull();
|
|
76
|
+
}
|
|
77
|
+
);
|
|
78
|
+
});
|