panelui-native 0.2.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 (106) hide show
  1. package/README.md +258 -0
  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/alert/index.js +106 -13
  5. package/lib/module/components/alert/index.js.map +1 -1
  6. package/lib/module/components/avatar/index.js +58 -17
  7. package/lib/module/components/avatar/index.js.map +1 -1
  8. package/lib/module/components/badge/index.js +29 -8
  9. package/lib/module/components/badge/index.js.map +1 -1
  10. package/lib/module/components/button/index.js +87 -26
  11. package/lib/module/components/button/index.js.map +1 -1
  12. package/lib/module/components/checkbox/index.js +59 -18
  13. package/lib/module/components/checkbox/index.js.map +1 -1
  14. package/lib/module/components/empty-state/index.js +178 -0
  15. package/lib/module/components/empty-state/index.js.map +1 -0
  16. package/lib/module/components/frame/index.js +64 -13
  17. package/lib/module/components/frame/index.js.map +1 -1
  18. package/lib/module/components/input-group/index.js +132 -0
  19. package/lib/module/components/input-group/index.js.map +1 -0
  20. package/lib/module/components/label/index.js +117 -0
  21. package/lib/module/components/label/index.js.map +1 -0
  22. package/lib/module/components/progress/index.js +138 -0
  23. package/lib/module/components/progress/index.js.map +1 -0
  24. package/lib/module/components/steps/index.js +354 -0
  25. package/lib/module/components/steps/index.js.map +1 -0
  26. package/lib/module/components/tabs/index.js +9 -3
  27. package/lib/module/components/tabs/index.js.map +1 -1
  28. package/lib/module/components/timeline/index.js +633 -0
  29. package/lib/module/components/timeline/index.js.map +1 -0
  30. package/lib/module/components/toast/index.js +569 -0
  31. package/lib/module/components/toast/index.js.map +1 -0
  32. package/lib/module/components/toast/toast-store.js +81 -0
  33. package/lib/module/components/toast/toast-store.js.map +1 -0
  34. package/lib/module/components/typography/index.js +126 -0
  35. package/lib/module/components/typography/index.js.map +1 -0
  36. package/lib/module/icons/index.js +590 -8
  37. package/lib/module/icons/index.js.map +1 -1
  38. package/lib/module/index.js +11 -2
  39. package/lib/module/index.js.map +1 -1
  40. package/lib/module/providers/panel-ui-provider.js +2 -1
  41. package/lib/module/providers/panel-ui-provider.js.map +1 -1
  42. package/lib/module/theme/use-theme.js +90 -3
  43. package/lib/module/theme/use-theme.js.map +1 -1
  44. package/lib/typescript/src/components/accordion/index.d.ts +56 -0
  45. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -0
  46. package/lib/typescript/src/components/alert/index.d.ts +56 -4
  47. package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
  48. package/lib/typescript/src/components/avatar/index.d.ts +8 -1
  49. package/lib/typescript/src/components/avatar/index.d.ts.map +1 -1
  50. package/lib/typescript/src/components/badge/index.d.ts +38 -0
  51. package/lib/typescript/src/components/badge/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/button/index.d.ts +57 -0
  53. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  54. package/lib/typescript/src/components/checkbox/index.d.ts +50 -0
  55. package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
  56. package/lib/typescript/src/components/empty-state/index.d.ts +73 -0
  57. package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -0
  58. package/lib/typescript/src/components/frame/index.d.ts +39 -5
  59. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  60. package/lib/typescript/src/components/input-group/index.d.ts +34 -0
  61. package/lib/typescript/src/components/input-group/index.d.ts.map +1 -0
  62. package/lib/typescript/src/components/label/index.d.ts +28 -0
  63. package/lib/typescript/src/components/label/index.d.ts.map +1 -0
  64. package/lib/typescript/src/components/progress/index.d.ts +132 -0
  65. package/lib/typescript/src/components/progress/index.d.ts.map +1 -0
  66. package/lib/typescript/src/components/steps/index.d.ts +62 -0
  67. package/lib/typescript/src/components/steps/index.d.ts.map +1 -0
  68. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  69. package/lib/typescript/src/components/timeline/index.d.ts +96 -0
  70. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -0
  71. package/lib/typescript/src/components/toast/index.d.ts +176 -0
  72. package/lib/typescript/src/components/toast/index.d.ts.map +1 -0
  73. package/lib/typescript/src/components/toast/toast-store.d.ts +65 -0
  74. package/lib/typescript/src/components/toast/toast-store.d.ts.map +1 -0
  75. package/lib/typescript/src/components/typography/index.d.ts +77 -0
  76. package/lib/typescript/src/components/typography/index.d.ts.map +1 -0
  77. package/lib/typescript/src/icons/index.d.ts +60 -0
  78. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  79. package/lib/typescript/src/index.d.ts +13 -4
  80. package/lib/typescript/src/index.d.ts.map +1 -1
  81. package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
  82. package/lib/typescript/src/theme/use-theme.d.ts +64 -4
  83. package/lib/typescript/src/theme/use-theme.d.ts.map +1 -1
  84. package/package.json +22 -4
  85. package/src/components/accordion/index.tsx +344 -0
  86. package/src/components/alert/index.tsx +102 -16
  87. package/src/components/avatar/index.tsx +68 -20
  88. package/src/components/badge/index.tsx +38 -9
  89. package/src/components/button/index.tsx +102 -19
  90. package/src/components/checkbox/index.tsx +64 -12
  91. package/src/components/empty-state/index.tsx +136 -0
  92. package/src/components/frame/index.tsx +101 -31
  93. package/src/components/input-group/index.tsx +164 -0
  94. package/src/components/label/index.tsx +128 -0
  95. package/src/components/progress/index.tsx +147 -0
  96. package/src/components/steps/index.tsx +343 -0
  97. package/src/components/tabs/index.tsx +10 -2
  98. package/src/components/timeline/index.tsx +456 -0
  99. package/src/components/toast/index.tsx +568 -0
  100. package/src/components/toast/toast-store.ts +120 -0
  101. package/src/components/typography/index.tsx +121 -0
  102. package/src/icons/index.tsx +423 -7
  103. package/src/index.ts +114 -4
  104. package/src/providers/panel-ui-provider.tsx +3 -0
  105. package/src/theme/use-theme.ts +119 -4
  106. package/theme.css +330 -7
