@bubbasfw/ui 0.0.0-stage → 0.2.0

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.
@@ -0,0 +1,105 @@
1
+ 'use client';
2
+
3
+ import { forwardRef, type ElementRef, type ReactNode } from 'react';
4
+ import { ActivityIndicator, Pressable, Text, type PressableProps } from 'react-native';
5
+ import { cn, focusRing } from '../internal/utils';
6
+ import { useTheme } from '../theme';
7
+
8
+ export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
9
+ export type ButtonSize = 'sm' | 'md' | 'lg';
10
+
11
+ export interface ButtonProps extends Omit<PressableProps, 'children' | 'className'> {
12
+ /** Text content, or inline React Native Text content. */
13
+ children: ReactNode;
14
+ variant?: ButtonVariant;
15
+ size?: ButtonSize;
16
+ loading?: boolean;
17
+ loadingLabel?: string;
18
+ className?: string;
19
+ textClassName?: string;
20
+ }
21
+
22
+ const containerVariants: Record<ButtonVariant, string> = {
23
+ primary: 'border-ui-action-border bg-bubba-red',
24
+ secondary: 'border-ui-border bg-ui-selected',
25
+ outline: 'border-ui-border bg-transparent',
26
+ ghost: 'border-transparent bg-transparent',
27
+ };
28
+ const textVariants: Record<ButtonVariant, string> = {
29
+ primary: 'text-ui-action-text',
30
+ secondary: 'text-ui-text',
31
+ outline: 'text-ui-text',
32
+ ghost: 'text-ui-link',
33
+ };
34
+ const containerSizes: Record<ButtonSize, string> = {
35
+ sm: 'min-h-[44px] px-3 py-2',
36
+ md: 'min-h-[48px] px-5 py-3',
37
+ lg: 'min-h-[56px] px-6 py-4',
38
+ };
39
+ const textSizes: Record<ButtonSize, string> = {
40
+ sm: 'text-sm',
41
+ md: 'text-base',
42
+ lg: 'text-lg',
43
+ };
44
+
45
+ /** One native Pressable and Text implementation on iOS, Android, and web. */
46
+ export const Button = forwardRef<ElementRef<typeof Pressable>, ButtonProps>(
47
+ function Button(
48
+ {
49
+ children,
50
+ variant = 'primary',
51
+ size = 'md',
52
+ loading = false,
53
+ loadingLabel,
54
+ disabled = false,
55
+ className,
56
+ textClassName,
57
+ accessibilityState,
58
+ 'aria-busy': ariaBusy,
59
+ 'aria-disabled': ariaDisabled,
60
+ ...props
61
+ },
62
+ ref,
63
+ ) {
64
+ const { colors } = useTheme();
65
+ const explicitlyDisabled = disabled || (ariaDisabled ?? accessibilityState?.disabled ?? false);
66
+ const isDisabled = explicitlyDisabled || loading;
67
+ const isBusy = loading || (ariaBusy ?? accessibilityState?.busy ?? false);
68
+ const spinnerColor = explicitlyDisabled
69
+ ? colors['disabled-text']
70
+ : variant === 'primary' ? colors['action-text'] : colors.text;
71
+
72
+ return (
73
+ <Pressable
74
+ {...props}
75
+ ref={ref}
76
+ accessibilityRole="button"
77
+ accessibilityState={{ ...accessibilityState, disabled: isDisabled, busy: isBusy }}
78
+ aria-busy={isBusy}
79
+ aria-disabled={isDisabled}
80
+ disabled={isDisabled}
81
+ className={cn(
82
+ 'min-w-[44px] flex-row items-center justify-center gap-2 rounded-[4px] border active:opacity-80',
83
+ focusRing,
84
+ containerVariants[variant],
85
+ containerSizes[size],
86
+ explicitlyDisabled && 'border-ui-disabled-surface bg-ui-disabled-surface',
87
+ className,
88
+ )}
89
+ >
90
+ {loading ? <ActivityIndicator size="small" color={spinnerColor} accessible={false} /> : null}
91
+ <Text
92
+ className={cn(
93
+ 'font-bold text-center',
94
+ textVariants[variant],
95
+ textSizes[size],
96
+ explicitlyDisabled && 'text-ui-disabled-text',
97
+ textClassName,
98
+ )}
99
+ >
100
+ {loading && loadingLabel ? loadingLabel : children}
101
+ </Text>
102
+ </Pressable>
103
+ );
104
+ },
105
+ );
@@ -0,0 +1,39 @@
1
+ 'use client';
2
+
3
+ import { forwardRef, type ElementRef, type ReactNode } from 'react';
4
+ import { Linking, Platform, Pressable, type PressableProps } from 'react-native';
5
+ import { cn, focusRing } from '../internal/utils';
6
+ import { Text } from './typography';
7
+
8
+ export interface IconButtonProps extends Omit<PressableProps, 'children' | 'accessibilityLabel'> {
9
+ accessibilityLabel: string;
10
+ children: ReactNode;
11
+ variant?: 'primary' | 'outline' | 'ghost';
12
+ className?: string;
13
+ }
14
+ const iconVariants = { primary: 'bg-bubba-red border-bubba-red', outline: 'border-ui-border bg-transparent', ghost: 'border-transparent bg-transparent' };
15
+ export const IconButton = forwardRef<ElementRef<typeof Pressable>, IconButtonProps>(function IconButton({ accessibilityLabel, children, variant = 'ghost', className, disabled, accessibilityState, 'aria-disabled': ariaDisabled, ...props }, ref) {
16
+ const isDisabled = disabled || (ariaDisabled ?? accessibilityState?.disabled ?? false);
17
+ return <Pressable {...props} ref={ref} accessibilityRole="button" accessibilityLabel={accessibilityLabel} disabled={isDisabled} aria-disabled={isDisabled} accessibilityState={{ ...accessibilityState, disabled: isDisabled }} className={cn('h-12 w-12 items-center justify-center rounded border active:opacity-80', focusRing, iconVariants[variant], isDisabled && 'opacity-50', className)}>{children}</Pressable>;
18
+ });
19
+
20
+ export interface LinkProps extends Omit<PressableProps, 'children'> {
21
+ href: string;
22
+ children: ReactNode;
23
+ external?: boolean;
24
+ className?: string;
25
+ textClassName?: string;
26
+ /** Called when the operating system cannot open a native URL. */
27
+ onOpenError?: (error: unknown) => void;
28
+ }
29
+ type WebLinkProps = PressableProps & { href?: string; hrefAttrs?: { target?: string; rel?: string } };
30
+ const LinkPressable = Pressable as typeof Pressable & ((props: WebLinkProps) => ReactNode);
31
+
32
+ export const Link = forwardRef<ElementRef<typeof Pressable>, LinkProps>(function Link({ href, children, external = false, className, textClassName, disabled, onPress, onOpenError, accessibilityState, 'aria-disabled': ariaDisabled, ...props }, ref) {
33
+ const isDisabled = disabled || (ariaDisabled ?? accessibilityState?.disabled ?? false);
34
+ const webProps = Platform.OS === 'web' && !isDisabled ? { href, hrefAttrs: external ? { target: '_blank', rel: 'noopener noreferrer' } : undefined } : {};
35
+ return <LinkPressable {...props} {...webProps} ref={ref} accessibilityRole="link" disabled={isDisabled} aria-disabled={isDisabled} accessibilityState={{ ...accessibilityState, disabled: isDisabled }} className={cn('min-h-[44px] min-w-[44px] justify-center self-start', focusRing, isDisabled && 'opacity-50', className)} onPress={(event) => {
36
+ onPress?.(event);
37
+ if (Platform.OS !== 'web' && !event.isDefaultPrevented()) void Linking.openURL(href).catch((error: unknown) => onOpenError?.(error));
38
+ }}><Text className={cn('text-ui-link underline', textClassName)}>{children}</Text></LinkPressable>;
39
+ });
@@ -0,0 +1,55 @@
1
+ 'use client';
2
+
3
+ import { forwardRef, useState, type ElementRef, type ReactNode } from 'react';
4
+ import { Image, Pressable, View, type ImageSourcePropType, type ViewProps, type PressableProps } from 'react-native';
5
+ import { cn, focusRing } from '../internal/utils';
6
+ import { Text } from './typography';
7
+
8
+ export type StatusTone = 'neutral' | 'info' | 'success' | 'warning' | 'error';
9
+ const toneClasses = {
10
+ neutral: 'bg-ui-selected', info: 'bg-ui-info-surface', success: 'bg-ui-success-surface', warning: 'bg-ui-warning-surface', error: 'bg-ui-error-surface',
11
+ };
12
+ const toneText = { neutral: 'text-ui-text', info: 'text-ui-info', success: 'text-ui-success', warning: 'text-ui-warning', error: 'text-ui-error' };
13
+ export interface BadgeProps extends ViewProps { children: ReactNode; tone?: StatusTone; className?: string; textClassName?: string }
14
+ export function Badge({ children, tone = 'neutral', className, textClassName, ...props }: BadgeProps) {
15
+ return <View {...props} className={cn('self-start rounded-full px-3 py-1', toneClasses[tone], className)}><Text variant="small" className={cn('font-semibold', toneText[tone], textClassName)}>{children}</Text></View>;
16
+ }
17
+
18
+ export interface AvatarProps extends ViewProps {
19
+ source?: ImageSourcePropType;
20
+ name: string;
21
+ size?: 'sm' | 'md' | 'lg';
22
+ className?: string;
23
+ }
24
+ const avatarSizes = { sm: 'h-8 w-8', md: 'h-12 w-12', lg: 'h-16 w-16' };
25
+ export function Avatar({ source, name, size = 'md', className, ...props }: AvatarProps) {
26
+ const [failedSource, setFailedSource] = useState<ImageSourcePropType>();
27
+ const initials = name.trim().split(/\s+/).slice(0, 2).map((part) => part[0]).join('').toUpperCase() || '?';
28
+ return <View {...props} accessible accessibilityRole="image" accessibilityLabel={name} className={cn('items-center justify-center overflow-hidden rounded-full bg-ui-selected', avatarSizes[size], className)}>
29
+ {source && source !== failedSource ? <Image source={source} resizeMode="cover" accessible={false} className="h-full w-full" onError={() => setFailedSource(source)} /> : <Text accessible={false} className="font-bold">{initials}</Text>}
30
+ </View>;
31
+ }
32
+
33
+ export interface EmptyStateProps extends ViewProps { title: string; description?: string; children?: ReactNode; className?: string }
34
+ export function EmptyState({ title, description, children, className, ...props }: EmptyStateProps) {
35
+ return <View {...props} className={cn('items-start gap-3 py-8', className)}>
36
+ <Text className="text-lg font-bold">{title}</Text>
37
+ {description ? <Text tone="muted">{description}</Text> : null}
38
+ {children}
39
+ </View>;
40
+ }
41
+
42
+ export interface ListItemProps extends Omit<ViewProps, 'children'> {
43
+ title: string;
44
+ description?: string;
45
+ leading?: ReactNode;
46
+ trailing?: ReactNode;
47
+ className?: string;
48
+ onPress?: PressableProps['onPress'];
49
+ disabled?: boolean;
50
+ }
51
+ export const ListItem = forwardRef<ElementRef<typeof View>, ListItemProps>(function ListItem({ title, description, leading, trailing, className, disabled = false, onPress, accessibilityState, ...props }, ref) {
52
+ const content = <>{leading}<View className="min-w-0 flex-1 gap-1"><Text className="font-semibold">{title}</Text>{description ? <Text variant="small" tone="muted">{description}</Text> : null}</View>{trailing}</>;
53
+ const classes = cn('min-h-[48px] min-w-[44px] flex-row items-center gap-3 border-b border-ui-line py-3', onPress && focusRing, disabled && 'opacity-50', className);
54
+ return onPress ? <Pressable {...props} ref={ref} onPress={onPress} disabled={disabled} aria-disabled={disabled} accessibilityRole="button" accessibilityState={{ ...accessibilityState, disabled }} className={classes}>{content}</Pressable> : <View {...props} ref={ref} accessibilityState={accessibilityState} className={classes}>{content}</View>;
55
+ });
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ import { useId, type ReactNode } from 'react';
4
+ import { Pressable, View, type ViewProps } from 'react-native';
5
+ import { cn, focusRing, useControllableState } from '../internal/utils';
6
+ import { Text } from './typography';
7
+
8
+ export interface AccordionItem { value: string; title: string; content: ReactNode; disabled?: boolean }
9
+ export interface AccordionProps extends ViewProps {
10
+ items: AccordionItem[];
11
+ value?: string;
12
+ defaultValue?: string;
13
+ onValueChange?: (value: string) => void;
14
+ className?: string;
15
+ }
16
+
17
+ /** Single-open disclosure. An empty value collapses every section. */
18
+ export function Accordion({ items, value, defaultValue = '', onValueChange, className, ...props }: AccordionProps) {
19
+ const [selected, setSelected] = useControllableState({ value, defaultValue, onChange: onValueChange });
20
+ const id = useId();
21
+ return <View {...props} className={className}>{items.map((item, index) => {
22
+ const expanded = selected === item.value;
23
+ const triggerID = `${id}-trigger-${index}`;
24
+ const panelID = `${id}-panel-${index}`;
25
+ return <View key={item.value} className="border-b border-ui-line">
26
+ <Pressable nativeID={triggerID} accessibilityRole="button" accessibilityLabel={item.title} accessibilityState={{ expanded, disabled: item.disabled }} aria-expanded={expanded} aria-disabled={item.disabled} {...{ 'aria-controls': expanded ? panelID : undefined }} disabled={item.disabled} onPress={() => setSelected(expanded ? '' : item.value)} className={cn('min-h-[48px] min-w-[44px] flex-row items-center justify-between gap-4 py-3', focusRing, item.disabled && 'opacity-50')}>
27
+ <Text className="flex-1 font-semibold">{item.title}</Text>
28
+ <View accessible={false} className={cn('mr-1 h-2 w-2 border-b-2 border-r-2 border-ui-text', expanded ? 'rotate-[225deg]' : 'rotate-45')} />
29
+ </Pressable>
30
+ {expanded ? <View nativeID={panelID} aria-labelledby={triggerID} className="gap-3 pb-4">{item.content}</View> : null}
31
+ </View>;
32
+ })}</View>;
33
+ }
@@ -0,0 +1,68 @@
1
+ 'use client';
2
+
3
+ import { useEffect, type ReactNode } from 'react';
4
+ import { ActivityIndicator, Pressable, View, type ViewProps } from 'react-native';
5
+ import { cn, focusRing, useControllableState } from '../internal/utils';
6
+ import { useTheme } from '../theme';
7
+ import { Text } from './typography';
8
+ import type { StatusTone } from './content';
9
+
10
+ const paints = { neutral: 'bg-ui-selected', info: 'bg-ui-info-surface', success: 'bg-ui-success-surface', warning: 'bg-ui-warning-surface', error: 'bg-ui-error-surface' };
11
+ const texts = { neutral: 'text-ui-text', info: 'text-ui-info', success: 'text-ui-success', warning: 'text-ui-warning', error: 'text-ui-error' };
12
+ export interface AlertProps extends ViewProps {
13
+ title: string;
14
+ description?: string;
15
+ children?: ReactNode;
16
+ tone?: StatusTone;
17
+ announce?: 'off' | 'polite' | 'assertive';
18
+ className?: string;
19
+ }
20
+ export function Alert({ title, description, children, tone = 'info', announce = 'off', className, ...props }: AlertProps) {
21
+ return <View {...props} accessibilityLiveRegion={announce === 'off' ? 'none' : announce} aria-live={announce} className={cn('gap-2 rounded-xl p-4', paints[tone], className)}>
22
+ <Text className={cn('font-semibold', texts[tone])}>{title}</Text>
23
+ {description ? <Text className={texts[tone]}>{description}</Text> : null}
24
+ {children}
25
+ </View>;
26
+ }
27
+
28
+ export interface ToastProps extends Omit<AlertProps, 'announce'> {
29
+ open?: boolean;
30
+ defaultOpen?: boolean;
31
+ onOpenChange?: (open: boolean) => void;
32
+ /** Zero keeps the notification until explicitly dismissed. */
33
+ duration?: number;
34
+ dismissLabel?: string;
35
+ }
36
+ /** Nonmodal inline notification. The consumer chooses its location. */
37
+ export function Toast({ open, defaultOpen = true, onOpenChange, duration = 0, dismissLabel = 'Dismiss notification', className, children, ...props }: ToastProps) {
38
+ const [visible, setVisible] = useControllableState({ value: open, defaultValue: defaultOpen, onChange: onOpenChange });
39
+ useEffect(() => {
40
+ if (!visible || duration <= 0) return;
41
+ const timer = setTimeout(() => setVisible(false), duration);
42
+ return () => clearTimeout(timer);
43
+ }, [duration, visible, setVisible]);
44
+ if (!visible) return null;
45
+ return <Alert {...props} accessibilityRole="alert" announce="polite" className={className}>
46
+ {children}
47
+ <Pressable accessibilityRole="button" accessibilityLabel={dismissLabel} onPress={() => setVisible(false)} className={cn('min-h-[44px] min-w-[44px] self-start justify-center rounded px-2', focusRing)}><Text variant="small" className="font-semibold underline">Dismiss</Text></Pressable>
48
+ </Alert>;
49
+ }
50
+
51
+ export interface SpinnerProps extends ViewProps { label?: string; size?: 'small' | 'large'; className?: string }
52
+ export function Spinner({ label = 'Loading', size = 'small', className, ...props }: SpinnerProps) {
53
+ const { colors } = useTheme();
54
+ return <View {...props} accessibilityRole="progressbar" accessibilityLabel={label} accessibilityState={{ busy: true }} aria-busy className={cn('self-start', className)}><ActivityIndicator accessible={false} color={colors.text} size={size} /></View>;
55
+ }
56
+
57
+ export interface ProgressProps extends ViewProps { value: number; max?: number; label: string; className?: string; indicatorClassName?: string }
58
+ export function Progress({ value, max = 100, label, className, indicatorClassName, ...props }: ProgressProps) {
59
+ const maximum = Number.isFinite(max) && max > 0 ? max : 100;
60
+ const current = Math.min(maximum, Math.max(0, Number.isFinite(value) ? value : 0));
61
+ return <View {...props} accessibilityRole="progressbar" accessibilityLabel={label} accessibilityValue={{ min: 0, max: maximum, now: current }} aria-valuemin={0} aria-valuemax={maximum} aria-valuenow={current} className={cn('h-2 w-full overflow-hidden rounded-full bg-ui-selected', className)}>
62
+ <View className={cn('h-full bg-bubba-red', indicatorClassName)} style={{ width: `${current / maximum * 100}%` }} />
63
+ </View>;
64
+ }
65
+
66
+ export interface SkeletonProps extends ViewProps { className?: string }
67
+ /** Static placeholder; avoids forcing motion on either platform. */
68
+ export function Skeleton({ className, ...props }: SkeletonProps) { return <View {...props} accessible={false} aria-hidden importantForAccessibility="no-hide-descendants" className={cn('h-4 w-full rounded bg-ui-selected', className)} />; }
@@ -0,0 +1,112 @@
1
+ 'use client';
2
+
3
+ import { forwardRef, useId, type ElementRef, type KeyboardEvent, type SyntheticEvent } from 'react';
4
+ import { Platform, Pressable, Text, View, type GestureResponderEvent, type PressableProps } from 'react-native';
5
+ import { cn, focusRing, useControllableState } from '../../internal/utils';
6
+ import { useFormField } from './FormField';
7
+
8
+ /** RNW's Enter activation forwards a raw DOM keyup, without React event methods. */
9
+ export interface ChoiceDOMKeyboardEvent {
10
+ readonly key: string;
11
+ readonly code: string;
12
+ readonly repeat: boolean;
13
+ readonly type: string;
14
+ readonly defaultPrevented: boolean;
15
+ readonly target: unknown;
16
+ readonly currentTarget: unknown;
17
+ preventDefault(): void;
18
+ stopPropagation(): void;
19
+ }
20
+ export type ChoicePressEvent = GestureResponderEvent | SyntheticEvent<unknown, unknown> | ChoiceDOMKeyboardEvent;
21
+ export interface ChoiceProps extends Omit<PressableProps, 'children' | 'onPress'> {
22
+ checked?: boolean;
23
+ defaultChecked?: boolean;
24
+ onCheckedChange?: (checked: boolean) => void;
25
+ /** Web supplies React keyboard events for Space and raw DOM keyup for Enter. */
26
+ onPress?: ((event: ChoicePressEvent) => void) | null;
27
+ label?: string;
28
+ description?: string;
29
+ invalid?: boolean;
30
+ required?: boolean;
31
+ className?: string;
32
+ textClassName?: string;
33
+ }
34
+
35
+ export interface CheckboxProps extends ChoiceProps {
36
+ /** A mixed checkbox becomes checked on its next activation. */
37
+ indeterminate?: boolean;
38
+ }
39
+ export interface SwitchProps extends ChoiceProps { }
40
+
41
+ interface ChoiceControlProps extends CheckboxProps { kind: 'checkbox' | 'switch' }
42
+
43
+ /** RNW Pressable handles Enter; non-button roles need an explicit Space handler. */
44
+ function spaceKeyHandler(disabled: boolean, activate: (event: KeyboardEvent<unknown>) => void) {
45
+ return Platform.OS === 'web' ? {
46
+ onKeyDown(event: KeyboardEvent<unknown>) {
47
+ const key = event.key;
48
+ if (key === ' ' || key === 'Spacebar') {
49
+ event.preventDefault();
50
+ if (!disabled && !event.repeat) activate(event);
51
+ }
52
+ },
53
+ } : {};
54
+ }
55
+
56
+ const ChoiceControl = forwardRef<ElementRef<typeof Pressable>, ChoiceControlProps>(function ChoiceControl({
57
+ kind, checked, defaultChecked = false, onCheckedChange, indeterminate = false, label, description, invalid, required,
58
+ disabled, className, textClassName, onPress, accessibilityLabel, accessibilityHint, accessibilityState,
59
+ 'aria-label': ariaLabel, 'aria-disabled': ariaDisabled, nativeID, ...props
60
+ }, ref) {
61
+ const field = useFormField();
62
+ const generatedID = useId().replace(/:/g, '');
63
+ const id = nativeID ?? field?.controlID ?? `bui-choice-${generatedID}`;
64
+ const isDisabled = Boolean(disabled || ariaDisabled || accessibilityState?.disabled || field?.disabled);
65
+ const [isChecked, setChecked] = useControllableState({ value: checked, defaultValue: defaultChecked, onChange: onCheckedChange });
66
+ const mixed = kind === 'checkbox' && indeterminate;
67
+ const state = mixed ? 'mixed' : isChecked;
68
+ const name = accessibilityLabel ?? ariaLabel ?? label ?? field?.label;
69
+ const isRequired = required ?? field?.required ?? false;
70
+ const isInvalid = invalid ?? field?.invalid ?? false;
71
+ const hint = [accessibilityHint ?? description ?? field?.hint, field?.error && `Error: ${field.error}`,
72
+ isRequired && 'Required'].filter(Boolean).join('. ') || undefined;
73
+ const activate = (event: ChoicePressEvent) => {
74
+ if (isDisabled) return;
75
+ setChecked(mixed || !isChecked);
76
+ onPress?.(event);
77
+ };
78
+ const descriptions = [description && `${id}-description`, field?.descriptionID].filter(Boolean).join(' ') || undefined;
79
+
80
+ return <Pressable {...props} {...spaceKeyHandler(isDisabled, activate)} ref={ref} nativeID={id}
81
+ accessibilityRole={kind} accessibilityLabel={name} accessibilityHint={hint}
82
+ accessibilityState={{ ...accessibilityState, checked: state, disabled: isDisabled }}
83
+ aria-checked={state} aria-disabled={isDisabled} disabled={isDisabled}
84
+ {...(Platform.OS === 'web' ? { 'aria-describedby': descriptions, 'aria-required': isRequired,
85
+ 'aria-invalid': isInvalid } : {})}
86
+ onPress={activate}
87
+ className={cn('min-h-[44px] min-w-[44px] flex-row items-center gap-3 rounded-[4px] py-2', focusRing,
88
+ isDisabled && 'opacity-50', className)}>
89
+ {kind === 'checkbox' ? <View accessible={false} importantForAccessibility="no-hide-descendants"
90
+ className={cn('h-5 w-5 items-center justify-center rounded-[4px] border-2 border-ui-border',
91
+ (isChecked || mixed) && 'border-bubba-red bg-bubba-red', isInvalid && 'border-ui-error')}>
92
+ {mixed ? <View className="h-0.5 w-3 bg-white" /> : isChecked ?
93
+ <View className="h-[10px] w-[6px] -translate-y-px rotate-45 border-b-2 border-r-2 border-white" /> : null}
94
+ </View> : <View accessible={false} importantForAccessibility="no-hide-descendants"
95
+ className={cn('h-[26px] w-[44px] rounded-full border border-ui-border bg-ui-selected p-[2px]',
96
+ isChecked && 'border-bubba-red bg-bubba-red', isInvalid && 'border-ui-error')}>
97
+ <View className={cn('h-5 w-5 rounded-full', isChecked ? 'translate-x-[18px] bg-white' : 'translate-x-0 bg-ui-muted')} />
98
+ </View>}
99
+ {label || description ? <View className="min-w-0 flex-1 gap-1">
100
+ {label ? <Text className={cn('text-base font-medium text-ui-text', textClassName)}>{label}</Text> : null}
101
+ {description ? <Text nativeID={`${id}-description`} className="text-sm leading-5 text-ui-muted">{description}</Text> : null}
102
+ </View> : null}
103
+ </Pressable>;
104
+ });
105
+
106
+ export const Checkbox = forwardRef<ElementRef<typeof Pressable>, CheckboxProps>(function Checkbox(props, ref) {
107
+ return <ChoiceControl {...props} ref={ref} kind="checkbox" />;
108
+ });
109
+
110
+ export const Switch = forwardRef<ElementRef<typeof Pressable>, SwitchProps>(function Switch(props, ref) {
111
+ return <ChoiceControl {...props} ref={ref} kind="switch" />;
112
+ });
@@ -0,0 +1,58 @@
1
+ 'use client';
2
+
3
+ import { createContext, forwardRef, useContext, useId, type ElementRef, type ReactNode } from 'react';
4
+ import { Platform, Text, View, type ViewProps } from 'react-native';
5
+ import { cn } from '../../internal/utils';
6
+
7
+ interface FieldContextValue {
8
+ controlID: string;
9
+ labelID: string;
10
+ label: string;
11
+ descriptionID?: string;
12
+ hint?: string;
13
+ error?: string;
14
+ invalid: boolean;
15
+ required: boolean;
16
+ disabled: boolean;
17
+ }
18
+
19
+ const FieldContext = createContext<FieldContextValue | undefined>(undefined);
20
+ export function useFormField() { return useContext(FieldContext); }
21
+
22
+ export interface FormFieldProps extends ViewProps {
23
+ label: string;
24
+ hint?: string;
25
+ error?: string;
26
+ required?: boolean;
27
+ disabled?: boolean;
28
+ /** Opt into announcing errors that change during interaction. */
29
+ announceError?: boolean;
30
+ children: ReactNode;
31
+ className?: string;
32
+ labelClassName?: string;
33
+ }
34
+
35
+ /** Associates a visible label, hint, and error with the enclosed form control. */
36
+ export const FormField = forwardRef<ElementRef<typeof View>, FormFieldProps>(function FormField({ label, hint, error, required = false, disabled = false,
37
+ announceError = false, children, nativeID, className, labelClassName, ...props }, ref) {
38
+ const generatedID = useId().replace(/:/g, '');
39
+ const id = nativeID ?? `bui-field-${generatedID}`;
40
+ const descriptionID = [hint && `${id}-hint`, error && `${id}-error`].filter(Boolean).join(' ') || undefined;
41
+ const field: FieldContextValue = { controlID: `${id}-control`, labelID: `${id}-label`,
42
+ label, descriptionID, hint, error, invalid: Boolean(error), required, disabled };
43
+
44
+ return (
45
+ <FieldContext.Provider value={field}>
46
+ <View {...props} ref={ref} nativeID={nativeID} className={cn('gap-2', className)}>
47
+ <Text nativeID={field.labelID} className={cn('text-base font-semibold text-ui-text', labelClassName)}>
48
+ {label}{required ? ' *' : ''}
49
+ </Text>
50
+ {children}
51
+ {hint ? <Text nativeID={`${id}-hint`} className="text-sm leading-5 text-ui-muted">{hint}</Text> : null}
52
+ {error ? <Text nativeID={`${id}-error`} accessibilityLiveRegion={announceError ? 'polite' : 'none'}
53
+ {...(Platform.OS === 'web' ? { 'aria-live': announceError ? 'polite' as const : 'off' as const } : {})}
54
+ className="text-sm leading-5 text-ui-error">{error}</Text> : null}
55
+ </View>
56
+ </FieldContext.Provider>
57
+ );
58
+ });
@@ -0,0 +1,55 @@
1
+ 'use client';
2
+
3
+ import { forwardRef } from 'react';
4
+ import { Platform, TextInput, type TextInputProps } from 'react-native';
5
+ import { cn, focusRing } from '../../internal/utils';
6
+ import { useTheme } from '../../theme';
7
+ import { useFormField } from './FormField';
8
+
9
+ export interface InputProps extends TextInputProps {
10
+ disabled?: boolean;
11
+ invalid?: boolean;
12
+ required?: boolean;
13
+ className?: string;
14
+ }
15
+
16
+ /** Native TextInput with field association and matching light/dark paint. */
17
+ export const Input = forwardRef<TextInput, InputProps>(function Input({ disabled, invalid, required,
18
+ readOnly = false, editable, className, nativeID, accessibilityLabel, accessibilityHint,
19
+ accessibilityState, 'aria-label': ariaLabel, 'aria-disabled': ariaDisabled, placeholderTextColor, selectionColor, ...props }, ref) {
20
+ const field = useFormField();
21
+ const { colors } = useTheme();
22
+ const isDisabled = Boolean(disabled || ariaDisabled || accessibilityState?.disabled || field?.disabled);
23
+ const isReadOnly = readOnly || (editable === false && !isDisabled);
24
+ const isInvalid = invalid ?? field?.invalid ?? false;
25
+ const isRequired = required ?? field?.required ?? false;
26
+ const explicitLabel = accessibilityLabel ?? ariaLabel;
27
+ const nativeHint = [accessibilityHint ?? field?.hint, field?.error && `Error: ${field.error}`,
28
+ isRequired && 'Required', isReadOnly && 'Read only'].filter(Boolean).join('. ') || undefined;
29
+ const webProps = Platform.OS === 'web' ? {
30
+ disabled: isDisabled,
31
+ readOnly: isReadOnly,
32
+ 'aria-describedby': field?.descriptionID,
33
+ 'aria-labelledby': explicitLabel ? undefined : field?.labelID,
34
+ 'aria-invalid': isInvalid,
35
+ 'aria-required': isRequired,
36
+ 'aria-readonly': isReadOnly || undefined,
37
+ } : {};
38
+
39
+ return <TextInput {...props} {...webProps} ref={ref} nativeID={nativeID ?? field?.controlID}
40
+ accessibilityLabel={explicitLabel ?? (Platform.OS === 'web' ? undefined : field?.label)}
41
+ accessibilityHint={nativeHint} accessibilityState={{ ...accessibilityState, disabled: isDisabled }}
42
+ aria-disabled={isDisabled} editable={!isDisabled && !isReadOnly}
43
+ placeholderTextColor={placeholderTextColor ?? colors.muted} selectionColor={selectionColor ?? colors.selection}
44
+ className={cn('min-h-[48px] rounded-[4px] border border-ui-border bg-ui-surface px-3 py-3 text-base text-ui-text',
45
+ focusRing, isInvalid && 'border-ui-error', isDisabled && 'bg-ui-disabled-surface text-ui-disabled-text', className)} />;
46
+ });
47
+
48
+ export interface TextareaProps extends InputProps { }
49
+
50
+ /** A multiline Input; numberOfLines and style/minHeight remain native props. */
51
+ export const Textarea = forwardRef<TextInput, TextareaProps>(function Textarea({ className,
52
+ numberOfLines = 4, textAlignVertical = 'top', ...props }, ref) {
53
+ return <Input {...props} ref={ref} multiline numberOfLines={numberOfLines} textAlignVertical={textAlignVertical}
54
+ className={cn('min-h-[120px]', className)} />;
55
+ });
@@ -0,0 +1,87 @@
1
+ 'use client';
2
+
3
+ import { forwardRef, useId, type ElementRef } from 'react';
4
+ import { Platform, Pressable, Text, View, type ViewProps } from 'react-native';
5
+ import { cn, focusRing, useControllableState } from '../../internal/utils';
6
+ import { useFormField } from './FormField';
7
+ import { useSelectionNavigation } from './selection';
8
+
9
+ export interface RadioOption {
10
+ value: string;
11
+ label: string;
12
+ description?: string;
13
+ disabled?: boolean;
14
+ }
15
+
16
+ export interface RadioGroupProps extends Omit<ViewProps, 'children'> {
17
+ options: readonly RadioOption[];
18
+ value?: string;
19
+ defaultValue?: string;
20
+ onValueChange?: (value: string) => void;
21
+ label?: string;
22
+ disabled?: boolean;
23
+ required?: boolean;
24
+ invalid?: boolean;
25
+ orientation?: 'horizontal' | 'vertical';
26
+ className?: string;
27
+ optionClassName?: string;
28
+ textClassName?: string;
29
+ }
30
+
31
+ /** Single selection with roving web focus and native radio accessibility. */
32
+ export const RadioGroup = forwardRef<ElementRef<typeof View>, RadioGroupProps>(function RadioGroup({
33
+ options, value, defaultValue, onValueChange, label, disabled = false, required, invalid,
34
+ orientation = 'vertical', className, optionClassName, textClassName, nativeID,
35
+ accessibilityLabel, accessibilityHint, accessibilityState, 'aria-label': ariaLabel, 'aria-disabled': ariaDisabled, ...props
36
+ }, ref) {
37
+ const field = useFormField();
38
+ const generatedID = useId().replace(/:/g, '');
39
+ const id = nativeID ?? field?.controlID ?? `bui-radio-${generatedID}`;
40
+ const isDisabled = Boolean(disabled || ariaDisabled || accessibilityState?.disabled || field?.disabled);
41
+ const isRequired = required ?? field?.required ?? false;
42
+ const isInvalid = invalid ?? field?.invalid ?? false;
43
+ const [selected, setSelected] = useControllableState<string | undefined>({
44
+ value, defaultValue, onChange: (next) => { if (next !== undefined) onValueChange?.(next); },
45
+ });
46
+ const select = (next: string) => {
47
+ if (!isDisabled && options.some((option) => option.value === next && !option.disabled)) setSelected(next);
48
+ };
49
+ const navigation = useSelectionNavigation(options, selected, select, orientation, isDisabled);
50
+ const labelID = label ? `${id}-label` : field?.labelID;
51
+ const explicitLabel = accessibilityLabel ?? ariaLabel;
52
+ const name = explicitLabel ?? label ?? field?.label;
53
+ const hint = [accessibilityHint ?? field?.hint, field?.error && `Error: ${field.error}`,
54
+ isRequired && 'Required'].filter(Boolean).join('. ') || undefined;
55
+
56
+ return <View {...props} ref={ref} nativeID={id} className={cn('gap-2', className)}>
57
+ {label ? <Text nativeID={labelID} className="text-base font-semibold text-ui-text">{label}</Text> : null}
58
+ <View accessibilityRole="radiogroup" accessibilityLabel={Platform.OS === 'web' && labelID && !explicitLabel ? undefined : name}
59
+ accessibilityHint={hint} accessibilityState={{ ...accessibilityState, disabled: isDisabled }} aria-disabled={isDisabled}
60
+ {...(Platform.OS === 'web' ? { 'aria-labelledby': explicitLabel ? undefined : labelID,
61
+ 'aria-describedby': field?.descriptionID, 'aria-required': isRequired, 'aria-invalid': isInvalid,
62
+ 'aria-orientation': orientation } : {})}
63
+ className={cn('gap-1', orientation === 'horizontal' && 'flex-row flex-wrap gap-x-6')}>
64
+ {options.map((option, index) => {
65
+ const checked = selected === option.value;
66
+ const optionDisabled = isDisabled || Boolean(option.disabled);
67
+ const optionID = `${id}-option-${index}`;
68
+ return <Pressable key={option.value} {...navigation(index)} nativeID={optionID}
69
+ accessibilityRole="radio" accessibilityLabel={option.label} accessibilityHint={option.description}
70
+ accessibilityState={{ checked, disabled: optionDisabled }} aria-checked={checked} aria-disabled={optionDisabled}
71
+ {...(Platform.OS === 'web' ? { 'aria-describedby': option.description ? `${optionID}-description` : undefined } : {})}
72
+ disabled={optionDisabled} onPress={() => select(option.value)}
73
+ className={cn('min-h-[44px] min-w-[44px] flex-row items-center gap-3 rounded-[4px] py-2',
74
+ focusRing, optionDisabled && 'opacity-50', optionClassName)}>
75
+ <View accessible={false} importantForAccessibility="no-hide-descendants"
76
+ className={cn('h-5 w-5 items-center justify-center rounded-full border-2 border-ui-border', checked && 'border-bubba-red')}>
77
+ {checked ? <View className="h-2.5 w-2.5 rounded-full bg-bubba-red" /> : null}
78
+ </View>
79
+ <View className="min-w-0 flex-1 gap-1">
80
+ <Text className={cn('text-base font-medium text-ui-text', textClassName)}>{option.label}</Text>
81
+ {option.description ? <Text nativeID={`${optionID}-description`} className="text-sm leading-5 text-ui-muted">{option.description}</Text> : null}
82
+ </View>
83
+ </Pressable>;
84
+ })}
85
+ </View>
86
+ </View>;
87
+ });