panelui-native 0.4.0 → 0.5.3
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/README.md +16 -8
- package/lib/module/components/accordion/index.js +312 -0
- package/lib/module/components/accordion/index.js.map +1 -0
- package/lib/module/components/avatar/index.js +58 -17
- package/lib/module/components/avatar/index.js.map +1 -1
- package/lib/module/components/badge/index.js +26 -5
- package/lib/module/components/badge/index.js.map +1 -1
- package/lib/module/components/button/index.js +51 -19
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/checkbox/index.js +42 -14
- package/lib/module/components/checkbox/index.js.map +1 -1
- package/lib/module/components/frame/index.js +64 -13
- package/lib/module/components/frame/index.js.map +1 -1
- package/lib/module/components/timeline/index.js +658 -0
- package/lib/module/components/timeline/index.js.map +1 -0
- package/lib/module/components/toast/index.js +145 -55
- package/lib/module/components/toast/index.js.map +1 -1
- package/lib/module/icons/index.js +388 -27
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +3 -1
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/accordion/index.d.ts +56 -0
- package/lib/typescript/src/components/accordion/index.d.ts.map +1 -0
- package/lib/typescript/src/components/avatar/index.d.ts +8 -1
- package/lib/typescript/src/components/avatar/index.d.ts.map +1 -1
- package/lib/typescript/src/components/badge/index.d.ts +38 -0
- package/lib/typescript/src/components/badge/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts +18 -0
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/checkbox/index.d.ts +42 -0
- package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
- package/lib/typescript/src/components/frame/index.d.ts +39 -5
- package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
- package/lib/typescript/src/components/timeline/index.d.ts +96 -0
- package/lib/typescript/src/components/timeline/index.d.ts.map +1 -0
- package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
- package/lib/typescript/src/icons/index.d.ts +38 -3
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +4 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/accordion/index.tsx +344 -0
- package/src/components/avatar/index.tsx +68 -20
- package/src/components/badge/index.tsx +35 -6
- package/src/components/button/index.tsx +63 -20
- package/src/components/checkbox/index.tsx +49 -11
- package/src/components/frame/index.tsx +101 -31
- package/src/components/timeline/index.tsx +482 -0
- package/src/components/toast/index.tsx +166 -45
- package/src/icons/index.tsx +292 -27
- package/src/index.ts +38 -1
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accordion — collapsible sections.
|
|
3
|
+
*
|
|
4
|
+
* Adapted from: heroui-inc/heroui-native src/components/accordion/
|
|
5
|
+
* (the Item / Trigger / Indicator / Content anatomy, single vs multiple
|
|
6
|
+
* selection, the rotating chevron indicator, and layout-transition expand).
|
|
7
|
+
*
|
|
8
|
+
* Upstream ships `default` and `surface`; the extra variants here follow the
|
|
9
|
+
* same slot structure.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
Children,
|
|
13
|
+
createContext,
|
|
14
|
+
forwardRef,
|
|
15
|
+
isValidElement,
|
|
16
|
+
useCallback,
|
|
17
|
+
useContext,
|
|
18
|
+
useMemo,
|
|
19
|
+
useState,
|
|
20
|
+
type ReactNode,
|
|
21
|
+
} from 'react';
|
|
22
|
+
import { Pressable, View, type Text as RNText, type ViewProps } from 'react-native';
|
|
23
|
+
import Animated, {
|
|
24
|
+
LinearTransition,
|
|
25
|
+
useAnimatedStyle,
|
|
26
|
+
useDerivedValue,
|
|
27
|
+
withTiming,
|
|
28
|
+
} from 'react-native-reanimated';
|
|
29
|
+
import { tv } from 'tailwind-variants';
|
|
30
|
+
import { ChevronDownIcon } from '../../icons';
|
|
31
|
+
import { Text, type TextProps } from '../../primitives/text';
|
|
32
|
+
|
|
33
|
+
export type AccordionVariant =
|
|
34
|
+
| 'default'
|
|
35
|
+
| 'surface'
|
|
36
|
+
| 'separated'
|
|
37
|
+
| 'bordered'
|
|
38
|
+
| 'ghost';
|
|
39
|
+
|
|
40
|
+
export type AccordionSelectionMode = 'single' | 'multiple';
|
|
41
|
+
|
|
42
|
+
const accordionVariants = tv({
|
|
43
|
+
slots: {
|
|
44
|
+
root: 'w-full flex-col overflow-hidden',
|
|
45
|
+
separator: 'h-px bg-border',
|
|
46
|
+
item: 'flex-col overflow-hidden',
|
|
47
|
+
trigger: 'flex-row items-center justify-between gap-4 py-4',
|
|
48
|
+
title: 'flex-1 text-base font-medium text-foreground',
|
|
49
|
+
indicator: 'items-center justify-center',
|
|
50
|
+
content: 'pb-4',
|
|
51
|
+
contentText: 'text-sm text-muted-foreground',
|
|
52
|
+
},
|
|
53
|
+
variants: {
|
|
54
|
+
variant: {
|
|
55
|
+
default: { trigger: 'px-1', content: 'px-1' },
|
|
56
|
+
surface: {
|
|
57
|
+
root: 'rounded-2xl border border-border bg-surface',
|
|
58
|
+
separator: 'mx-4',
|
|
59
|
+
trigger: 'px-4',
|
|
60
|
+
content: 'px-4',
|
|
61
|
+
},
|
|
62
|
+
separated: {
|
|
63
|
+
root: 'gap-2.5 overflow-visible',
|
|
64
|
+
separator: 'hidden',
|
|
65
|
+
item: 'rounded-xl border border-border bg-card',
|
|
66
|
+
trigger: 'px-4',
|
|
67
|
+
content: 'px-4',
|
|
68
|
+
},
|
|
69
|
+
bordered: {
|
|
70
|
+
separator: 'hidden',
|
|
71
|
+
item: 'mb-2 rounded-xl border border-border',
|
|
72
|
+
trigger: 'px-4',
|
|
73
|
+
content: 'px-4',
|
|
74
|
+
},
|
|
75
|
+
ghost: {
|
|
76
|
+
separator: 'hidden',
|
|
77
|
+
trigger: 'px-0',
|
|
78
|
+
content: 'px-0',
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
defaultVariants: {
|
|
83
|
+
variant: 'default',
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
interface AccordionContextValue {
|
|
88
|
+
expanded: string[];
|
|
89
|
+
toggle: (value: string) => void;
|
|
90
|
+
variant: AccordionVariant;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface AccordionItemContextValue {
|
|
94
|
+
value: string;
|
|
95
|
+
isExpanded: boolean;
|
|
96
|
+
isDisabled: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const AccordionContext = createContext<AccordionContextValue | null>(null);
|
|
100
|
+
const AccordionItemContext = createContext<AccordionItemContextValue | null>(null);
|
|
101
|
+
|
|
102
|
+
function useAccordion(component: string): AccordionContextValue {
|
|
103
|
+
const context = useContext(AccordionContext);
|
|
104
|
+
if (!context) throw new Error(`${component} must be used within an <Accordion>`);
|
|
105
|
+
return context;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function useAccordionItem(component: string): AccordionItemContextValue {
|
|
109
|
+
const context = useContext(AccordionItemContext);
|
|
110
|
+
if (!context) throw new Error(`${component} must be used within an <Accordion.Item>`);
|
|
111
|
+
return context;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface AccordionProps extends ViewProps {
|
|
115
|
+
className?: string;
|
|
116
|
+
variant?: AccordionVariant;
|
|
117
|
+
/** `single` collapses the open item when another opens. */
|
|
118
|
+
selectionMode?: AccordionSelectionMode;
|
|
119
|
+
/** Expanded item value(s), controlled. */
|
|
120
|
+
value?: string | string[];
|
|
121
|
+
defaultValue?: string | string[];
|
|
122
|
+
onValueChange?: (value: string | string[]) => void;
|
|
123
|
+
/** Hide the hairlines drawn between items. */
|
|
124
|
+
hideSeparator?: boolean;
|
|
125
|
+
children?: ReactNode;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const toArray = (value: string | string[] | undefined): string[] =>
|
|
129
|
+
value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
130
|
+
|
|
131
|
+
const AccordionRoot = forwardRef<View, AccordionProps>(
|
|
132
|
+
(
|
|
133
|
+
{
|
|
134
|
+
className,
|
|
135
|
+
variant = 'default',
|
|
136
|
+
selectionMode = 'single',
|
|
137
|
+
value,
|
|
138
|
+
defaultValue,
|
|
139
|
+
onValueChange,
|
|
140
|
+
hideSeparator = false,
|
|
141
|
+
children,
|
|
142
|
+
...props
|
|
143
|
+
},
|
|
144
|
+
ref
|
|
145
|
+
) => {
|
|
146
|
+
const [internal, setInternal] = useState<string[]>(() => toArray(defaultValue));
|
|
147
|
+
const isControlled = value !== undefined;
|
|
148
|
+
const expanded = isControlled ? toArray(value) : internal;
|
|
149
|
+
|
|
150
|
+
const toggle = useCallback(
|
|
151
|
+
(itemValue: string) => {
|
|
152
|
+
const isOpen = expanded.includes(itemValue);
|
|
153
|
+
const next =
|
|
154
|
+
selectionMode === 'single'
|
|
155
|
+
? isOpen
|
|
156
|
+
? []
|
|
157
|
+
: [itemValue]
|
|
158
|
+
: isOpen
|
|
159
|
+
? expanded.filter((entry) => entry !== itemValue)
|
|
160
|
+
: [...expanded, itemValue];
|
|
161
|
+
|
|
162
|
+
if (!isControlled) setInternal(next);
|
|
163
|
+
// Hand back the shape the caller gave us.
|
|
164
|
+
onValueChange?.(selectionMode === 'single' ? (next[0] ?? '') : next);
|
|
165
|
+
},
|
|
166
|
+
[expanded, selectionMode, isControlled, onValueChange]
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const context = useMemo(
|
|
170
|
+
() => ({ expanded, toggle, variant }),
|
|
171
|
+
[expanded, toggle, variant]
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const { root, separator } = accordionVariants({ variant });
|
|
175
|
+
const items = Children.toArray(children).filter(isValidElement);
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<AccordionContext.Provider value={context}>
|
|
179
|
+
<Animated.View
|
|
180
|
+
ref={ref}
|
|
181
|
+
layout={LinearTransition.duration(200)}
|
|
182
|
+
className={root({ className })}
|
|
183
|
+
{...props}
|
|
184
|
+
>
|
|
185
|
+
{items.map((child, index) => (
|
|
186
|
+
<View key={child.key ?? index}>
|
|
187
|
+
{child}
|
|
188
|
+
{!hideSeparator && index < items.length - 1 ? (
|
|
189
|
+
<View className={separator()} />
|
|
190
|
+
) : null}
|
|
191
|
+
</View>
|
|
192
|
+
))}
|
|
193
|
+
</Animated.View>
|
|
194
|
+
</AccordionContext.Provider>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
);
|
|
198
|
+
AccordionRoot.displayName = 'Accordion';
|
|
199
|
+
|
|
200
|
+
export interface AccordionItemProps extends ViewProps {
|
|
201
|
+
className?: string;
|
|
202
|
+
/** Identifies this item in the accordion's value. */
|
|
203
|
+
value: string;
|
|
204
|
+
isDisabled?: boolean;
|
|
205
|
+
children?: ReactNode;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const AccordionItem = forwardRef<View, AccordionItemProps>(
|
|
209
|
+
({ className, value, isDisabled = false, children, ...props }, ref) => {
|
|
210
|
+
const { expanded, variant } = useAccordion('Accordion.Item');
|
|
211
|
+
const isExpanded = expanded.includes(value);
|
|
212
|
+
const { item } = accordionVariants({ variant });
|
|
213
|
+
|
|
214
|
+
const context = useMemo(
|
|
215
|
+
() => ({ value, isExpanded, isDisabled }),
|
|
216
|
+
[value, isExpanded, isDisabled]
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<AccordionItemContext.Provider value={context}>
|
|
221
|
+
<Animated.View
|
|
222
|
+
ref={ref}
|
|
223
|
+
layout={LinearTransition.duration(200)}
|
|
224
|
+
className={item({ className })}
|
|
225
|
+
{...props}
|
|
226
|
+
>
|
|
227
|
+
{children}
|
|
228
|
+
</Animated.View>
|
|
229
|
+
</AccordionItemContext.Provider>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
);
|
|
233
|
+
AccordionItem.displayName = 'Accordion.Item';
|
|
234
|
+
|
|
235
|
+
export interface AccordionTriggerProps extends ViewProps {
|
|
236
|
+
className?: string;
|
|
237
|
+
children?: ReactNode;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The pressable header row. Bare strings are wrapped in the title style. */
|
|
241
|
+
const AccordionTrigger = forwardRef<View, AccordionTriggerProps>(
|
|
242
|
+
({ className, children, ...props }, ref) => {
|
|
243
|
+
const { toggle, variant } = useAccordion('Accordion.Trigger');
|
|
244
|
+
const { value, isExpanded, isDisabled } = useAccordionItem('Accordion.Trigger');
|
|
245
|
+
const { trigger, title } = accordionVariants({ variant });
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<Pressable
|
|
249
|
+
ref={ref}
|
|
250
|
+
accessibilityRole="button"
|
|
251
|
+
accessibilityState={{ expanded: isExpanded, disabled: isDisabled }}
|
|
252
|
+
disabled={isDisabled}
|
|
253
|
+
onPress={() => toggle(value)}
|
|
254
|
+
className={trigger({ className })}
|
|
255
|
+
{...props}
|
|
256
|
+
>
|
|
257
|
+
{typeof children === 'string' ? (
|
|
258
|
+
<Text className={title()}>{children}</Text>
|
|
259
|
+
) : (
|
|
260
|
+
children
|
|
261
|
+
)}
|
|
262
|
+
</Pressable>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
);
|
|
266
|
+
AccordionTrigger.displayName = 'Accordion.Trigger';
|
|
267
|
+
|
|
268
|
+
export interface AccordionIndicatorProps extends ViewProps {
|
|
269
|
+
className?: string;
|
|
270
|
+
/** Replaces the default chevron. */
|
|
271
|
+
children?: ReactNode;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Chevron that rotates 180° while the item is open. */
|
|
275
|
+
const AccordionIndicator = forwardRef<View, AccordionIndicatorProps>(
|
|
276
|
+
({ className, children, ...props }, ref) => {
|
|
277
|
+
const { variant } = useAccordion('Accordion.Indicator');
|
|
278
|
+
const { isExpanded } = useAccordionItem('Accordion.Indicator');
|
|
279
|
+
const { indicator } = accordionVariants({ variant });
|
|
280
|
+
|
|
281
|
+
const rotation = useDerivedValue(
|
|
282
|
+
() => withTiming(isExpanded ? 180 : 0, { duration: 200 }),
|
|
283
|
+
[isExpanded]
|
|
284
|
+
);
|
|
285
|
+
const style = useAnimatedStyle(() => ({
|
|
286
|
+
transform: [{ rotate: `${rotation.value}deg` }],
|
|
287
|
+
}));
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<Animated.View style={style}>
|
|
291
|
+
<View ref={ref} className={indicator({ className })} {...props}>
|
|
292
|
+
{children ?? <ChevronDownIcon size={18} />}
|
|
293
|
+
</View>
|
|
294
|
+
</Animated.View>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
);
|
|
298
|
+
AccordionIndicator.displayName = 'Accordion.Indicator';
|
|
299
|
+
|
|
300
|
+
export interface AccordionContentProps extends ViewProps {
|
|
301
|
+
className?: string;
|
|
302
|
+
children?: ReactNode;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The collapsible body. Unmounts when closed, per the repo's convention for
|
|
307
|
+
* conditionally shown content — the layout transition on the item animates
|
|
308
|
+
* the height change.
|
|
309
|
+
*/
|
|
310
|
+
const AccordionContent = forwardRef<View, AccordionContentProps>(
|
|
311
|
+
({ className, children, ...props }, ref) => {
|
|
312
|
+
const { variant } = useAccordion('Accordion.Content');
|
|
313
|
+
const { isExpanded } = useAccordionItem('Accordion.Content');
|
|
314
|
+
const { content, contentText } = accordionVariants({ variant });
|
|
315
|
+
|
|
316
|
+
if (!isExpanded) return null;
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<View ref={ref} className={content({ className })} {...props}>
|
|
320
|
+
{typeof children === 'string' ? (
|
|
321
|
+
<Text className={contentText()}>{children}</Text>
|
|
322
|
+
) : (
|
|
323
|
+
children
|
|
324
|
+
)}
|
|
325
|
+
</View>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
);
|
|
329
|
+
AccordionContent.displayName = 'Accordion.Content';
|
|
330
|
+
|
|
331
|
+
const AccordionTitle = forwardRef<RNText, TextProps>(({ className, ...props }, ref) => {
|
|
332
|
+
const { variant } = useAccordion('Accordion.Title');
|
|
333
|
+
const { title } = accordionVariants({ variant });
|
|
334
|
+
return <Text ref={ref} className={title({ className })} {...props} />;
|
|
335
|
+
});
|
|
336
|
+
AccordionTitle.displayName = 'Accordion.Title';
|
|
337
|
+
|
|
338
|
+
export const Accordion = Object.assign(AccordionRoot, {
|
|
339
|
+
Item: AccordionItem,
|
|
340
|
+
Trigger: AccordionTrigger,
|
|
341
|
+
Title: AccordionTitle,
|
|
342
|
+
Indicator: AccordionIndicator,
|
|
343
|
+
Content: AccordionContent,
|
|
344
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { forwardRef, useState } from 'react';
|
|
1
|
+
import { forwardRef, useState, type ReactNode } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
Image,
|
|
4
4
|
View,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from 'react-native';
|
|
9
9
|
import { tv, type VariantProps } from 'tailwind-variants';
|
|
10
10
|
import { Text } from '../../primitives/text';
|
|
11
|
+
import { cn } from '../../utils/cn';
|
|
11
12
|
|
|
12
13
|
const avatarVariants = tv({
|
|
13
14
|
slots: {
|
|
@@ -37,32 +38,79 @@ export interface AvatarProps extends ViewProps, VariantProps<typeof avatarVarian
|
|
|
37
38
|
imageProps?: Omit<ImageProps, 'source'>;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
({ className, size, source, fallback, imageProps, ...props }, ref) => {
|
|
41
|
+
const AvatarRoot = forwardRef<View, AvatarProps>(
|
|
42
|
+
({ className, size, source, fallback, imageProps, children, ...props }, ref) => {
|
|
42
43
|
const [errored, setErrored] = useState(false);
|
|
43
44
|
const { root, image, fallback: fallbackSlot } = avatarVariants({ size });
|
|
44
45
|
const showImage = !!source && !errored;
|
|
45
46
|
|
|
47
|
+
const face = showImage ? (
|
|
48
|
+
<Image
|
|
49
|
+
source={source}
|
|
50
|
+
onError={() => setErrored(true)}
|
|
51
|
+
className={image()}
|
|
52
|
+
{...imageProps}
|
|
53
|
+
/>
|
|
54
|
+
) : (
|
|
55
|
+
<Text className={fallbackSlot()}>{fallback ?? '?'}</Text>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// The plain avatar is a single clipped node.
|
|
59
|
+
if (!children) {
|
|
60
|
+
return (
|
|
61
|
+
<View
|
|
62
|
+
ref={ref}
|
|
63
|
+
accessibilityRole="image"
|
|
64
|
+
className={root({ className })}
|
|
65
|
+
{...props}
|
|
66
|
+
>
|
|
67
|
+
{face}
|
|
68
|
+
</View>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// With an overlay it needs two: the face keeps overflow-hidden to round
|
|
73
|
+
// the image, which would otherwise cut a corner badge in half, so the
|
|
74
|
+
// overlay hangs off an unclipped wrapper around it.
|
|
46
75
|
return (
|
|
47
|
-
<View
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
{
|
|
52
|
-
>
|
|
53
|
-
{showImage ? (
|
|
54
|
-
<Image
|
|
55
|
-
source={source}
|
|
56
|
-
onError={() => setErrored(true)}
|
|
57
|
-
className={image()}
|
|
58
|
-
{...imageProps}
|
|
59
|
-
/>
|
|
60
|
-
) : (
|
|
61
|
-
<Text className={fallbackSlot()}>{fallback ?? '?'}</Text>
|
|
62
|
-
)}
|
|
76
|
+
<View ref={ref} className={cn('self-start', className)} {...props}>
|
|
77
|
+
<View accessibilityRole="image" className={root()}>
|
|
78
|
+
{face}
|
|
79
|
+
</View>
|
|
80
|
+
{children}
|
|
63
81
|
</View>
|
|
64
82
|
);
|
|
65
83
|
}
|
|
66
84
|
);
|
|
85
|
+
AvatarRoot.displayName = 'Avatar';
|
|
67
86
|
|
|
68
|
-
|
|
87
|
+
export interface AvatarBadgeProps extends ViewProps {
|
|
88
|
+
className?: string;
|
|
89
|
+
children?: ReactNode;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Overlay pinned to the avatar's top-right — an unread count, a presence dot.
|
|
94
|
+
*
|
|
95
|
+
* The ring is `border-background` so the badge separates from the image
|
|
96
|
+
* whatever surface the avatar sits on.
|
|
97
|
+
*/
|
|
98
|
+
const AvatarBadge = forwardRef<View, AvatarBadgeProps>(
|
|
99
|
+
({ className, children, ...props }, ref) => (
|
|
100
|
+
<View
|
|
101
|
+
ref={ref}
|
|
102
|
+
className={cn(
|
|
103
|
+
'absolute -right-1 -top-1 rounded-full border-2 border-background',
|
|
104
|
+
className
|
|
105
|
+
)}
|
|
106
|
+
{...props}
|
|
107
|
+
>
|
|
108
|
+
{children}
|
|
109
|
+
</View>
|
|
110
|
+
)
|
|
111
|
+
);
|
|
112
|
+
AvatarBadge.displayName = 'Avatar.Badge';
|
|
113
|
+
|
|
114
|
+
export const Avatar = Object.assign(AvatarRoot, {
|
|
115
|
+
Badge: AvatarBadge,
|
|
116
|
+
});
|
|
@@ -9,6 +9,13 @@ const badgeVariants = tv({
|
|
|
9
9
|
label: 'text-xs font-medium',
|
|
10
10
|
},
|
|
11
11
|
variants: {
|
|
12
|
+
shape: {
|
|
13
|
+
default: {},
|
|
14
|
+
/** A bare status dot, no label. */
|
|
15
|
+
dot: { root: 'h-2 w-2 rounded-full px-0 py-0' },
|
|
16
|
+
/** Circular, for a count sitting over an avatar or an icon. */
|
|
17
|
+
count: { root: 'h-5 min-w-5 rounded-full px-1.5 py-0' },
|
|
18
|
+
},
|
|
12
19
|
variant: {
|
|
13
20
|
default: { root: 'bg-primary', label: 'text-primary-foreground' },
|
|
14
21
|
secondary: { root: 'bg-secondary', label: 'text-secondary-foreground' },
|
|
@@ -21,27 +28,49 @@ const badgeVariants = tv({
|
|
|
21
28
|
},
|
|
22
29
|
defaultVariants: {
|
|
23
30
|
variant: 'default',
|
|
31
|
+
shape: 'default',
|
|
24
32
|
},
|
|
25
33
|
});
|
|
26
34
|
|
|
35
|
+
/** Counts past this render as "99+" so the badge keeps its shape. */
|
|
36
|
+
const MAX_COUNT = 99;
|
|
37
|
+
|
|
27
38
|
export interface BadgeProps
|
|
28
39
|
extends ViewProps,
|
|
29
40
|
VariantProps<typeof badgeVariants> {
|
|
30
41
|
children?: ReactNode;
|
|
31
42
|
className?: string;
|
|
32
43
|
labelClassName?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Renders a number instead of children, clamped to "99+". Implies the
|
|
46
|
+
* `count` shape unless you set one.
|
|
47
|
+
*/
|
|
48
|
+
count?: number;
|
|
33
49
|
}
|
|
34
50
|
|
|
35
51
|
export const Badge = forwardRef<View, BadgeProps>(
|
|
36
|
-
({ children, className, labelClassName, variant, ...props }, ref) => {
|
|
37
|
-
const
|
|
52
|
+
({ children, className, labelClassName, variant, shape, count, ...props }, ref) => {
|
|
53
|
+
const resolvedShape = shape ?? (count !== undefined ? 'count' : 'default');
|
|
54
|
+
const { root, label } = badgeVariants({ variant, shape: resolvedShape });
|
|
55
|
+
|
|
56
|
+
const content =
|
|
57
|
+
count !== undefined
|
|
58
|
+
? `${Math.min(count, MAX_COUNT)}${count > MAX_COUNT ? '+' : ''}`
|
|
59
|
+
: children;
|
|
38
60
|
|
|
39
61
|
return (
|
|
40
|
-
<View
|
|
41
|
-
{
|
|
42
|
-
|
|
62
|
+
<View
|
|
63
|
+
ref={ref}
|
|
64
|
+
accessibilityLabel={
|
|
65
|
+
count !== undefined ? `${count} unread` : undefined
|
|
66
|
+
}
|
|
67
|
+
className={root({ className })}
|
|
68
|
+
{...props}
|
|
69
|
+
>
|
|
70
|
+
{resolvedShape === 'dot' ? null : typeof content === 'string' ? (
|
|
71
|
+
<Text className={label({ className: labelClassName })}>{content}</Text>
|
|
43
72
|
) : (
|
|
44
|
-
|
|
73
|
+
content
|
|
45
74
|
)}
|
|
46
75
|
</View>
|
|
47
76
|
);
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { forwardRef, type ReactNode } from 'react';
|
|
2
2
|
import type { View } from 'react-native';
|
|
3
3
|
import { tv, type VariantProps } from 'tailwind-variants';
|
|
4
|
+
import { useCSSVariable } from 'uniwind';
|
|
4
5
|
import {
|
|
5
6
|
AnimatedPressable,
|
|
6
7
|
type AnimatedPressableProps,
|
|
7
8
|
} from '../../primitives/animated-pressable';
|
|
8
9
|
import { Text } from '../../primitives/text';
|
|
10
|
+
import { IconColorProvider } from '../../icons';
|
|
9
11
|
import { Spinner } from '../spinner';
|
|
10
12
|
|
|
11
13
|
const buttonVariants = tv({
|
|
@@ -41,6 +43,12 @@ const buttonVariants = tv({
|
|
|
41
43
|
label: 'text-white',
|
|
42
44
|
spinner: 'border-white/32 border-t-white',
|
|
43
45
|
},
|
|
46
|
+
/** Neutral surface for third-party sign-in, sized for a full-width stack. */
|
|
47
|
+
social: {
|
|
48
|
+
root: 'border-input bg-card shadow-sm',
|
|
49
|
+
label: 'text-foreground',
|
|
50
|
+
spinner: 'border-muted border-t-foreground',
|
|
51
|
+
},
|
|
44
52
|
},
|
|
45
53
|
size: {
|
|
46
54
|
sm: { root: 'h-9 gap-1.5 px-2.5', label: 'text-sm' },
|
|
@@ -65,6 +73,24 @@ type ButtonVariantProps = VariantProps<typeof buttonVariants>;
|
|
|
65
73
|
|
|
66
74
|
const SPINNER_SIZE = { sm: 'sm', md: 'sm', lg: 'md', icon: 'sm' } as const;
|
|
67
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The theme token each variant's content reads against. Icons in the content
|
|
78
|
+
* slots inherit the resolved value, so they follow the theme automatically.
|
|
79
|
+
*
|
|
80
|
+
* `destructive` is absent on purpose: its background is a saturated red in
|
|
81
|
+
* every theme, so its content is always white rather than a themed token.
|
|
82
|
+
*/
|
|
83
|
+
const CONTENT_COLOR_VAR: Record<
|
|
84
|
+
Exclude<NonNullable<ButtonVariantProps['variant']>, 'destructive'>,
|
|
85
|
+
string
|
|
86
|
+
> = {
|
|
87
|
+
primary: '--color-primary-foreground',
|
|
88
|
+
secondary: '--color-secondary-foreground',
|
|
89
|
+
outline: '--color-foreground',
|
|
90
|
+
ghost: '--color-foreground',
|
|
91
|
+
social: '--color-foreground',
|
|
92
|
+
};
|
|
93
|
+
|
|
68
94
|
export interface ButtonProps
|
|
69
95
|
extends Omit<AnimatedPressableProps, 'children' | 'disabled'>,
|
|
70
96
|
Omit<ButtonVariantProps, 'disabled'> {
|
|
@@ -105,27 +131,44 @@ export const Button = forwardRef<View, ButtonProps>(
|
|
|
105
131
|
disabled: isDisabled,
|
|
106
132
|
});
|
|
107
133
|
|
|
134
|
+
// Icons in the content slots inherit this, so they stay legible when the
|
|
135
|
+
// theme inverts the button's background. Without it every caller has to
|
|
136
|
+
// hardcode a hex that is wrong in one theme or the other.
|
|
137
|
+
const themedColor = useCSSVariable(
|
|
138
|
+
CONTENT_COLOR_VAR[
|
|
139
|
+
variant === 'destructive' ? 'primary' : (variant ?? 'primary')
|
|
140
|
+
]
|
|
141
|
+
);
|
|
142
|
+
const contentColor =
|
|
143
|
+
variant === 'destructive'
|
|
144
|
+
? '#ffffff'
|
|
145
|
+
: typeof themedColor === 'string'
|
|
146
|
+
? themedColor
|
|
147
|
+
: undefined;
|
|
148
|
+
|
|
108
149
|
return (
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
150
|
+
<IconColorProvider color={contentColor}>
|
|
151
|
+
<AnimatedPressable
|
|
152
|
+
ref={ref}
|
|
153
|
+
accessibilityRole="button"
|
|
154
|
+
accessibilityState={{ disabled: isDisabled, busy: loading }}
|
|
155
|
+
disabled={isDisabled}
|
|
156
|
+
className={root({ className })}
|
|
157
|
+
{...props}
|
|
158
|
+
>
|
|
159
|
+
{loading ? (
|
|
160
|
+
<Spinner size={SPINNER_SIZE[size ?? 'md']} className={spinner()} />
|
|
161
|
+
) : (
|
|
162
|
+
startContent
|
|
163
|
+
)}
|
|
164
|
+
{typeof children === 'string' ? (
|
|
165
|
+
<Text className={label({ className: labelClassName })}>{children}</Text>
|
|
166
|
+
) : (
|
|
167
|
+
children
|
|
168
|
+
)}
|
|
169
|
+
{endContent}
|
|
170
|
+
</AnimatedPressable>
|
|
171
|
+
</IconColorProvider>
|
|
129
172
|
);
|
|
130
173
|
}
|
|
131
174
|
);
|