@@ -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,39 +9,68 @@ 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' },
15
22
  outline: { root: 'border-border bg-transparent', label: 'text-foreground' },
16
23
  destructive: { root: 'bg-destructive', label: 'text-white' },
17
- success: { root: 'bg-success/8 dark:bg-success/16', label: 'text-success-foreground' },
18
- warning: { root: 'bg-warning/8 dark:bg-warning/16', label: 'text-warning-foreground' },
19
- info: { root: 'bg-info/8 dark:bg-info/16', label: 'text-info-foreground' },
24
+ success: { root: 'bg-success-subtle', label: 'text-success-foreground' },
25
+ warning: { root: 'bg-warning-subtle', label: 'text-warning-foreground' },
26
+ info: { root: 'bg-info-subtle', label: 'text-info-foreground' },
20
27
  },
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,38 +1,53 @@
1
- import { forwardRef, isValidElement, type ReactNode } from 'react';
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';
11
+ import { Spinner } from '../spinner';
9
12
 
10
13
  const buttonVariants = tv({
11
14
  slots: {
12
15
  root: 'flex-row items-center justify-center gap-2 rounded-lg border border-transparent',
13
16
  label: 'font-medium',
17
+ spinner: '',
14
18
  },
15
19
  variants: {
16
20
  variant: {
17
21
  primary: {
18
22
  root: 'border-primary bg-primary shadow-sm',
19
23
  label: 'text-primary-foreground',
24
+ spinner: 'border-primary-foreground/32 border-t-primary-foreground',
20
25
  },
21
26
  secondary: {
22
27
  root: 'bg-secondary',
23
28
  label: 'text-secondary-foreground',
29
+ spinner: 'border-secondary-foreground/24 border-t-secondary-foreground',
24
30
  },
25
31
  outline: {
26
32
  root: 'border-input bg-popover shadow-sm',
27
33
  label: 'text-foreground',
34
+ spinner: 'border-muted border-t-foreground',
28
35
  },
29
36
  ghost: {
30
37
  root: 'bg-transparent',
31
38
  label: 'text-foreground',
39
+ spinner: 'border-muted border-t-foreground',
32
40
  },
33
41
  destructive: {
34
42
  root: 'border-destructive bg-destructive shadow-sm',
35
43
  label: 'text-white',
44
+ spinner: 'border-white/32 border-t-white',
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',
36
51
  },
37
52
  },
38
53
  size: {
@@ -41,6 +56,9 @@ const buttonVariants = tv({
41
56
  lg: { root: 'h-12 px-6', label: 'text-lg' },
42
57
  icon: { root: 'h-11 w-11 px-0' },
43
58
  },
59
+ fullWidth: {
60
+ true: { root: 'w-full' },
61
+ },
44
62
  disabled: {
45
63
  true: { root: 'opacity-[0.64]' },
46
64
  },
@@ -53,39 +71,104 @@ const buttonVariants = tv({
53
71
 
54
72
  type ButtonVariantProps = VariantProps<typeof buttonVariants>;
55
73
 
74
+ const SPINNER_SIZE = { sm: 'sm', md: 'sm', lg: 'md', icon: 'sm' } as const;
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
+
56
94
  export interface ButtonProps
57
95
  extends Omit<AnimatedPressableProps, 'children' | 'disabled'>,
58
96
  Omit<ButtonVariantProps, 'disabled'> {
59
97
  children?: ReactNode;
60
98
  disabled?: boolean;
99
+ /** Show a spinner and block presses while an action is in flight. */
100
+ loading?: boolean;
101
+ /** Content rendered before the label (replaced by the spinner while loading). */
102
+ startContent?: ReactNode;
103
+ /** Content rendered after the label. */
104
+ endContent?: ReactNode;
61
105
  /** Extra classes for the label when children is a string. */
62
106
  labelClassName?: string;
63
107
  }
64
108
 
65
109
  export const Button = forwardRef<View, ButtonProps>(
66
110
  (
67
- { children, className, labelClassName, variant, size, disabled, ...props },
111
+ {
112
+ children,
113
+ className,
114
+ labelClassName,
115
+ variant,
116
+ size,
117
+ fullWidth,
118
+ disabled,
119
+ loading = false,
120
+ startContent,
121
+ endContent,
122
+ ...props
123
+ },
68
124
  ref
69
125
  ) => {
70
- const { root, label } = buttonVariants({ variant, size, disabled: !!disabled });
126
+ const isDisabled = disabled || loading;
127
+ const { root, label, spinner } = buttonVariants({
128
+ variant,
129
+ size,
130
+ fullWidth,
131
+ disabled: isDisabled,
132
+ });
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;
71
148
 
72
149
  return (
73
- <AnimatedPressable
74
- ref={ref}
75
- accessibilityRole="button"
76
- accessibilityState={{ disabled: !!disabled }}
77
- disabled={disabled}
78
- className={root({ className })}
79
- {...props}
80
- >
81
- {typeof children === 'string' ? (
82
- <Text className={label({ className: labelClassName })}>{children}</Text>
83
- ) : isValidElement(children) ? (
84
- children
85
- ) : (
86
- children
87
- )}
88
- </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>
89
172
  );
90
173
  }
91
174
  );
@@ -16,13 +16,39 @@ const checkboxVariants = tv({
16
16
  row: 'flex-row items-center gap-2.5',
17
17
  box: 'h-5 w-5 items-center justify-center rounded-md border border-input bg-background',
18
18
  fill: 'absolute inset-0 items-center justify-center rounded-md bg-primary',
19
+ content: 'flex-1 gap-0.5',
19
20
  label: 'text-sm text-foreground',
21
+ description: 'text-sm text-muted-foreground',
20
22
  },
21
23
  variants: {
24
+ variant: {
25
+ default: {},
26
+ /**
27
+ * The whole surface is the target — for pickable options where the
28
+ * checkbox is a confirmation rather than the affordance itself.
29
+ */
30
+ card: {
31
+ row: 'w-full items-start gap-3 rounded-xl border border-border bg-card p-4',
32
+ label: 'text-base font-medium',
33
+ },
34
+ },
35
+ checked: {
36
+ true: {},
37
+ },
22
38
  disabled: {
23
39
  true: { row: 'opacity-50' },
24
40
  },
25
41
  },
42
+ compoundVariants: [
43
+ {
44
+ variant: 'card',
45
+ checked: true,
46
+ class: { row: 'border-primary bg-accent' },
47
+ },
48
+ ],
49
+ defaultVariants: {
50
+ variant: 'default',
51
+ },
26
52
  });
27
53
 
28
54
  export interface CheckboxProps extends VariantProps<typeof checkboxVariants> {
@@ -32,12 +58,17 @@ export interface CheckboxProps extends VariantProps<typeof checkboxVariants> {
32
58
  disabled?: boolean;
33
59
  /** Optional label rendered next to the box; pressing it also toggles. */
34
60
  label?: string;
61
+ /** Secondary line under the label, for extra context. */
62
+ description?: string;
35
63
  }
36
64
 
37
65
  export const Checkbox = forwardRef<View, CheckboxProps>(
38
- ({ className, checked, onCheckedChange, disabled, label }, ref) => {
66
+ (
67
+ { className, checked, onCheckedChange, disabled, label, description, variant },
68
+ ref
69
+ ) => {
39
70
  const progress = useSharedValue(checked ? 1 : 0);
40
- const slots = checkboxVariants({ disabled: !!disabled });
71
+ const slots = checkboxVariants({ variant, checked, disabled: !!disabled });
41
72
  const checkColor = useCSSVariable('--color-primary-foreground');
42
73
 
43
74
  useEffect(() => {
@@ -51,26 +82,47 @@ export const Checkbox = forwardRef<View, CheckboxProps>(
51
82
  transform: [{ scale: 0.6 + progress.value * 0.4 }],
52
83
  }));
53
84
 
85
+ const box = (
86
+ <View className={slots.box()}>
87
+ <Animated.View style={fillStyle} className={slots.fill()}>
88
+ <CheckIcon
89
+ size={12}
90
+ color={typeof checkColor === 'string' ? checkColor : '#fff'}
91
+ />
92
+ </Animated.View>
93
+ </View>
94
+ );
95
+
54
96
  return (
55
97
  <Pressable
56
98
  ref={ref}
57
99
  accessibilityRole="checkbox"
58
100
  accessibilityState={{ checked, disabled: !!disabled }}
59
101
  accessibilityLabel={label}
102
+ accessibilityHint={description}
60
103
  disabled={disabled}
61
104
  onPress={() => onCheckedChange?.(!checked)}
62
105
  hitSlop={8}
63
- className={slots.row({ className })}
106
+ // A description makes the row two lines tall — align the box to the
107
+ // label rather than centring it against the whole block.
108
+ className={slots.row({
109
+ className:
110
+ description && variant !== 'card'
111
+ ? `items-start ${className ?? ''}`
112
+ : className,
113
+ })}
64
114
  >
65
- <View className={slots.box()}>
66
- <Animated.View style={fillStyle} className={slots.fill()}>
67
- <CheckIcon
68
- size={12}
69
- color={typeof checkColor === 'string' ? checkColor : '#fff'}
70
- />
71
- </Animated.View>
72
- </View>
73
- {label ? <Text className={slots.label()}>{label}</Text> : null}
115
+ {/* The card lays out content first so the box sits top-right. */}
116
+ {variant === 'card' ? null : box}
117
+ {label || description ? (
118
+ <View className={slots.content()}>
119
+ {label ? <Text className={slots.label()}>{label}</Text> : null}
120
+ {description ? (
121
+ <Text className={slots.description()}>{description}</Text>
122
+ ) : null}
123
+ </View>
124
+ ) : null}
125
+ {variant === 'card' ? box : null}
74
126
  </Pressable>
75
127
  );
76
128
  }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * EmptyState — the placeholder shown when a list or view has no content.
3
+ *
4
+ * Adapted from: cosscom/coss packages/ui/src/components/empty.tsx
5
+ * (centered Header / Media / Title / Description / Content anatomy, and the
6
+ * `icon` media variant that stacks two rotated ghost cards behind the icon).
7
+ * Coss does that with CSS pseudo-elements; on native it is two absolutely
8
+ * positioned siblings with static rotate transforms.
9
+ */
10
+ import { forwardRef, type ReactNode } from 'react';
11
+ import { View, type Text as RNText, type ViewProps } from 'react-native';
12
+ import { tv, type VariantProps } from 'tailwind-variants';
13
+ import { Text, type TextProps } from '../../primitives/text';
14
+
15
+ const emptyStateVariants = tv({
16
+ slots: {
17
+ root: 'w-full flex-1 items-center justify-center gap-6 px-6 py-12',
18
+ header: 'w-full max-w-sm items-center gap-1',
19
+ title: 'text-center text-xl font-semibold text-foreground',
20
+ description: 'text-center text-sm text-muted-foreground',
21
+ content: 'w-full max-w-sm items-center gap-3',
22
+ },
23
+ });
24
+
25
+ const mediaVariants = tv({
26
+ slots: {
27
+ wrapper: 'items-center justify-center',
28
+ card: 'h-9 w-9 shrink-0 items-center justify-center',
29
+ ghost: 'absolute h-9 w-9 rounded-md border border-border bg-card',
30
+ },
31
+ variants: {
32
+ variant: {
33
+ default: {
34
+ card: 'bg-transparent',
35
+ ghost: 'hidden',
36
+ },
37
+ icon: {
38
+ card: 'rounded-md border border-border bg-card',
39
+ },
40
+ },
41
+ },
42
+ defaultVariants: {
43
+ variant: 'default',
44
+ },
45
+ });
46
+
47
+ export interface EmptyStateProps extends ViewProps {
48
+ className?: string;
49
+ children?: ReactNode;
50
+ }
51
+
52
+ const EmptyStateRoot = forwardRef<View, EmptyStateProps>(
53
+ ({ className, ...props }, ref) => {
54
+ const { root } = emptyStateVariants();
55
+ return <View ref={ref} className={root({ className })} {...props} />;
56
+ }
57
+ );
58
+ EmptyStateRoot.displayName = 'EmptyState';
59
+
60
+ /** Groups Media, Title and Description into one centered column. */
61
+ const EmptyStateHeader = forwardRef<View, EmptyStateProps>(
62
+ ({ className, ...props }, ref) => {
63
+ const { header } = emptyStateVariants();
64
+ return <View ref={ref} className={header({ className })} {...props} />;
65
+ }
66
+ );
67
+ EmptyStateHeader.displayName = 'EmptyState.Header';
68
+
69
+ export interface EmptyStateMediaProps
70
+ extends ViewProps,
71
+ VariantProps<typeof mediaVariants> {
72
+ className?: string;
73
+ children?: ReactNode;
74
+ }
75
+
76
+ /**
77
+ * Visual anchor above the title. `variant="icon"` frames the child in a small
78
+ * card with two rotated ghost cards fanned out behind it.
79
+ */
80
+ const EmptyStateMedia = forwardRef<View, EmptyStateMediaProps>(
81
+ ({ className, variant = 'default', children, ...props }, ref) => {
82
+ const { wrapper, card, ghost } = mediaVariants({ variant });
83
+
84
+ return (
85
+ <View ref={ref} className={wrapper({ className: `mb-4 ${className ?? ''}` })} {...props}>
86
+ {variant === 'icon' ? (
87
+ <>
88
+ <View
89
+ accessibilityElementsHidden
90
+ importantForAccessibility="no-hide-descendants"
91
+ className={`${ghost()} -translate-x-2 -rotate-[10deg] scale-90`}
92
+ />
93
+ <View
94
+ accessibilityElementsHidden
95
+ importantForAccessibility="no-hide-descendants"
96
+ className={`${ghost()} translate-x-2 rotate-[10deg] scale-90`}
97
+ />
98
+ </>
99
+ ) : null}
100
+ <View className={card()}>{children}</View>
101
+ </View>
102
+ );
103
+ }
104
+ );
105
+ EmptyStateMedia.displayName = 'EmptyState.Media';
106
+
107
+ const EmptyStateTitle = forwardRef<RNText, TextProps>(({ className, ...props }, ref) => {
108
+ const { title } = emptyStateVariants();
109
+ return <Text ref={ref} className={title({ className })} {...props} />;
110
+ });
111
+ EmptyStateTitle.displayName = 'EmptyState.Title';
112
+
113
+ const EmptyStateDescription = forwardRef<RNText, TextProps>(
114
+ ({ className, ...props }, ref) => {
115
+ const { description } = emptyStateVariants();
116
+ return <Text ref={ref} className={description({ className })} {...props} />;
117
+ }
118
+ );
119
+ EmptyStateDescription.displayName = 'EmptyState.Description';
120
+
121
+ /** Slot below the header, for actions such as a primary button. */
122
+ const EmptyStateContent = forwardRef<View, EmptyStateProps>(
123
+ ({ className, ...props }, ref) => {
124
+ const { content } = emptyStateVariants();
125
+ return <View ref={ref} className={content({ className })} {...props} />;
126
+ }
127
+ );
128
+ EmptyStateContent.displayName = 'EmptyState.Content';
129
+
130
+ export const EmptyState = Object.assign(EmptyStateRoot, {
131
+ Header: EmptyStateHeader,
132
+ Media: EmptyStateMedia,
133
+ Title: EmptyStateTitle,
134
+ Description: EmptyStateDescription,
135
+ Content: EmptyStateContent,
136
+ });