@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,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
CHANGED
|
@@ -2,5 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
export { Button } from './components/Button';
|
|
4
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';
|
|
5
24
|
export { tokens } from './tokens';
|
|
6
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); }
|
package/src/tokens.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Pinned foundations from Bubba's Brand Studio brand release 1.0.0. */
|
|
2
2
|
export const tokens = {
|
|
3
3
|
brandVersion: '1.0.0',
|
|
4
|
+
systemVersion: '0.3.0',
|
|
4
5
|
colors: {
|
|
5
6
|
blue: '#007CE8',
|
|
6
7
|
red: '#CC0000',
|
|
@@ -18,6 +19,96 @@ export const tokens = {
|
|
|
18
19
|
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32, 12: 48, 16: 64 },
|
|
19
20
|
radius: { control: 4, panel: 12 },
|
|
20
21
|
touchTarget: { minimum: 44, default: 48 },
|
|
22
|
+
layout: { maxWidth: 1200, readingWidth: 720, breakpoints: { compact: 640, wide: 960 } },
|
|
23
|
+
motion: { feedback: 140 },
|
|
24
|
+
themes: {
|
|
25
|
+
'light': {
|
|
26
|
+
'canvas': '#F7F8FA',
|
|
27
|
+
'surface': '#FFFFFF',
|
|
28
|
+
'raised': '#FFFFFF',
|
|
29
|
+
'hover': '#F6F7F9',
|
|
30
|
+
'selected': '#ECEEF1',
|
|
31
|
+
'text': '#131B23',
|
|
32
|
+
'heading': '#03256C',
|
|
33
|
+
'muted': '#526075',
|
|
34
|
+
'line': '#E4E6EA',
|
|
35
|
+
'border': '#637086',
|
|
36
|
+
'link': '#03256C',
|
|
37
|
+
'link-hover': '#A80000',
|
|
38
|
+
'focus': '#03256C',
|
|
39
|
+
'focus-inner': '#FFFFFF',
|
|
40
|
+
'accent': '#CC0000',
|
|
41
|
+
'action-text': '#FFFFFF',
|
|
42
|
+
'action-border': '#A80000',
|
|
43
|
+
'disabled-surface': '#F0F2F5',
|
|
44
|
+
'disabled-text': '#6B727E',
|
|
45
|
+
'info': '#03256C',
|
|
46
|
+
'info-surface': '#F1F5FC',
|
|
47
|
+
'info-border': '#03256C',
|
|
48
|
+
'success': '#23633F',
|
|
49
|
+
'success-surface': '#EAF5EF',
|
|
50
|
+
'success-border': '#23633F',
|
|
51
|
+
'warning': '#805500',
|
|
52
|
+
'warning-surface': '#FFF4D6',
|
|
53
|
+
'warning-border': '#805500',
|
|
54
|
+
'error': '#B42318',
|
|
55
|
+
'error-surface': '#FFF0EF',
|
|
56
|
+
'error-border': '#B42318',
|
|
57
|
+
'code-surface': '#171717',
|
|
58
|
+
'code-text': '#FFFFFF',
|
|
59
|
+
'code-muted': '#B4B4B4',
|
|
60
|
+
'selection': '#D9E8FB',
|
|
61
|
+
'selection-text': '#131B23',
|
|
62
|
+
'scrollbar': '#637086',
|
|
63
|
+
'scrollbar-track': '#F7F8FA',
|
|
64
|
+
'shadow-action': '0 3px 8px #CC000024',
|
|
65
|
+
'shadow-panel': '0 2px 12px #131B2308',
|
|
66
|
+
'shadow-dialog': '0 20px 100px #00133345'
|
|
67
|
+
},
|
|
68
|
+
'dark': {
|
|
69
|
+
'canvas': '#171717',
|
|
70
|
+
'surface': '#202020',
|
|
71
|
+
'raised': '#292929',
|
|
72
|
+
'hover': '#303030',
|
|
73
|
+
'selected': '#383838',
|
|
74
|
+
'text': '#FFFFFF',
|
|
75
|
+
'heading': '#FFFFFF',
|
|
76
|
+
'muted': '#B4B4B4',
|
|
77
|
+
'line': '#3D3D3D',
|
|
78
|
+
'border': '#858585',
|
|
79
|
+
'link': '#8DC7FF',
|
|
80
|
+
'link-hover': '#B8DEFF',
|
|
81
|
+
'focus': '#FFFFFF',
|
|
82
|
+
'focus-inner': '#202020',
|
|
83
|
+
'accent': '#FF9F99',
|
|
84
|
+
'action-text': '#FFFFFF',
|
|
85
|
+
'action-border': '#EF817A',
|
|
86
|
+
'disabled-surface': '#242424',
|
|
87
|
+
'disabled-text': '#9A9A9A',
|
|
88
|
+
'info': '#8DC7FF',
|
|
89
|
+
'info-surface': '#1D2D3D',
|
|
90
|
+
'info-border': '#8DC7FF',
|
|
91
|
+
'success': '#8DDBA9',
|
|
92
|
+
'success-surface': '#183323',
|
|
93
|
+
'success-border': '#8DDBA9',
|
|
94
|
+
'warning': '#FED766',
|
|
95
|
+
'warning-surface': '#3A2E12',
|
|
96
|
+
'warning-border': '#FED766',
|
|
97
|
+
'error': '#FFAEA8',
|
|
98
|
+
'error-surface': '#3D2020',
|
|
99
|
+
'error-border': '#FFAEA8',
|
|
100
|
+
'code-surface': '#171717',
|
|
101
|
+
'code-text': '#FFFFFF',
|
|
102
|
+
'code-muted': '#B4B4B4',
|
|
103
|
+
'selection': '#383838',
|
|
104
|
+
'selection-text': '#FFFFFF',
|
|
105
|
+
'scrollbar': '#858585',
|
|
106
|
+
'scrollbar-track': '#171717',
|
|
107
|
+
'shadow-action': '0 3px 8px #00000040',
|
|
108
|
+
'shadow-panel': '0 2px 12px #00000020',
|
|
109
|
+
'shadow-dialog': '0 20px 100px #00000080'
|
|
110
|
+
}
|
|
111
|
+
},
|
|
21
112
|
} as const;
|
|
22
113
|
|
|
23
114
|
export type BubbaTokens = typeof tokens;
|
package/tailwind-preset.cjs
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
const { tokens } = require('./dist/tokens.cjs');
|
|
2
|
+
const plugin = require('tailwindcss/plugin');
|
|
3
|
+
|
|
4
|
+
const colorRoles = Object.entries(tokens.themes.light).filter(([, value]) => value.startsWith('#'));
|
|
5
|
+
const rgb = (hex) => [1, 3, 5].map((start) => parseInt(hex.slice(start, start + 2), 16)).join(' ');
|
|
2
6
|
|
|
3
7
|
/** @type {import('tailwindcss').Config} */
|
|
4
8
|
module.exports = {
|
|
5
9
|
theme: {
|
|
6
10
|
extend: {
|
|
7
|
-
colors: {
|
|
11
|
+
colors: {
|
|
12
|
+
bubba: tokens.colors,
|
|
13
|
+
ui: Object.fromEntries(colorRoles.map(([name]) => [name, `rgb(var(--bui-${name}) / <alpha-value>)`])),
|
|
14
|
+
},
|
|
8
15
|
borderRadius: {
|
|
9
16
|
'bubba-control': `${tokens.radius.control}px`,
|
|
10
17
|
'bubba-panel': `${tokens.radius.panel}px`,
|
|
11
18
|
},
|
|
12
19
|
},
|
|
13
20
|
},
|
|
21
|
+
plugins: [plugin(({ addBase }) => addBase({
|
|
22
|
+
':root': Object.fromEntries(colorRoles.map(([name, value]) => [`--bui-${name}`, rgb(value)])),
|
|
23
|
+
}))],
|
|
14
24
|
};
|