@bubbasfw/ui 0.1.0 → 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.
- package/README.md +65 -42
- package/dist/index.cjs +1106 -14
- package/dist/index.d.cts +358 -4
- package/dist/index.d.ts +358 -4
- package/dist/index.js +1076 -18
- package/dist/tokens.cjs +92 -1
- package/dist/tokens.d.cts +100 -0
- package/dist/tokens.d.ts +100 -0
- package/dist/tokens.js +92 -1
- package/package.json +2 -2
- package/src/components/Button.tsx +18 -18
- package/src/components/actions.tsx +39 -0
- package/src/components/content.tsx +55 -0
- package/src/components/disclosure.tsx +33 -0
- package/src/components/feedback.tsx +68 -0
- package/src/components/forms/Choice.tsx +112 -0
- package/src/components/forms/FormField.tsx +58 -0
- package/src/components/forms/Input.tsx +55 -0
- package/src/components/forms/RadioGroup.tsx +87 -0
- package/src/components/forms/Select.tsx +102 -0
- package/src/components/forms/Tabs.tsx +70 -0
- package/src/components/forms/index.ts +6 -0
- package/src/components/forms/selection.ts +48 -0
- package/src/components/layout.tsx +35 -0
- package/src/components/navigation.tsx +27 -0
- package/src/components/overlays.tsx +64 -0
- package/src/components/typography.tsx +31 -0
- package/src/index.ts +19 -0
- package/src/internal/scroll-lock.ts +101 -0
- package/src/internal/utils.ts +32 -0
- package/src/theme.tsx +56 -0
- package/src/tokens.ts +91 -0
- package/tailwind-preset.cjs +11 -1
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { forwardRef, useEffect, useId, useState, type ElementRef } from 'react';
|
|
4
|
+
import { Platform, Pressable, Text, View, type PressableProps } from 'react-native';
|
|
5
|
+
import { cn, focusRing, useControllableState } from '../../internal/utils';
|
|
6
|
+
import { Dialog } from '../overlays';
|
|
7
|
+
import { useFormField } from './FormField';
|
|
8
|
+
import type { RadioOption } from './RadioGroup';
|
|
9
|
+
import { useSelectionNavigation } from './selection';
|
|
10
|
+
|
|
11
|
+
export type SelectOption = RadioOption;
|
|
12
|
+
export interface SelectProps extends Omit<PressableProps, 'children'> {
|
|
13
|
+
options: readonly SelectOption[];
|
|
14
|
+
value?: string;
|
|
15
|
+
defaultValue?: string;
|
|
16
|
+
onValueChange?: (value: string) => void;
|
|
17
|
+
label?: string;
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
required?: boolean;
|
|
20
|
+
invalid?: boolean;
|
|
21
|
+
className?: string;
|
|
22
|
+
textClassName?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A labelled button opens a single-choice dialog on web and native. */
|
|
26
|
+
export const Select = forwardRef<ElementRef<typeof Pressable>, SelectProps>(function Select({
|
|
27
|
+
options, value, defaultValue, onValueChange, label, placeholder = 'Choose an option',
|
|
28
|
+
required, invalid, disabled, className, textClassName, nativeID, testID, onPress,
|
|
29
|
+
accessibilityLabel, accessibilityHint, accessibilityState, 'aria-label': ariaLabel, 'aria-disabled': ariaDisabled, ...props
|
|
30
|
+
}, ref) {
|
|
31
|
+
const field = useFormField();
|
|
32
|
+
const generatedID = useId().replace(/:/g, '');
|
|
33
|
+
const id = nativeID ?? field?.controlID ?? `bui-select-${generatedID}`;
|
|
34
|
+
const isDisabled = Boolean(disabled || ariaDisabled || accessibilityState?.disabled || field?.disabled);
|
|
35
|
+
const isInvalid = invalid ?? field?.invalid ?? false;
|
|
36
|
+
const isRequired = required ?? field?.required ?? false;
|
|
37
|
+
const [selected, setSelected] = useControllableState<string | undefined>({ value, defaultValue,
|
|
38
|
+
onChange: (next) => { if (next !== undefined) onValueChange?.(next); },
|
|
39
|
+
});
|
|
40
|
+
const [open, setOpen] = useState(false);
|
|
41
|
+
const [pending, setPending] = useState(selected);
|
|
42
|
+
useEffect(() => { if (isDisabled) setOpen(false); }, [isDisabled]);
|
|
43
|
+
const selectedOption = options.find((option) => option.value === selected);
|
|
44
|
+
const title = label ?? field?.label ?? 'Choose an option';
|
|
45
|
+
const labelID = label ? `${id}-label` : field?.labelID;
|
|
46
|
+
const explicitLabel = accessibilityLabel ?? ariaLabel;
|
|
47
|
+
const hint = [accessibilityHint ?? field?.hint, field?.error && `Error: ${field.error}`,
|
|
48
|
+
isRequired && 'Required', 'Opens a list of choices'].filter(Boolean).join('. ');
|
|
49
|
+
const commit = (next: string) => {
|
|
50
|
+
if (isDisabled || !options.some((option) => option.value === next && !option.disabled)) return;
|
|
51
|
+
setSelected(next);
|
|
52
|
+
setOpen(false);
|
|
53
|
+
};
|
|
54
|
+
const navigation = useSelectionNavigation(options, pending, setPending, 'vertical', isDisabled, commit);
|
|
55
|
+
|
|
56
|
+
return <View className="gap-2">
|
|
57
|
+
{label ? <Text nativeID={labelID} className="text-base font-semibold text-ui-text">{label}</Text> : null}
|
|
58
|
+
<Pressable {...props} ref={ref} nativeID={id} testID={testID} accessibilityRole="button"
|
|
59
|
+
accessibilityLabel={explicitLabel ?? (Platform.OS === 'web' ? undefined : `${title}: ${selectedOption?.label ?? placeholder}`)}
|
|
60
|
+
accessibilityHint={hint} accessibilityState={{ ...accessibilityState, disabled: isDisabled, expanded: open }}
|
|
61
|
+
aria-disabled={isDisabled} aria-expanded={open} disabled={isDisabled}
|
|
62
|
+
{...(Platform.OS === 'web' ? { 'aria-haspopup': 'dialog',
|
|
63
|
+
'aria-labelledby': !explicitLabel && labelID ? `${labelID} ${id}-value` : undefined,
|
|
64
|
+
'aria-describedby': field?.descriptionID, 'aria-invalid': isInvalid } : {})}
|
|
65
|
+
onPress={(event) => { if (!isDisabled) { setPending(selected); setOpen(true); } onPress?.(event); }}
|
|
66
|
+
className={cn('min-h-[48px] flex-row items-center justify-between gap-4 rounded-[4px] border border-ui-border bg-ui-surface px-3 py-3',
|
|
67
|
+
focusRing, isInvalid && 'border-ui-error', isDisabled && 'bg-ui-disabled-surface', className)}>
|
|
68
|
+
<Text nativeID={`${id}-value`} className={cn('min-w-0 flex-1 text-base', selectedOption ? 'text-ui-text' : 'text-ui-muted',
|
|
69
|
+
isDisabled && 'text-ui-disabled-text', textClassName)}>{selectedOption?.label ?? placeholder}</Text>
|
|
70
|
+
<View accessible={false} importantForAccessibility="no-hide-descendants"
|
|
71
|
+
className="mr-1 h-2 w-2 rotate-45 border-b-2 border-r-2 border-ui-muted" />
|
|
72
|
+
</Pressable>
|
|
73
|
+
<Dialog open={open} onOpenChange={setOpen} title={title} closeLabel="Cancel"
|
|
74
|
+
testID={testID ? `${testID}-dialog` : undefined}>
|
|
75
|
+
<View accessibilityRole="radiogroup" accessibilityLabel={title}
|
|
76
|
+
{...(Platform.OS === 'web' ? { 'aria-required': isRequired } : {})} className="gap-1">
|
|
77
|
+
{options.map((option, index) => {
|
|
78
|
+
const checked = pending === option.value;
|
|
79
|
+
const optionID = `${id}-option-${index}`;
|
|
80
|
+
return <Pressable key={option.value} {...navigation(index)} nativeID={optionID}
|
|
81
|
+
testID={testID ? `${testID}-option-${option.value}` : undefined}
|
|
82
|
+
accessibilityRole="radio" accessibilityLabel={option.label} accessibilityHint={option.description}
|
|
83
|
+
accessibilityState={{ checked, disabled: Boolean(option.disabled) }}
|
|
84
|
+
aria-checked={checked} aria-disabled={Boolean(option.disabled)} disabled={option.disabled}
|
|
85
|
+
{...(Platform.OS === 'web' ? { 'aria-describedby': option.description ? `${optionID}-description` : undefined } : {})}
|
|
86
|
+
onPress={() => commit(option.value)} className={cn('min-h-[48px] min-w-[44px] flex-row items-center gap-3 rounded-[4px] px-3 py-3',
|
|
87
|
+
focusRing, checked && 'bg-ui-selected', option.disabled && 'opacity-50')}>
|
|
88
|
+
<View accessible={false} importantForAccessibility="no-hide-descendants"
|
|
89
|
+
className={cn('h-5 w-5 items-center justify-center rounded-full border-2 border-ui-border', checked && 'border-bubba-red')}>
|
|
90
|
+
{checked ? <View className="h-2.5 w-2.5 rounded-full bg-bubba-red" /> : null}
|
|
91
|
+
</View>
|
|
92
|
+
<View className="min-w-0 flex-1 gap-1">
|
|
93
|
+
<Text className="text-base font-medium text-ui-text">{option.label}</Text>
|
|
94
|
+
{option.description ? <Text nativeID={`${optionID}-description`} className="text-sm leading-5 text-ui-muted">{option.description}</Text> : null}
|
|
95
|
+
</View>
|
|
96
|
+
</Pressable>;
|
|
97
|
+
})}
|
|
98
|
+
{options.length === 0 ? <Text className="px-3 py-4 text-base text-ui-muted">No options available.</Text> : null}
|
|
99
|
+
</View>
|
|
100
|
+
</Dialog>
|
|
101
|
+
</View>;
|
|
102
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { forwardRef, useId, type ElementRef, type ReactNode } from 'react';
|
|
4
|
+
import { Platform, Pressable, Text, View, type ViewProps } from 'react-native';
|
|
5
|
+
import { cn, focusRing, useControllableState } from '../../internal/utils';
|
|
6
|
+
import { useSelectionNavigation } from './selection';
|
|
7
|
+
|
|
8
|
+
export interface TabItem {
|
|
9
|
+
value: string;
|
|
10
|
+
label: string;
|
|
11
|
+
content: ReactNode;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TabsProps extends Omit<ViewProps, 'children'> {
|
|
16
|
+
items: readonly TabItem[];
|
|
17
|
+
value?: string;
|
|
18
|
+
defaultValue?: string;
|
|
19
|
+
onValueChange?: (value: string) => void;
|
|
20
|
+
label?: string;
|
|
21
|
+
orientation?: 'horizontal' | 'vertical';
|
|
22
|
+
className?: string;
|
|
23
|
+
tabListClassName?: string;
|
|
24
|
+
tabClassName?: string;
|
|
25
|
+
textClassName?: string;
|
|
26
|
+
panelClassName?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Automatically activates tabs with arrow keys; inactive panels are unmounted. */
|
|
30
|
+
export const Tabs = forwardRef<ElementRef<typeof View>, TabsProps>(function Tabs({
|
|
31
|
+
items, value, defaultValue, onValueChange, label = 'Content sections', orientation = 'horizontal',
|
|
32
|
+
className, tabListClassName, tabClassName, textClassName, panelClassName, nativeID, ...props
|
|
33
|
+
}, ref) {
|
|
34
|
+
const generatedID = useId().replace(/:/g, '');
|
|
35
|
+
const id = nativeID ?? `bui-tabs-${generatedID}`;
|
|
36
|
+
const [selected, setSelected] = useControllableState<string | undefined>({ value,
|
|
37
|
+
defaultValue: defaultValue ?? items.find((item) => !item.disabled)?.value,
|
|
38
|
+
onChange: (next) => { if (next !== undefined) onValueChange?.(next); },
|
|
39
|
+
});
|
|
40
|
+
const select = (next: string) => {
|
|
41
|
+
if (items.some((item) => item.value === next && !item.disabled)) setSelected(next);
|
|
42
|
+
};
|
|
43
|
+
const navigation = useSelectionNavigation(items, selected, select, orientation);
|
|
44
|
+
const activeIndex = items.findIndex((item) => item.value === selected && !item.disabled);
|
|
45
|
+
const active = items[activeIndex];
|
|
46
|
+
|
|
47
|
+
return <View {...props} ref={ref} nativeID={id} className={cn('gap-4', className)}>
|
|
48
|
+
<View accessibilityRole="tablist" accessibilityLabel={label}
|
|
49
|
+
{...(Platform.OS === 'web' ? { 'aria-orientation': orientation } : {})}
|
|
50
|
+
className={cn('gap-1 rounded-md border border-ui-border bg-ui-selected p-1',
|
|
51
|
+
orientation === 'horizontal' && 'flex-row flex-wrap', tabListClassName)}>
|
|
52
|
+
{items.map((item, index) => {
|
|
53
|
+
const isSelected = index === activeIndex;
|
|
54
|
+
return <Pressable key={item.value} {...navigation(index)} nativeID={`${id}-tab-${index}`}
|
|
55
|
+
accessibilityRole="tab" accessibilityLabel={item.label}
|
|
56
|
+
accessibilityState={{ selected: isSelected, disabled: Boolean(item.disabled) }}
|
|
57
|
+
aria-selected={isSelected} aria-disabled={Boolean(item.disabled)} disabled={item.disabled}
|
|
58
|
+
{...(Platform.OS === 'web' ? { 'aria-controls': isSelected ? `${id}-panel-${index}` : undefined } : {})}
|
|
59
|
+
onPress={() => select(item.value)} className={cn('min-h-[44px] min-w-[44px] items-center justify-center rounded-[4px] px-4 py-2',
|
|
60
|
+
focusRing, isSelected && 'bg-ui-surface', item.disabled && 'opacity-50', tabClassName)}>
|
|
61
|
+
<Text className={cn('text-base font-semibold', isSelected ? 'text-ui-text' : 'text-ui-muted', textClassName)}>{item.label}</Text>
|
|
62
|
+
</Pressable>;
|
|
63
|
+
})}
|
|
64
|
+
</View>
|
|
65
|
+
{active ? <View key={active.value} nativeID={`${id}-panel-${activeIndex}`} role="tabpanel"
|
|
66
|
+
accessibilityLabel={Platform.OS === 'web' ? undefined : active.label} tabIndex={0}
|
|
67
|
+
{...(Platform.OS === 'web' ? { 'aria-labelledby': `${id}-tab-${activeIndex}` } : {})}
|
|
68
|
+
className={cn('rounded-[4px]', focusRing, panelClassName)}>{active.content}</View> : null}
|
|
69
|
+
</View>;
|
|
70
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { FormField, type FormFieldProps } from './FormField';
|
|
2
|
+
export { Input, Textarea, type InputProps, type TextareaProps } from './Input';
|
|
3
|
+
export { Checkbox, Switch, type ChoiceProps, type ChoicePressEvent, type ChoiceDOMKeyboardEvent, type CheckboxProps, type SwitchProps } from './Choice';
|
|
4
|
+
export { RadioGroup, type RadioGroupProps, type RadioOption } from './RadioGroup';
|
|
5
|
+
export { Select, type SelectProps, type SelectOption } from './Select';
|
|
6
|
+
export { Tabs, type TabsProps, type TabItem } from './Tabs';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef, type ElementRef } from 'react';
|
|
4
|
+
import { Platform, Pressable } from 'react-native';
|
|
5
|
+
|
|
6
|
+
interface SelectionKeyEvent {
|
|
7
|
+
key?: string;
|
|
8
|
+
nativeEvent?: { key?: string };
|
|
9
|
+
repeat?: boolean;
|
|
10
|
+
preventDefault(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** One tab stop, native touch activation, and actual focus movement on web. */
|
|
14
|
+
export function useSelectionNavigation<T extends { value: string; disabled?: boolean }>(
|
|
15
|
+
items: readonly T[], value: string | undefined, onSelect: (value: string) => void,
|
|
16
|
+
orientation: 'horizontal' | 'vertical', disabled = false, onActivate = onSelect,
|
|
17
|
+
) {
|
|
18
|
+
const refs = useRef<Array<ElementRef<typeof Pressable> | null>>([]);
|
|
19
|
+
const enabled = items.map((item, index) => !disabled && !item.disabled ? index : -1).filter((index) => index !== -1);
|
|
20
|
+
const selectedIndex = items.findIndex((item) => item.value === value && !item.disabled);
|
|
21
|
+
const tabStop = selectedIndex >= 0 && !disabled ? selectedIndex : enabled[0];
|
|
22
|
+
|
|
23
|
+
return (index: number) => ({
|
|
24
|
+
ref: (node: ElementRef<typeof Pressable> | null) => { refs.current[index] = node; },
|
|
25
|
+
tabIndex: (Platform.OS === 'web' && index !== tabStop ? -1 : 0) as 0 | -1,
|
|
26
|
+
...(Platform.OS === 'web' ? {
|
|
27
|
+
onKeyDown(event: SelectionKeyEvent) {
|
|
28
|
+
if (disabled || items[index]?.disabled) return;
|
|
29
|
+
const key = event.key ?? event.nativeEvent?.key;
|
|
30
|
+
if (key === ' ' || key === 'Spacebar') {
|
|
31
|
+
event.preventDefault();
|
|
32
|
+
if (!event.repeat) onActivate(items[index].value);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const forward = key === (orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown');
|
|
36
|
+
const backward = key === (orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp');
|
|
37
|
+
if (!forward && !backward && key !== 'Home' && key !== 'End') return;
|
|
38
|
+
event.preventDefault();
|
|
39
|
+
const position = enabled.indexOf(index);
|
|
40
|
+
const next = key === 'Home' ? enabled[0] : key === 'End' ? enabled[enabled.length - 1]
|
|
41
|
+
: enabled[(position + (forward ? 1 : -1) + enabled.length) % enabled.length];
|
|
42
|
+
if (next === undefined) return;
|
|
43
|
+
onSelect(items[next].value);
|
|
44
|
+
refs.current[next]?.focus();
|
|
45
|
+
},
|
|
46
|
+
} : {}),
|
|
47
|
+
});
|
|
48
|
+
}
|