panelui-native 0.4.0 → 0.5.1

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.
Files changed (51) hide show
  1. package/README.md +14 -7
  2. package/lib/module/components/accordion/index.js +312 -0
  3. package/lib/module/components/accordion/index.js.map +1 -0
  4. package/lib/module/components/avatar/index.js +58 -17
  5. package/lib/module/components/avatar/index.js.map +1 -1
  6. package/lib/module/components/badge/index.js +26 -5
  7. package/lib/module/components/badge/index.js.map +1 -1
  8. package/lib/module/components/button/index.js +51 -19
  9. package/lib/module/components/button/index.js.map +1 -1
  10. package/lib/module/components/checkbox/index.js +42 -14
  11. package/lib/module/components/checkbox/index.js.map +1 -1
  12. package/lib/module/components/frame/index.js +64 -13
  13. package/lib/module/components/frame/index.js.map +1 -1
  14. package/lib/module/components/timeline/index.js +633 -0
  15. package/lib/module/components/timeline/index.js.map +1 -0
  16. package/lib/module/components/toast/index.js +145 -55
  17. package/lib/module/components/toast/index.js.map +1 -1
  18. package/lib/module/icons/index.js +388 -27
  19. package/lib/module/icons/index.js.map +1 -1
  20. package/lib/module/index.js +3 -1
  21. package/lib/module/index.js.map +1 -1
  22. package/lib/typescript/src/components/accordion/index.d.ts +56 -0
  23. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -0
  24. package/lib/typescript/src/components/avatar/index.d.ts +8 -1
  25. package/lib/typescript/src/components/avatar/index.d.ts.map +1 -1
  26. package/lib/typescript/src/components/badge/index.d.ts +38 -0
  27. package/lib/typescript/src/components/badge/index.d.ts.map +1 -1
  28. package/lib/typescript/src/components/button/index.d.ts +18 -0
  29. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  30. package/lib/typescript/src/components/checkbox/index.d.ts +42 -0
  31. package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
  32. package/lib/typescript/src/components/frame/index.d.ts +39 -5
  33. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  34. package/lib/typescript/src/components/timeline/index.d.ts +96 -0
  35. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -0
  36. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  37. package/lib/typescript/src/icons/index.d.ts +38 -3
  38. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  39. package/lib/typescript/src/index.d.ts +4 -2
  40. package/lib/typescript/src/index.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/components/accordion/index.tsx +344 -0
  43. package/src/components/avatar/index.tsx +68 -20
  44. package/src/components/badge/index.tsx +35 -6
  45. package/src/components/button/index.tsx +63 -20
  46. package/src/components/checkbox/index.tsx +49 -11
  47. package/src/components/frame/index.tsx +101 -31
  48. package/src/components/timeline/index.tsx +456 -0
  49. package/src/components/toast/index.tsx +166 -45
  50. package/src/icons/index.tsx +292 -27
  51. 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
- export const Avatar = forwardRef<View, AvatarProps>(
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
- ref={ref}
49
- accessibilityRole="image"
50
- className={root({ className })}
51
- {...props}
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
- Avatar.displayName = 'Avatar';
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 { root, label } = badgeVariants({ variant });
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 ref={ref} className={root({ className })} {...props}>
41
- {typeof children === 'string' ? (
42
- <Text className={label({ className: labelClassName })}>{children}</Text>
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
- children
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
- <AnimatedPressable
110
- ref={ref}
111
- accessibilityRole="button"
112
- accessibilityState={{ disabled: isDisabled, busy: loading }}
113
- disabled={isDisabled}
114
- className={root({ className })}
115
- {...props}
116
- >
117
- {loading ? (
118
- <Spinner size={SPINNER_SIZE[size ?? 'md']} className={spinner()} />
119
- ) : (
120
- startContent
121
- )}
122
- {typeof children === 'string' ? (
123
- <Text className={label({ className: labelClassName })}>{children}</Text>
124
- ) : (
125
- children
126
- )}
127
- {endContent}
128
- </AnimatedPressable>
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
  );