@olwiba/ui 0.2.9 → 0.2.11
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/index.d.ts +53 -1
- package/dist/index.js +255 -130
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/app/AppShell.tsx +31 -3
- package/src/components/PricingCard.tsx +7 -1
- package/src/index.ts +6 -0
- package/src/marketing/PricingSection.tsx +7 -2
- package/src/motion/AnimatedSwap.tsx +200 -0
package/package.json
CHANGED
package/src/app/AppShell.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
Avatar,
|
|
14
14
|
AvatarFallback,
|
|
15
15
|
AvatarImage,
|
|
16
|
+
cn,
|
|
16
17
|
DropdownMenu,
|
|
17
18
|
DropdownMenuContent,
|
|
18
19
|
DropdownMenuGroup,
|
|
@@ -123,6 +124,23 @@ export interface AppShellProps {
|
|
|
123
124
|
|
|
124
125
|
// ─── Internal sub-components ──────────────────────────────────────────────────
|
|
125
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Shared by the two `size="lg"` buttons that bookend the sidebar (brand, user).
|
|
129
|
+
* Both hold a 32px square — a logo badge, an avatar — so in the collapsed rail
|
|
130
|
+
* they shrink to exactly that instead of the size's default 48px.
|
|
131
|
+
*
|
|
132
|
+
* `transition-[width,height]` drops `padding` from the transition while
|
|
133
|
+
* collapsed, and is load-bearing: the base variant animates padding, but
|
|
134
|
+
* `!max-w-8` clamps the button to 32px instantly (max-width isn't
|
|
135
|
+
* transitionable), so a padding still animating 16px→0 leaves a 0px-wide
|
|
136
|
+
* content box on the first frame and `overflow-hidden` clips the corner off
|
|
137
|
+
* the 32px badge for the whole 150ms. Snapping padding makes it fit from frame
|
|
138
|
+
* one. Expanding is deliberately left on the base transition — there width and
|
|
139
|
+
* padding animate together and the content box never drops below 32px.
|
|
140
|
+
*/
|
|
141
|
+
const RAIL_SQUARE =
|
|
142
|
+
'group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!min-w-8 group-data-[collapsible=icon]:!max-w-8 group-data-[collapsible=icon]:!p-0 group-data-[collapsible=icon]:transition-[width,height]';
|
|
143
|
+
|
|
126
144
|
function NavUser({ user }: { user: AppShellUser }) {
|
|
127
145
|
const { isMobile } = useSidebar();
|
|
128
146
|
const uiMode = useUIMode();
|
|
@@ -136,7 +154,14 @@ function NavUser({ user }: { user: AppShellUser }) {
|
|
|
136
154
|
<DropdownMenuTrigger asChild>
|
|
137
155
|
<SidebarMenuButton
|
|
138
156
|
size="lg"
|
|
139
|
-
|
|
157
|
+
// Collapsed, this is a bare avatar with no affordance saying it
|
|
158
|
+
// opens anything — every other item in the rail has a tooltip,
|
|
159
|
+
// so its absence reads as "not a button".
|
|
160
|
+
tooltip={user.name ?? user.email}
|
|
161
|
+
className={cn(
|
|
162
|
+
'data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground',
|
|
163
|
+
RAIL_SQUARE,
|
|
164
|
+
)}
|
|
140
165
|
>
|
|
141
166
|
<Avatar mode={avatarMode} size="sm" className="grayscale">
|
|
142
167
|
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
|
|
@@ -233,8 +258,11 @@ function ShellSidebar({
|
|
|
233
258
|
<SidebarMenuButton
|
|
234
259
|
asChild
|
|
235
260
|
size="lg"
|
|
236
|
-
tooltip
|
|
237
|
-
|
|
261
|
+
// No tooltip: unlike a nav item, the collapsed rail's logo is
|
|
262
|
+
// still the logo — a hover label repeating the app's own name
|
|
263
|
+
// is noise. Nav items keep theirs because a bare icon doesn't
|
|
264
|
+
// say where it goes.
|
|
265
|
+
className={RAIL_SQUARE}
|
|
238
266
|
>
|
|
239
267
|
{renderLink({
|
|
240
268
|
href: brand.href ?? '#',
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as React from 'react';
|
|
4
4
|
import { Check, Minus } from 'lucide-react';
|
|
5
5
|
import { Badge, Button, cn, Separator, useUIVariant } from '@olwiba/cn';
|
|
6
|
+
import { AnimatedSwap } from '../motion/AnimatedSwap';
|
|
6
7
|
|
|
7
8
|
export interface PricingFeature {
|
|
8
9
|
label: string;
|
|
@@ -68,7 +69,12 @@ export function PricingCard({
|
|
|
68
69
|
<div className="space-y-1">
|
|
69
70
|
<div className="text-sm font-medium text-muted-foreground">{name}</div>
|
|
70
71
|
<div className="flex items-end gap-1">
|
|
71
|
-
|
|
72
|
+
{/* Animates when a billing-cadence toggle swaps the price out. The
|
|
73
|
+
price is its own swap key: a card whose price never changes never
|
|
74
|
+
animates. */}
|
|
75
|
+
<AnimatedSwap swapKey={price} className="text-4xl font-bold tracking-tight">
|
|
76
|
+
{price}
|
|
77
|
+
</AnimatedSwap>
|
|
72
78
|
<span className="mb-1 text-sm text-muted-foreground">{period}</span>
|
|
73
79
|
</div>
|
|
74
80
|
<p className="text-sm text-muted-foreground">{description}</p>
|
package/src/index.ts
CHANGED
|
@@ -111,6 +111,12 @@ export { Overlay, type OverlayProps, type OverlayVariant } from './overlays/Over
|
|
|
111
111
|
export { FadeIn, type FadeInProps } from './motion/FadeIn';
|
|
112
112
|
export { StaggerChildren, type StaggerChildrenProps } from './motion/StaggerChildren';
|
|
113
113
|
export { CountUp, type CountUpProps } from './motion/CountUp';
|
|
114
|
+
export {
|
|
115
|
+
AnimatedSwap,
|
|
116
|
+
type AnimatedSwapProps,
|
|
117
|
+
type AnimatedSwapEffect,
|
|
118
|
+
type AnimatedSwapSpec,
|
|
119
|
+
} from './motion/AnimatedSwap';
|
|
114
120
|
export { CountdownTimer, type CountdownTimerProps } from './motion/CountdownTimer';
|
|
115
121
|
export { AnimatedPill, type AnimatedPillProps } from './motion/AnimatedPill';
|
|
116
122
|
export { PageTransition, type PageTransitionProps } from './motion/PageTransition';
|
|
@@ -229,7 +229,12 @@ export function PricingSection({
|
|
|
229
229
|
>
|
|
230
230
|
{entry.label}
|
|
231
231
|
{saveBadges[entry.key] && (
|
|
232
|
-
|
|
232
|
+
// `default` (primary), not `secondary`: the toggle is
|
|
233
|
+
// `bg-muted` with a `bg-background` active tab, and a
|
|
234
|
+
// secondary badge is the same grey as both — the
|
|
235
|
+
// saving is the incentive, so it has to carry brand
|
|
236
|
+
// colour to read at all.
|
|
237
|
+
<Badge variant="default" className="text-xs">
|
|
233
238
|
{saveBadges[entry.key]}
|
|
234
239
|
</Badge>
|
|
235
240
|
)}
|
|
@@ -259,7 +264,7 @@ export function PricingSection({
|
|
|
259
264
|
>
|
|
260
265
|
Annual
|
|
261
266
|
{saveBadge && (
|
|
262
|
-
<Badge variant="
|
|
267
|
+
<Badge variant="default" className="text-xs">{saveBadge}</Badge>
|
|
263
268
|
)}
|
|
264
269
|
</Button>
|
|
265
270
|
</>
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { cn } from '@olwiba/cn';
|
|
5
|
+
import { useUIMode, type UIMode } from '../context/OlwibaUIContext';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A transition is pure data: two layers, each with a start and an end style.
|
|
9
|
+
* Adding an effect means adding an entry to {@link EFFECTS} — the component
|
|
10
|
+
* itself never learns what "roll" means. Consumers can pass a spec object
|
|
11
|
+
* directly for anything not in the set.
|
|
12
|
+
*/
|
|
13
|
+
export interface AnimatedSwapSpec {
|
|
14
|
+
/** Hide overflow on the container. Required by effects that travel further than they fade. */
|
|
15
|
+
clip: boolean;
|
|
16
|
+
duration: number;
|
|
17
|
+
easing: string;
|
|
18
|
+
/** The incoming layer: where it starts, where it lands. */
|
|
19
|
+
enter: { from: React.CSSProperties; to: React.CSSProperties };
|
|
20
|
+
/** The outgoing layer: where it starts, where it leaves. */
|
|
21
|
+
exit: { from: React.CSSProperties; to: React.CSSProperties };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type AnimatedSwapEffect = 'roll' | 'fade' | 'slide' | 'none';
|
|
25
|
+
|
|
26
|
+
const EFFECTS: Record<Exclude<AnimatedSwapEffect, 'none'>, AnimatedSwapSpec> = {
|
|
27
|
+
// Odometer. No opacity at all — the two values are solid and one physically
|
|
28
|
+
// displaces the other, which is what makes it read as a mechanism rather
|
|
29
|
+
// than a crossfade. Needs `clip`, or the departing value is legible above
|
|
30
|
+
// the container the whole way out.
|
|
31
|
+
roll: {
|
|
32
|
+
clip: true,
|
|
33
|
+
duration: 320,
|
|
34
|
+
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
|
35
|
+
enter: { from: { transform: 'translateY(100%)' }, to: { transform: 'translateY(0)' } },
|
|
36
|
+
exit: { from: { transform: 'translateY(0)' }, to: { transform: 'translateY(-100%)' } },
|
|
37
|
+
},
|
|
38
|
+
// The neutral one. No movement, so it never fights a layout that's also
|
|
39
|
+
// reflowing around it.
|
|
40
|
+
fade: {
|
|
41
|
+
clip: false,
|
|
42
|
+
duration: 220,
|
|
43
|
+
easing: 'ease',
|
|
44
|
+
enter: { from: { opacity: 0 }, to: { opacity: 1 } },
|
|
45
|
+
exit: { from: { opacity: 1 }, to: { opacity: 0 } },
|
|
46
|
+
},
|
|
47
|
+
// Horizontal, and short: 0.75rem of travel carried by opacity, so it needs
|
|
48
|
+
// no clipping and won't crop a ring or drop shadow on whatever it wraps.
|
|
49
|
+
slide: {
|
|
50
|
+
clip: false,
|
|
51
|
+
duration: 280,
|
|
52
|
+
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
|
53
|
+
enter: {
|
|
54
|
+
from: { opacity: 0, transform: 'translateX(0.75rem)' },
|
|
55
|
+
to: { opacity: 1, transform: 'translateX(0)' },
|
|
56
|
+
},
|
|
57
|
+
exit: {
|
|
58
|
+
from: { opacity: 1, transform: 'translateX(0)' },
|
|
59
|
+
to: { opacity: 0, transform: 'translateX(-0.75rem)' },
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Mode picks the effect when the caller doesn't. An explicit `effect` always wins. */
|
|
65
|
+
const MODE_EFFECT: Record<UIMode, AnimatedSwapEffect> = {
|
|
66
|
+
default: 'fade',
|
|
67
|
+
playful: 'roll',
|
|
68
|
+
smooth: 'slide',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export interface AnimatedSwapProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
72
|
+
/**
|
|
73
|
+
* Changing this is the swap. Nothing else triggers one — the component
|
|
74
|
+
* can't see inside `children`, so re-rendering with different content under
|
|
75
|
+
* the same key updates in place with no animation.
|
|
76
|
+
*/
|
|
77
|
+
swapKey: React.Key;
|
|
78
|
+
/** Named effect, or a spec of your own. Defaults from the current UI mode. */
|
|
79
|
+
effect?: AnimatedSwapEffect | AnimatedSwapSpec;
|
|
80
|
+
/** Override the effect's duration, in ms. */
|
|
81
|
+
duration?: number;
|
|
82
|
+
children: React.ReactNode;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Animates the replacement of arbitrary content when `swapKey` changes: a
|
|
87
|
+
* price when the billing cadence toggles, a chart when the range changes, a
|
|
88
|
+
* status pill when a job finishes.
|
|
89
|
+
*
|
|
90
|
+
* Both versions are mounted for the length of the transition. The incoming
|
|
91
|
+
* one stays in normal flow so the container sizes to it immediately; the
|
|
92
|
+
* outgoing one is lifted out of flow and overlaid, so content below settles
|
|
93
|
+
* once, at the start, under cover of the animation — rather than snapping
|
|
94
|
+
* when the old copy unmounts.
|
|
95
|
+
*
|
|
96
|
+
* CSS transitions only: no keyframes to register in a consumer's Tailwind
|
|
97
|
+
* config, and no motion library.
|
|
98
|
+
*/
|
|
99
|
+
export function AnimatedSwap({
|
|
100
|
+
swapKey,
|
|
101
|
+
effect,
|
|
102
|
+
duration,
|
|
103
|
+
children,
|
|
104
|
+
className,
|
|
105
|
+
style,
|
|
106
|
+
...props
|
|
107
|
+
}: AnimatedSwapProps) {
|
|
108
|
+
const mode = useUIMode();
|
|
109
|
+
const resolved = effect ?? MODE_EFFECT[mode];
|
|
110
|
+
const spec = typeof resolved !== 'string' ? resolved : resolved === 'none' ? null : EFFECTS[resolved];
|
|
111
|
+
const ms = duration ?? spec?.duration ?? 0;
|
|
112
|
+
|
|
113
|
+
const reduced = usePrefersReducedMotion();
|
|
114
|
+
const animated = spec !== null && !reduced;
|
|
115
|
+
|
|
116
|
+
// What was on screen last commit. Read during the render that notices the
|
|
117
|
+
// key changed — by then `children` is already the new content, so the
|
|
118
|
+
// outgoing copy can only come from here.
|
|
119
|
+
const previous = React.useRef(children);
|
|
120
|
+
const seenKey = React.useRef(swapKey);
|
|
121
|
+
|
|
122
|
+
const [swap, setSwap] = React.useState<{ key: React.Key; out: React.ReactNode; running: boolean } | null>(null);
|
|
123
|
+
|
|
124
|
+
if (seenKey.current !== swapKey) {
|
|
125
|
+
const out = previous.current;
|
|
126
|
+
seenKey.current = swapKey;
|
|
127
|
+
if (animated) setSwap({ key: swapKey, out, running: false });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
React.useEffect(() => {
|
|
131
|
+
previous.current = children;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Mount the incoming layer at its start style first, then flip on the next
|
|
135
|
+
// frame. Committing both in one pass gives the browser no start value to
|
|
136
|
+
// transition from and the swap just snaps.
|
|
137
|
+
React.useEffect(() => {
|
|
138
|
+
if (!swap || swap.running) return;
|
|
139
|
+
const raf = requestAnimationFrame(() => {
|
|
140
|
+
setSwap((s) => (s && s.key === swap.key ? { ...s, running: true } : s));
|
|
141
|
+
});
|
|
142
|
+
return () => cancelAnimationFrame(raf);
|
|
143
|
+
}, [swap]);
|
|
144
|
+
|
|
145
|
+
// Timer rather than onTransitionEnd: an effect may animate a property the
|
|
146
|
+
// wrapped node also transitions on its own, and there's no reliable single
|
|
147
|
+
// event marking the end of both layers.
|
|
148
|
+
React.useEffect(() => {
|
|
149
|
+
if (!swap?.running) return;
|
|
150
|
+
const done = setTimeout(() => {
|
|
151
|
+
setSwap((s) => (s && s.key === swap.key ? null : s));
|
|
152
|
+
}, ms);
|
|
153
|
+
return () => clearTimeout(done);
|
|
154
|
+
}, [swap, ms]);
|
|
155
|
+
|
|
156
|
+
const phase = (layer: 'enter' | 'exit'): React.CSSProperties | undefined => {
|
|
157
|
+
if (!swap || !spec) return undefined;
|
|
158
|
+
return {
|
|
159
|
+
...spec[layer][swap.running ? 'to' : 'from'],
|
|
160
|
+
transition: `transform ${ms}ms ${spec.easing}, opacity ${ms}ms ${spec.easing}`,
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<span
|
|
166
|
+
className={cn('relative inline-block', swap && spec?.clip && 'overflow-hidden', className)}
|
|
167
|
+
style={style}
|
|
168
|
+
{...props}
|
|
169
|
+
>
|
|
170
|
+
<span className="block" style={phase('enter')}>
|
|
171
|
+
{children}
|
|
172
|
+
</span>
|
|
173
|
+
{swap && (
|
|
174
|
+
// Pinned rather than inset-0: stretching the outgoing copy to the
|
|
175
|
+
// incoming one's box would resize it mid-exit.
|
|
176
|
+
<span className="absolute left-0 top-0 block" aria-hidden="true" style={phase('exit')}>
|
|
177
|
+
{swap.out}
|
|
178
|
+
</span>
|
|
179
|
+
)}
|
|
180
|
+
</span>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function usePrefersReducedMotion() {
|
|
185
|
+
// Not read during render on the server: the query is unknowable there, and
|
|
186
|
+
// guessing either way produces a hydration mismatch. Every swap happens
|
|
187
|
+
// after mount, by which point this is settled.
|
|
188
|
+
const [reduced, setReduced] = React.useState(false);
|
|
189
|
+
|
|
190
|
+
React.useEffect(() => {
|
|
191
|
+
const query = window.matchMedia?.('(prefers-reduced-motion: reduce)');
|
|
192
|
+
if (!query) return;
|
|
193
|
+
setReduced(query.matches);
|
|
194
|
+
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
|
|
195
|
+
query.addEventListener('change', onChange);
|
|
196
|
+
return () => query.removeEventListener('change', onChange);
|
|
197
|
+
}, []);
|
|
198
|
+
|
|
199
|
+
return reduced;
|
|
200
|
+
}
|