@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olwiba/ui",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -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
- className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground 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"
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={typeof brand.name === 'string' ? brand.name : undefined}
237
- className="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"
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
- <span className="text-4xl font-bold tracking-tight">{price}</span>
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
- <Badge variant="secondary" className="text-xs">
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="secondary" className="text-xs">{saveBadge}</Badge>
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
+ }