@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.
- package/LICENSE +21 -0
- package/README.md +94 -2
- package/dist/index.cjs +1202 -0
- package/dist/index.d.cts +376 -0
- package/dist/index.d.ts +376 -0
- package/dist/index.js +1165 -0
- package/dist/tokens.cjs +116 -0
- package/dist/tokens.d.cts +139 -0
- package/dist/tokens.d.ts +139 -0
- package/dist/tokens.js +114 -0
- package/package.json +85 -4
- package/src/components/Button.tsx +105 -0
- 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 +25 -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 +114 -0
- package/tailwind-preset.cjs +24 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type ElementRef } from 'react';
|
|
4
|
+
import { View, type ViewProps } from 'react-native';
|
|
5
|
+
import { cn } from '../internal/utils';
|
|
6
|
+
|
|
7
|
+
const gaps = { 0: 'gap-0', 1: 'gap-1', 2: 'gap-2', 3: 'gap-3', 4: 'gap-4', 6: 'gap-6', 8: 'gap-8', 12: 'gap-12', 16: 'gap-16' };
|
|
8
|
+
const alignments = { start: 'items-start', center: 'items-center', end: 'items-end', stretch: 'items-stretch' };
|
|
9
|
+
const justifications = { start: 'justify-start', center: 'justify-center', end: 'justify-end', between: 'justify-between' };
|
|
10
|
+
export interface StackProps extends ViewProps {
|
|
11
|
+
gap?: keyof typeof gaps;
|
|
12
|
+
align?: keyof typeof alignments;
|
|
13
|
+
justify?: keyof typeof justifications;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
export const Stack = forwardRef<ElementRef<typeof View>, StackProps>(function Stack({ gap = 4, align = 'stretch', justify = 'start', className, ...props }, ref) {
|
|
17
|
+
return <View {...props} ref={ref} className={cn('flex-col', gaps[gap], alignments[align], justifications[justify], className)} />;
|
|
18
|
+
});
|
|
19
|
+
export interface InlineProps extends StackProps { wrap?: boolean }
|
|
20
|
+
export const Inline = forwardRef<ElementRef<typeof View>, InlineProps>(function Inline({ gap = 3, align = 'center', justify = 'start', wrap = true, className, ...props }, ref) {
|
|
21
|
+
return <View {...props} ref={ref} className={cn('flex-row', gaps[gap], alignments[align], justifications[justify], wrap ? 'flex-wrap' : 'flex-nowrap', className)} />;
|
|
22
|
+
});
|
|
23
|
+
export interface ContainerProps extends ViewProps { reading?: boolean; className?: string }
|
|
24
|
+
export const Container = forwardRef<ElementRef<typeof View>, ContainerProps>(function Container({ reading = false, className, ...props }, ref) {
|
|
25
|
+
return <View {...props} ref={ref} className={cn('mx-auto w-full px-4 sm:px-6 lg:px-8', reading ? 'max-w-[720px]' : 'max-w-[1200px]', className)} />;
|
|
26
|
+
});
|
|
27
|
+
export interface CardProps extends ViewProps { className?: string; padding?: keyof typeof gaps }
|
|
28
|
+
const paddings = { 0: 'p-0', 1: 'p-1', 2: 'p-2', 3: 'p-3', 4: 'p-4', 6: 'p-6', 8: 'p-8', 12: 'p-12', 16: 'p-16' };
|
|
29
|
+
export const Card = forwardRef<ElementRef<typeof View>, CardProps>(function Card({ padding = 6, className, ...props }, ref) {
|
|
30
|
+
return <View {...props} ref={ref} className={cn('rounded-xl border border-ui-line bg-ui-surface', paddings[padding], className)} />;
|
|
31
|
+
});
|
|
32
|
+
export interface DividerProps extends ViewProps { orientation?: 'horizontal' | 'vertical'; className?: string }
|
|
33
|
+
export function Divider({ orientation = 'horizontal', className, ...props }: DividerProps) {
|
|
34
|
+
return <View {...props} accessible={false} className={cn('bg-ui-line', orientation === 'horizontal' ? 'h-px w-full' : 'w-px self-stretch', className)} />;
|
|
35
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { View, type ViewProps } from 'react-native';
|
|
4
|
+
import { cn } from '../internal/utils';
|
|
5
|
+
import { Button } from './Button';
|
|
6
|
+
import { Link } from './actions';
|
|
7
|
+
import { Text } from './typography';
|
|
8
|
+
|
|
9
|
+
export interface BreadcrumbItem { label: string; href?: string }
|
|
10
|
+
export interface BreadcrumbsProps extends ViewProps { items: BreadcrumbItem[]; label?: string; className?: string }
|
|
11
|
+
export function Breadcrumbs({ items, label = 'Breadcrumb', className, ...props }: BreadcrumbsProps) {
|
|
12
|
+
return <View {...props} role="navigation" accessibilityLabel={label} className={cn('flex-row flex-wrap items-center gap-2', className)}>{items.map((item, index) => <View key={`${item.label}-${index}`} className="flex-row items-center gap-2">
|
|
13
|
+
{index > 0 ? <View accessible={false} className="h-2 w-2 rotate-[-45deg] border-b border-r border-ui-muted" /> : null}
|
|
14
|
+
{item.href && index !== items.length - 1 ? <Link href={item.href} textClassName="text-sm">{item.label}</Link> : <Text variant="small" {...(index === items.length - 1 ? { 'aria-current': 'page' as const } : {})}>{item.label}</Text>}
|
|
15
|
+
</View>)}</View>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PaginationProps extends ViewProps { page: number; count: number; onPageChange: (page: number) => void; label?: string; className?: string }
|
|
19
|
+
export function Pagination({ page, count, onPageChange, label = 'Pagination', className, ...props }: PaginationProps) {
|
|
20
|
+
const total = Math.max(1, Math.floor(Number.isFinite(count) ? count : 1));
|
|
21
|
+
const current = Math.min(total, Math.max(1, Math.floor(Number.isFinite(page) ? page : 1)));
|
|
22
|
+
return <View {...props} role="navigation" accessibilityLabel={label} className={cn('flex-row flex-wrap items-center gap-3', className)}>
|
|
23
|
+
<Button variant="outline" size="sm" disabled={current === 1} onPress={() => onPageChange(current - 1)} accessibilityLabel="Previous page">Previous</Button>
|
|
24
|
+
<Text variant="small" accessibilityLiveRegion="polite" aria-live="polite">{`Page ${current} of ${total}`}</Text>
|
|
25
|
+
<Button variant="outline" size="sm" disabled={current === total} onPress={() => onPageChange(current + 1)} accessibilityLabel="Next page">Next</Button>
|
|
26
|
+
</View>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useReducer, useState, type ReactNode } from 'react';
|
|
4
|
+
import { Modal, Platform, Pressable, ScrollView, View, type ViewProps } from 'react-native';
|
|
5
|
+
import { cn, focusRing, useControllableState } from '../internal/utils';
|
|
6
|
+
import { useWebScrollLock } from '../internal/scroll-lock';
|
|
7
|
+
import { useTheme } from '../theme';
|
|
8
|
+
import { Heading, Text } from './typography';
|
|
9
|
+
|
|
10
|
+
export interface DialogProps extends Omit<ViewProps, 'children'> {
|
|
11
|
+
open?: boolean;
|
|
12
|
+
defaultOpen?: boolean;
|
|
13
|
+
onOpenChange?: (open: boolean) => void;
|
|
14
|
+
title: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
closeLabel?: string;
|
|
18
|
+
dismissOnBackdropPress?: boolean;
|
|
19
|
+
className?: string;
|
|
20
|
+
contentClassName?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function Overlay({ open, defaultOpen = false, onOpenChange, title, description, children, closeLabel = 'Close', dismissOnBackdropPress = true, className, contentClassName, testID, style, sheet = false, ...props }: DialogProps & { sheet?: boolean }) {
|
|
24
|
+
const [isOpen, setOpen] = useControllableState({ value: open, defaultValue: defaultOpen, onChange: onOpenChange });
|
|
25
|
+
const { variables } = useTheme();
|
|
26
|
+
const titleID = useId();
|
|
27
|
+
const descriptionID = useId();
|
|
28
|
+
const [webMounted, setWebMounted] = useState(false);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
// RNW has no server portal; keep the initial hydration render identical.
|
|
31
|
+
if (Platform.OS === 'web') setWebMounted(true);
|
|
32
|
+
}, []);
|
|
33
|
+
const [, refreshPortal] = useReducer((count: number) => count + 1, 0);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
// RNW 0.21.4 StrictMode cleanup can clear its render-created portal ref.
|
|
36
|
+
// One ordinary rerender recreates it while preserving the Modal's identity.
|
|
37
|
+
if (Platform.OS === 'web' && isOpen && webMounted) refreshPortal();
|
|
38
|
+
}, [isOpen, webMounted]);
|
|
39
|
+
const canRender = Platform.OS !== 'web' || webMounted;
|
|
40
|
+
useWebScrollLock(isOpen && canRender);
|
|
41
|
+
// RNW captures the current trigger on mount and restores it on unmount.
|
|
42
|
+
if (!isOpen || !canRender) return null;
|
|
43
|
+
return <Modal transparent visible animationType="none" onRequestClose={() => setOpen(false)} accessibilityLabel={title} aria-labelledby={titleID} {...(description ? { 'aria-describedby': descriptionID } : {})}>
|
|
44
|
+
<View className={cn('flex-1 px-4 py-6', sheet ? 'justify-end' : 'justify-center')} style={variables}>
|
|
45
|
+
<View testID={testID ? `${testID}-backdrop` : undefined} accessible={false} onStartShouldSetResponder={() => dismissOnBackdropPress} onResponderRelease={() => { if (dismissOnBackdropPress) setOpen(false); }} className="absolute inset-0 bg-black/50" />
|
|
46
|
+
<View {...props} testID={testID} accessibilityViewIsModal className={cn('w-full self-center bg-ui-surface p-6', sheet ? 'max-h-[85%] max-w-[720px] rounded-t-xl' : 'max-h-[85%] max-w-[560px] rounded-xl', className)} style={style}>
|
|
47
|
+
<View className="mb-4 flex-row items-start justify-between gap-4">
|
|
48
|
+
<View className="min-w-0 flex-1 gap-2">
|
|
49
|
+
<Heading nativeID={titleID} level={2}>{title}</Heading>
|
|
50
|
+
{description ? <Text nativeID={descriptionID} tone="muted">{description}</Text> : null}
|
|
51
|
+
</View>
|
|
52
|
+
<Pressable accessibilityRole="button" accessibilityLabel={closeLabel} onPress={() => setOpen(false)} className={cn('min-h-[44px] min-w-[44px] items-center justify-center rounded px-2', focusRing)}><Text variant="small" className="font-semibold text-ui-link">{closeLabel}</Text></Pressable>
|
|
53
|
+
</View>
|
|
54
|
+
<ScrollView keyboardShouldPersistTaps="handled" className={cn('shrink', contentClassName)}>{children}</ScrollView>
|
|
55
|
+
</View>
|
|
56
|
+
</View>
|
|
57
|
+
</Modal>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Controlled or initially open modal dialog; supplies no hidden trigger. */
|
|
61
|
+
export function Dialog(props: DialogProps) { return <Overlay {...props} />; }
|
|
62
|
+
export type SheetProps = DialogProps;
|
|
63
|
+
/** Bottom-aligned, scrollable modal sheet with the same dismissal contract. */
|
|
64
|
+
export function Sheet(props: SheetProps) { return <Overlay {...props} sheet />; }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type ElementRef } from 'react';
|
|
4
|
+
import { Text as NativeText, Platform, type TextProps as NativeTextProps } from 'react-native';
|
|
5
|
+
import { cn } from '../internal/utils';
|
|
6
|
+
|
|
7
|
+
export type TextTone = 'default' | 'muted' | 'info' | 'success' | 'warning' | 'error';
|
|
8
|
+
export type TextVariant = 'body' | 'lead' | 'small' | 'label' | 'caption' | 'code';
|
|
9
|
+
const tones = { default: 'text-ui-text', muted: 'text-ui-muted', info: 'text-ui-info', success: 'text-ui-success', warning: 'text-ui-warning', error: 'text-ui-error' };
|
|
10
|
+
const variants = { body: 'text-base leading-6', lead: 'text-lg leading-7', small: 'text-sm leading-5', label: 'text-base leading-6 font-semibold', caption: 'text-xs leading-4', code: 'font-mono text-sm leading-6' };
|
|
11
|
+
|
|
12
|
+
export interface TextProps extends NativeTextProps {
|
|
13
|
+
tone?: TextTone;
|
|
14
|
+
variant?: TextVariant;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const Text = forwardRef<ElementRef<typeof NativeText>, TextProps>(function Text({ tone = 'default', variant = 'body', className, ...props }, ref) {
|
|
19
|
+
return <NativeText {...props} ref={ref} className={cn(variants[variant], tones[tone], className)} />;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export interface HeadingProps extends NativeTextProps {
|
|
23
|
+
level?: 1 | 2 | 3 | 4 | 5 | 6;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
const levels = { 1: 'text-3xl leading-10', 2: 'text-2xl leading-8', 3: 'text-xl leading-7', 4: 'text-lg leading-6', 5: 'text-base leading-6', 6: 'text-sm leading-5' };
|
|
27
|
+
|
|
28
|
+
export const Heading = forwardRef<ElementRef<typeof NativeText>, HeadingProps>(function Heading({ level = 2, className, ...props }, ref) {
|
|
29
|
+
const webLevel = Platform.OS === 'web' ? { 'aria-level': level } : {};
|
|
30
|
+
return <NativeText {...props} {...webLevel} ref={ref} accessibilityRole="header" className={cn('font-bold text-ui-heading', levels[level], className)} />;
|
|
31
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
export { Button } from './components/Button';
|
|
4
|
+
export type { ButtonProps, ButtonSize, ButtonVariant } from './components/Button';
|
|
5
|
+
export { Text, Heading } from './components/typography';
|
|
6
|
+
export type { TextProps, TextTone, TextVariant, HeadingProps } from './components/typography';
|
|
7
|
+
export { Stack, Inline, Container, Card, Divider } from './components/layout';
|
|
8
|
+
export type { StackProps, InlineProps, ContainerProps, CardProps, DividerProps } from './components/layout';
|
|
9
|
+
export { IconButton, Link } from './components/actions';
|
|
10
|
+
export type { IconButtonProps, LinkProps } from './components/actions';
|
|
11
|
+
export { Badge, Avatar, ListItem, EmptyState } from './components/content';
|
|
12
|
+
export type { BadgeProps, AvatarProps, ListItemProps, EmptyStateProps, StatusTone } from './components/content';
|
|
13
|
+
export { Accordion } from './components/disclosure';
|
|
14
|
+
export type { AccordionProps, AccordionItem } from './components/disclosure';
|
|
15
|
+
export { Dialog, Sheet } from './components/overlays';
|
|
16
|
+
export type { DialogProps, SheetProps } from './components/overlays';
|
|
17
|
+
export { Alert, Toast, Spinner, Progress, Skeleton } from './components/feedback';
|
|
18
|
+
export type { AlertProps, ToastProps, SpinnerProps, ProgressProps, SkeletonProps } from './components/feedback';
|
|
19
|
+
export { Breadcrumbs, Pagination } from './components/navigation';
|
|
20
|
+
export type { BreadcrumbItem, BreadcrumbsProps, PaginationProps } from './components/navigation';
|
|
21
|
+
export { ThemeProvider, useTheme, themeVariables } from './theme';
|
|
22
|
+
export type { ThemeProviderProps, Appearance, ResolvedAppearance, ThemeColor, ThemeColors } from './theme';
|
|
23
|
+
export * from './components/forms';
|
|
24
|
+
export { tokens } from './tokens';
|
|
25
|
+
export type { BubbaTokens } from './tokens';
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { Platform } from 'react-native';
|
|
3
|
+
|
|
4
|
+
// Structural browser types keep the native package independent of lib.dom.
|
|
5
|
+
interface InlineStyle {
|
|
6
|
+
getPropertyValue(name: string): string;
|
|
7
|
+
getPropertyPriority(name: string): string;
|
|
8
|
+
setProperty(name: string, value: string, priority?: string): void;
|
|
9
|
+
removeProperty(name: string): string;
|
|
10
|
+
}
|
|
11
|
+
interface StyledElement { style: InlineStyle }
|
|
12
|
+
interface ScrollDocument {
|
|
13
|
+
body: StyledElement | null;
|
|
14
|
+
documentElement: StyledElement & { clientWidth: number };
|
|
15
|
+
defaultView: {
|
|
16
|
+
scrollX: number;
|
|
17
|
+
scrollY: number;
|
|
18
|
+
getComputedStyle(element: StyledElement): { paddingRight: string };
|
|
19
|
+
scrollTo(x: number, y: number): void;
|
|
20
|
+
} | null;
|
|
21
|
+
}
|
|
22
|
+
interface ScrollLock { count: number; restore: () => void }
|
|
23
|
+
const locks = new WeakMap<ScrollDocument, ScrollLock>();
|
|
24
|
+
const overflowProperties = ['overflow', 'overflow-x', 'overflow-y'];
|
|
25
|
+
const overscrollProperties = ['overscroll-behavior', 'overscroll-behavior-x', 'overscroll-behavior-y'];
|
|
26
|
+
|
|
27
|
+
function preserve(style: InlineStyle, properties: string[]) {
|
|
28
|
+
const previous = properties.map((property) => ({
|
|
29
|
+
property,
|
|
30
|
+
value: style.getPropertyValue(property),
|
|
31
|
+
priority: style.getPropertyPriority(property),
|
|
32
|
+
}));
|
|
33
|
+
return () => {
|
|
34
|
+
for (const { property, value, priority } of previous) {
|
|
35
|
+
if (value) style.setProperty(property, value, priority);
|
|
36
|
+
else style.removeProperty(property);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function acquire(document: ScrollDocument) {
|
|
42
|
+
let lock = locks.get(document);
|
|
43
|
+
if (!lock) {
|
|
44
|
+
const body = document.body;
|
|
45
|
+
const view = document.defaultView;
|
|
46
|
+
if (!body || !view) return;
|
|
47
|
+
const root = document.documentElement;
|
|
48
|
+
const { scrollX, scrollY } = view;
|
|
49
|
+
const width = root.clientWidth;
|
|
50
|
+
const paddingRight = Number.parseFloat(view.getComputedStyle(body).paddingRight) || 0;
|
|
51
|
+
const restoreRoot = preserve(root.style, [...overflowProperties, ...overscrollProperties]);
|
|
52
|
+
const restoreBody = preserve(body.style, [
|
|
53
|
+
...overflowProperties, ...overscrollProperties, 'position', 'top', 'left', 'right', 'padding-right',
|
|
54
|
+
]);
|
|
55
|
+
const restoreScrollBehavior = preserve(root.style, ['scroll-behavior']);
|
|
56
|
+
|
|
57
|
+
root.style.setProperty('scroll-behavior', 'auto', 'important');
|
|
58
|
+
root.style.setProperty('overflow', 'hidden', 'important');
|
|
59
|
+
root.style.setProperty('overscroll-behavior', 'none', 'important');
|
|
60
|
+
body.style.setProperty('overflow', 'hidden', 'important');
|
|
61
|
+
body.style.setProperty('overscroll-behavior', 'none', 'important');
|
|
62
|
+
body.style.setProperty('position', 'fixed', 'important');
|
|
63
|
+
body.style.setProperty('top', `${-scrollY}px`, 'important');
|
|
64
|
+
body.style.setProperty('left', `${-scrollX}px`, 'important');
|
|
65
|
+
body.style.setProperty('right', `${scrollX}px`, 'important');
|
|
66
|
+
// Measure the actual change so existing stable/thin scrollbar styling is respected.
|
|
67
|
+
const compensation = Math.max(0, root.clientWidth - width);
|
|
68
|
+
if (compensation > 0) body.style.setProperty('padding-right', `${paddingRight + compensation}px`, 'important');
|
|
69
|
+
|
|
70
|
+
lock = {
|
|
71
|
+
count: 0,
|
|
72
|
+
restore: () => {
|
|
73
|
+
restoreRoot();
|
|
74
|
+
restoreBody();
|
|
75
|
+
view.scrollTo(scrollX, scrollY);
|
|
76
|
+
restoreScrollBehavior();
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
locks.set(document, lock);
|
|
80
|
+
}
|
|
81
|
+
lock.count += 1;
|
|
82
|
+
let released = false;
|
|
83
|
+
return () => {
|
|
84
|
+
if (released) return;
|
|
85
|
+
released = true;
|
|
86
|
+
lock.count -= 1;
|
|
87
|
+
if (lock.count === 0) {
|
|
88
|
+
lock.restore();
|
|
89
|
+
locks.delete(document);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One page lock survives nested dialogs; modal ScrollViews remain scrollable. */
|
|
95
|
+
export function useWebScrollLock(active: boolean) {
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!active || Platform.OS !== 'web') return;
|
|
98
|
+
const document = (globalThis as unknown as { document?: ScrollDocument }).document;
|
|
99
|
+
return document ? acquire(document) : undefined;
|
|
100
|
+
}, [active]);
|
|
101
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
import { twMerge } from 'tailwind-merge';
|
|
3
|
+
|
|
4
|
+
export function cn(...values: (string | false | null | undefined)[]) {
|
|
5
|
+
return twMerge(values.filter(Boolean).join(' '));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const focusRing = 'web:focus-visible:outline web:focus-visible:outline-2 web:focus-visible:outline-offset-2 web:focus-visible:outline-ui-focus';
|
|
9
|
+
|
|
10
|
+
/** Controlled values are never silently copied into local state. */
|
|
11
|
+
export function useControllableState<T>({ value, defaultValue, onChange }: {
|
|
12
|
+
value?: T;
|
|
13
|
+
defaultValue: T;
|
|
14
|
+
onChange?: (value: T) => void;
|
|
15
|
+
}) {
|
|
16
|
+
const [local, setLocal] = useState(defaultValue);
|
|
17
|
+
const controlled = value !== undefined;
|
|
18
|
+
const current = controlled ? value : local;
|
|
19
|
+
const currentRef = useRef(current);
|
|
20
|
+
currentRef.current = current;
|
|
21
|
+
const onChangeRef = useRef(onChange);
|
|
22
|
+
onChangeRef.current = onChange;
|
|
23
|
+
const setValue = useCallback((next: T) => {
|
|
24
|
+
if (Object.is(currentRef.current, next)) return;
|
|
25
|
+
if (!controlled) {
|
|
26
|
+
currentRef.current = next;
|
|
27
|
+
setLocal(next);
|
|
28
|
+
}
|
|
29
|
+
onChangeRef.current?.(next);
|
|
30
|
+
}, [controlled]);
|
|
31
|
+
return [current, setValue] as const;
|
|
32
|
+
}
|
package/src/theme.tsx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
|
4
|
+
import { View, useColorScheme, type ViewProps } from 'react-native';
|
|
5
|
+
import { vars } from 'nativewind';
|
|
6
|
+
import { tokens } from './tokens';
|
|
7
|
+
import { cn } from './internal/utils';
|
|
8
|
+
|
|
9
|
+
export type Appearance = 'light' | 'dark' | 'system';
|
|
10
|
+
export type ResolvedAppearance = 'light' | 'dark';
|
|
11
|
+
export type ThemeColor = Exclude<keyof typeof tokens.themes.light, `shadow-${string}`>;
|
|
12
|
+
export type ThemeColors = { [Key in ThemeColor]: string };
|
|
13
|
+
|
|
14
|
+
function rgb(hex: string) {
|
|
15
|
+
return [1, 3, 5].map((start) => parseInt(hex.slice(start, start + 2), 16)).join(' ');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function themeVariables(appearance: ResolvedAppearance) {
|
|
19
|
+
return vars(Object.fromEntries(Object.entries(tokens.themes[appearance])
|
|
20
|
+
.filter(([, value]) => value.startsWith('#'))
|
|
21
|
+
.map(([key, value]) => [`--bui-${key}`, rgb(value)])));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function themeValue(appearance: Appearance, resolvedAppearance: ResolvedAppearance) {
|
|
25
|
+
return {
|
|
26
|
+
appearance,
|
|
27
|
+
resolvedAppearance,
|
|
28
|
+
colors: Object.fromEntries(Object.entries(tokens.themes[resolvedAppearance])
|
|
29
|
+
.filter(([, value]) => value.startsWith('#'))) as ThemeColors,
|
|
30
|
+
variables: themeVariables(resolvedAppearance),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ThemeContext = createContext(themeValue('light', 'light'));
|
|
35
|
+
|
|
36
|
+
export interface ThemeProviderProps extends ViewProps {
|
|
37
|
+
children: ReactNode;
|
|
38
|
+
appearance?: Appearance;
|
|
39
|
+
className?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Scoped variables support nested themes without changing global appearance. */
|
|
43
|
+
export function ThemeProvider({ appearance = 'light', children, className, style, ...props }: ThemeProviderProps) {
|
|
44
|
+
const system = useColorScheme();
|
|
45
|
+
const resolved = appearance === 'system' ? system === 'dark' ? 'dark' : 'light' : appearance;
|
|
46
|
+
const value = useMemo(() => themeValue(appearance, resolved), [appearance, resolved]);
|
|
47
|
+
return (
|
|
48
|
+
<ThemeContext.Provider value={value}>
|
|
49
|
+
<View {...props} className={cn('bg-ui-canvas', className)} style={[value.variables, style]}>
|
|
50
|
+
{children}
|
|
51
|
+
</View>
|
|
52
|
+
</ThemeContext.Provider>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useTheme() { return useContext(ThemeContext); }
|