@nexub/react-native 0.0.0-stage → 2.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 +113 -2
- package/lib/module/components/actions.js +222 -0
- package/lib/module/components/actions.js.map +1 -0
- package/lib/module/components/containers.js +300 -0
- package/lib/module/components/containers.js.map +1 -0
- package/lib/module/components/display.js +299 -0
- package/lib/module/components/display.js.map +1 -0
- package/lib/module/components/feedback.js +484 -0
- package/lib/module/components/feedback.js.map +1 -0
- package/lib/module/components/inputs.js +792 -0
- package/lib/module/components/inputs.js.map +1 -0
- package/lib/module/components/mobile.js +818 -0
- package/lib/module/components/mobile.js.map +1 -0
- package/lib/module/components/overlays.js +421 -0
- package/lib/module/components/overlays.js.map +1 -0
- package/lib/module/components/shared.js +57 -0
- package/lib/module/components/shared.js.map +1 -0
- package/lib/module/hooks/useControllableState.js +23 -0
- package/lib/module/hooks/useControllableState.js.map +1 -0
- package/lib/module/index.js +31 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/patterns/app.js +709 -0
- package/lib/module/patterns/app.js.map +1 -0
- package/lib/module/patterns/auth.js +224 -0
- package/lib/module/patterns/auth.js.map +1 -0
- package/lib/module/patterns/home.js +201 -0
- package/lib/module/patterns/home.js.map +1 -0
- package/lib/module/patterns/index.js +8 -0
- package/lib/module/patterns/index.js.map +1 -0
- package/lib/module/patterns/money.js +419 -0
- package/lib/module/patterns/money.js.map +1 -0
- package/lib/module/primitives/Icon.js +17 -0
- package/lib/module/primitives/Icon.js.map +1 -0
- package/lib/module/primitives/Layout.js +79 -0
- package/lib/module/primitives/Layout.js.map +1 -0
- package/lib/module/primitives/Pressable.js +79 -0
- package/lib/module/primitives/Pressable.js.map +1 -0
- package/lib/module/primitives/ScreenContainer.js +58 -0
- package/lib/module/primitives/ScreenContainer.js.map +1 -0
- package/lib/module/primitives/Surface.js +57 -0
- package/lib/module/primitives/Surface.js.map +1 -0
- package/lib/module/primitives/Text.js +70 -0
- package/lib/module/primitives/Text.js.map +1 -0
- package/lib/module/theme/ThemeProvider.js +127 -0
- package/lib/module/theme/ThemeProvider.js.map +1 -0
- package/lib/module/theme/fonts.js +56 -0
- package/lib/module/theme/fonts.js.map +1 -0
- package/lib/module/theme/haptics.js +56 -0
- package/lib/module/theme/haptics.js.map +1 -0
- package/lib/module/theme/motion.js +89 -0
- package/lib/module/theme/motion.js.map +1 -0
- package/lib/module/theme/navigation.js +99 -0
- package/lib/module/theme/navigation.js.map +1 -0
- package/lib/typescript/components/actions.d.ts +53 -0
- package/lib/typescript/components/actions.d.ts.map +1 -0
- package/lib/typescript/components/containers.d.ts +59 -0
- package/lib/typescript/components/containers.d.ts.map +1 -0
- package/lib/typescript/components/display.d.ts +68 -0
- package/lib/typescript/components/display.d.ts.map +1 -0
- package/lib/typescript/components/feedback.d.ts +96 -0
- package/lib/typescript/components/feedback.d.ts.map +1 -0
- package/lib/typescript/components/inputs.d.ts +141 -0
- package/lib/typescript/components/inputs.d.ts.map +1 -0
- package/lib/typescript/components/mobile.d.ts +173 -0
- package/lib/typescript/components/mobile.d.ts.map +1 -0
- package/lib/typescript/components/overlays.d.ts +74 -0
- package/lib/typescript/components/overlays.d.ts.map +1 -0
- package/lib/typescript/components/shared.d.ts +20 -0
- package/lib/typescript/components/shared.d.ts.map +1 -0
- package/lib/typescript/hooks/useControllableState.d.ts +6 -0
- package/lib/typescript/hooks/useControllableState.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +39 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/patterns/app.d.ts +139 -0
- package/lib/typescript/patterns/app.d.ts.map +1 -0
- package/lib/typescript/patterns/auth.d.ts +54 -0
- package/lib/typescript/patterns/auth.d.ts.map +1 -0
- package/lib/typescript/patterns/home.d.ts +50 -0
- package/lib/typescript/patterns/home.d.ts.map +1 -0
- package/lib/typescript/patterns/index.d.ts +9 -0
- package/lib/typescript/patterns/index.d.ts.map +1 -0
- package/lib/typescript/patterns/money.d.ts +106 -0
- package/lib/typescript/patterns/money.d.ts.map +1 -0
- package/lib/typescript/primitives/Icon.d.ts +12 -0
- package/lib/typescript/primitives/Icon.d.ts.map +1 -0
- package/lib/typescript/primitives/Layout.d.ts +26 -0
- package/lib/typescript/primitives/Layout.d.ts.map +1 -0
- package/lib/typescript/primitives/Pressable.d.ts +21 -0
- package/lib/typescript/primitives/Pressable.d.ts.map +1 -0
- package/lib/typescript/primitives/ScreenContainer.d.ts +25 -0
- package/lib/typescript/primitives/ScreenContainer.d.ts.map +1 -0
- package/lib/typescript/primitives/Surface.d.ts +23 -0
- package/lib/typescript/primitives/Surface.d.ts.map +1 -0
- package/lib/typescript/primitives/Text.d.ts +21 -0
- package/lib/typescript/primitives/Text.d.ts.map +1 -0
- package/lib/typescript/theme/ThemeProvider.d.ts +69 -0
- package/lib/typescript/theme/ThemeProvider.d.ts.map +1 -0
- package/lib/typescript/theme/fonts.d.ts +25 -0
- package/lib/typescript/theme/fonts.d.ts.map +1 -0
- package/lib/typescript/theme/haptics.d.ts +3 -0
- package/lib/typescript/theme/haptics.d.ts.map +1 -0
- package/lib/typescript/theme/motion.d.ts +79 -0
- package/lib/typescript/theme/motion.d.ts.map +1 -0
- package/lib/typescript/theme/navigation.d.ts +158 -0
- package/lib/typescript/theme/navigation.d.ts.map +1 -0
- package/package.json +138 -3
- package/src/components/actions.tsx +259 -0
- package/src/components/containers.tsx +352 -0
- package/src/components/display.tsx +345 -0
- package/src/components/feedback.tsx +541 -0
- package/src/components/inputs.tsx +899 -0
- package/src/components/mobile.tsx +883 -0
- package/src/components/overlays.tsx +488 -0
- package/src/components/shared.ts +54 -0
- package/src/hooks/useControllableState.ts +27 -0
- package/src/index.ts +177 -0
- package/src/patterns/app.tsx +715 -0
- package/src/patterns/auth.tsx +266 -0
- package/src/patterns/home.tsx +211 -0
- package/src/patterns/index.ts +59 -0
- package/src/patterns/money.tsx +458 -0
- package/src/primitives/Icon.tsx +15 -0
- package/src/primitives/Layout.tsx +71 -0
- package/src/primitives/Pressable.tsx +97 -0
- package/src/primitives/ScreenContainer.tsx +65 -0
- package/src/primitives/Surface.tsx +75 -0
- package/src/primitives/Text.tsx +79 -0
- package/src/theme/ThemeProvider.tsx +210 -0
- package/src/theme/fonts.ts +52 -0
- package/src/theme/haptics.ts +57 -0
- package/src/theme/motion.ts +105 -0
- package/src/theme/navigation.ts +68 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Platform, StyleSheet, View, type ViewProps } from 'react-native';
|
|
3
|
+
import type { NativeTokens } from '@nexub/tokens/native';
|
|
4
|
+
import { useTheme, type ElevationLevel, type RoleName } from '../theme/ThemeProvider';
|
|
5
|
+
|
|
6
|
+
// Optional peer: native Liquid Glass on iOS 26+. Everything else gets the solid fallback.
|
|
7
|
+
let Glass: typeof import('expo-glass-effect') | undefined;
|
|
8
|
+
try {
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- optional peer, must not fail when absent
|
|
10
|
+
Glass = require('expo-glass-effect');
|
|
11
|
+
} catch {
|
|
12
|
+
Glass = undefined;
|
|
13
|
+
}
|
|
14
|
+
const liquidGlass = Platform.OS === 'ios' && !!Glass?.isLiquidGlassAvailable();
|
|
15
|
+
|
|
16
|
+
export type SurfaceTone = Extract<RoleName, `surface${string}`>;
|
|
17
|
+
export type ShapeName = keyof NativeTokens['shape'];
|
|
18
|
+
|
|
19
|
+
export interface SurfaceProps extends ViewProps {
|
|
20
|
+
/** Tonal tier. @default 'surface' */
|
|
21
|
+
tone?: SurfaceTone;
|
|
22
|
+
/** @default 0 */
|
|
23
|
+
elevation?: ElevationLevel;
|
|
24
|
+
/** 'glass' is for the control layer only (bars, floating controls), never content. @default 'solid' */
|
|
25
|
+
material?: 'solid' | 'glass';
|
|
26
|
+
/** Corner radius: a shape token or dp. */
|
|
27
|
+
radius?: ShapeName | number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A tonal surface. `material="glass"` is native Liquid Glass on iOS 26+, and
|
|
32
|
+
* `surface-elevated` + hairline elsewhere, or whenever glass is 'off' (user
|
|
33
|
+
* setting or Reduce Transparency).
|
|
34
|
+
*/
|
|
35
|
+
export function Surface({
|
|
36
|
+
tone = 'surface',
|
|
37
|
+
elevation = 0,
|
|
38
|
+
material = 'solid',
|
|
39
|
+
radius,
|
|
40
|
+
style,
|
|
41
|
+
...rest
|
|
42
|
+
}: SurfaceProps) {
|
|
43
|
+
const theme = useTheme();
|
|
44
|
+
const borderRadius = typeof radius === 'string' ? theme.tokens.shape[radius] : radius;
|
|
45
|
+
const glass = material === 'glass' && theme.glass !== 'off';
|
|
46
|
+
|
|
47
|
+
if (glass && liquidGlass && Glass) {
|
|
48
|
+
return (
|
|
49
|
+
<Glass.GlassView
|
|
50
|
+
glassEffectStyle={theme.glass === 'clear' ? 'clear' : 'regular'}
|
|
51
|
+
tintColor={theme.glass === 'tinted' ? theme.roles['surface-elevated'] : undefined}
|
|
52
|
+
colorScheme={theme.scheme}
|
|
53
|
+
style={[{ borderRadius, boxShadow: theme.elevation(elevation) }, style]}
|
|
54
|
+
{...rest}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return (
|
|
59
|
+
<View
|
|
60
|
+
style={[
|
|
61
|
+
{
|
|
62
|
+
backgroundColor: theme.roles[material === 'glass' ? 'surface-elevated' : tone],
|
|
63
|
+
borderRadius,
|
|
64
|
+
boxShadow: theme.elevation(elevation),
|
|
65
|
+
},
|
|
66
|
+
material === 'glass' && {
|
|
67
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
68
|
+
borderColor: theme.roles['outline-variant'],
|
|
69
|
+
},
|
|
70
|
+
style,
|
|
71
|
+
]}
|
|
72
|
+
{...rest}
|
|
73
|
+
/>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Text as RNText, type TextProps as RNTextProps } from 'react-native';
|
|
3
|
+
import type { NativeTypeRole } from '@nexub/tokens/native';
|
|
4
|
+
import { fontStyle, type FontFamily } from '../theme/fonts';
|
|
5
|
+
import { useTheme, type RoleName } from '../theme/ThemeProvider';
|
|
6
|
+
|
|
7
|
+
type Ramp = NonNullable<RNTextProps['dynamicTypeRamp']>;
|
|
8
|
+
|
|
9
|
+
// iOS Dynamic Type: each role follows its Apple text style's scaling curve.
|
|
10
|
+
const RAMP: Record<NativeTypeRole, Ramp> = {
|
|
11
|
+
'display-lg': 'largeTitle',
|
|
12
|
+
'display-md': 'largeTitle',
|
|
13
|
+
'display-sm': 'largeTitle',
|
|
14
|
+
'headline-lg': 'title1',
|
|
15
|
+
'headline-md': 'title2',
|
|
16
|
+
'headline-sm': 'title3',
|
|
17
|
+
'title-lg': 'title3',
|
|
18
|
+
'title-md': 'headline',
|
|
19
|
+
'title-sm': 'subheadline',
|
|
20
|
+
'body-lg': 'body',
|
|
21
|
+
'body-md': 'body',
|
|
22
|
+
'body-sm': 'footnote',
|
|
23
|
+
'label-lg': 'subheadline',
|
|
24
|
+
'label-md': 'footnote',
|
|
25
|
+
'label-sm': 'caption1',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Big type already reads at large sizes; cap it so it doesn't push content off
|
|
29
|
+
// screen. Body, title and label roles scale without a cap (WCAG 1.4.4).
|
|
30
|
+
const cap = (role: NativeTypeRole) =>
|
|
31
|
+
role.startsWith('display') ? 1.3 : role.startsWith('headline') ? 1.6 : undefined;
|
|
32
|
+
|
|
33
|
+
export interface TextProps extends RNTextProps {
|
|
34
|
+
/** Type role. @default 'body-md' */
|
|
35
|
+
variant?: NativeTypeRole;
|
|
36
|
+
/** Colour role. @default 'on-surface' */
|
|
37
|
+
color?: RoleName;
|
|
38
|
+
/** Tabular figures, for numbers that line up. */
|
|
39
|
+
tabular?: boolean;
|
|
40
|
+
/** Overrides the role's family (e.g. 'mono' for codes). */
|
|
41
|
+
family?: FontFamily;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function Text({
|
|
45
|
+
variant = 'body-md',
|
|
46
|
+
color = 'on-surface',
|
|
47
|
+
tabular,
|
|
48
|
+
family,
|
|
49
|
+
style,
|
|
50
|
+
...rest
|
|
51
|
+
}: TextProps) {
|
|
52
|
+
const { roles, tokens, fonts } = useTheme();
|
|
53
|
+
const t = tokens.type[variant];
|
|
54
|
+
return (
|
|
55
|
+
<RNText
|
|
56
|
+
dynamicTypeRamp={RAMP[variant]}
|
|
57
|
+
maxFontSizeMultiplier={cap(variant)}
|
|
58
|
+
style={[
|
|
59
|
+
{
|
|
60
|
+
...fontStyle(family ?? t.family, t.weight, fonts),
|
|
61
|
+
fontSize: t.size,
|
|
62
|
+
lineHeight: t.lineHeight,
|
|
63
|
+
letterSpacing: t.letterSpacing,
|
|
64
|
+
color: roles[color],
|
|
65
|
+
},
|
|
66
|
+
tabular && { fontVariant: ['tabular-nums'] },
|
|
67
|
+
style,
|
|
68
|
+
]}
|
|
69
|
+
{...rest}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface HeadingProps extends TextProps {}
|
|
75
|
+
|
|
76
|
+
/** Text announced as a heading. @default variant 'headline-sm' */
|
|
77
|
+
export function Heading({ variant = 'headline-sm', ...rest }: HeadingProps) {
|
|
78
|
+
return <Text accessibilityRole="header" variant={variant} {...rest} />;
|
|
79
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
AccessibilityInfo,
|
|
4
|
+
AppState,
|
|
5
|
+
Platform,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
View,
|
|
8
|
+
useColorScheme,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import { native, type NativeRoles } from '@nexub/tokens/native';
|
|
11
|
+
import { createSeedRoles } from '@nexub/tokens/seed';
|
|
12
|
+
import {
|
|
13
|
+
defaultMessages,
|
|
14
|
+
directionOf,
|
|
15
|
+
type ContrastLevel,
|
|
16
|
+
type Direction,
|
|
17
|
+
type GlassMode,
|
|
18
|
+
type NexubMessages,
|
|
19
|
+
type Product,
|
|
20
|
+
} from '@nexub/core';
|
|
21
|
+
import { playHaptic, type HapticToken } from './haptics';
|
|
22
|
+
import type { FontSource } from './fonts';
|
|
23
|
+
|
|
24
|
+
export type Scheme = 'light' | 'dark';
|
|
25
|
+
export type Roles = { -readonly [K in keyof NativeRoles]: string };
|
|
26
|
+
export type RoleName = keyof Roles;
|
|
27
|
+
export type ElevationLevel = 0 | 1 | 2 | 3;
|
|
28
|
+
|
|
29
|
+
export interface NexubTheme {
|
|
30
|
+
scheme: Scheme;
|
|
31
|
+
contrast: ContrastLevel;
|
|
32
|
+
product: Product | undefined;
|
|
33
|
+
/** Colour roles, already resolved for scheme, contrast, product and seed. */
|
|
34
|
+
roles: Roles;
|
|
35
|
+
/** Every native token (spacing, shape, size, type, state, motion). */
|
|
36
|
+
tokens: typeof native;
|
|
37
|
+
/** `boxShadow` for an elevation level, undefined for 0. */
|
|
38
|
+
elevation: (level: ElevationLevel) => string | undefined;
|
|
39
|
+
/** Effective glass: 'off' when the user has Reduce Transparency on. */
|
|
40
|
+
glass: GlassMode;
|
|
41
|
+
reducedMotion: boolean;
|
|
42
|
+
haptics: boolean;
|
|
43
|
+
fonts: FontSource;
|
|
44
|
+
locale: string;
|
|
45
|
+
dir: Direction;
|
|
46
|
+
messages: NexubMessages;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface NexubNativeProviderProps {
|
|
50
|
+
/** @default 'system' */
|
|
51
|
+
scheme?: Scheme | 'system';
|
|
52
|
+
/** 'system' = iOS Increase Contrast / Android high-contrast text → 'high'. @default 'system' */
|
|
53
|
+
contrast?: ContrastLevel | 'system';
|
|
54
|
+
/** Product brand: remaps the tertiary role family. */
|
|
55
|
+
product?: Product;
|
|
56
|
+
/** White-label seed colour (#rgb / #rrggbb); regenerates the `seedTarget` family. */
|
|
57
|
+
seed?: string;
|
|
58
|
+
/** @default 'primary' */
|
|
59
|
+
seedTarget?: 'primary' | 'tertiary';
|
|
60
|
+
/** Glass intensity (the app persists the user's choice). @default 'balanced' */
|
|
61
|
+
glass?: GlassMode;
|
|
62
|
+
/** Haptics on/off (user setting). @default true */
|
|
63
|
+
haptics?: boolean;
|
|
64
|
+
/** 'nexub' needs `nexubFonts` loaded; 'system' uses the platform font. @default 'nexub' */
|
|
65
|
+
fonts?: FontSource;
|
|
66
|
+
/** BCP 47 locale for Intl formatting. @default 'en' */
|
|
67
|
+
locale?: string;
|
|
68
|
+
/** @default derived from `locale` */
|
|
69
|
+
dir?: Direction;
|
|
70
|
+
/** Overrides for the default English strings. */
|
|
71
|
+
messages?: Partial<NexubMessages>;
|
|
72
|
+
children?: React.ReactNode;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ThemeContext = React.createContext<NexubTheme | null>(null);
|
|
76
|
+
|
|
77
|
+
// AccessibilityInfo reads that fire no change event (contrast): re-read on foreground.
|
|
78
|
+
function useForegroundSetting(
|
|
79
|
+
read: (() => Promise<boolean>) | undefined,
|
|
80
|
+
enabled: boolean,
|
|
81
|
+
): boolean {
|
|
82
|
+
const [on, setOn] = React.useState(false);
|
|
83
|
+
React.useEffect(() => {
|
|
84
|
+
if (!enabled || !read) return;
|
|
85
|
+
const get = () => read().then(setOn, () => setOn(false));
|
|
86
|
+
get();
|
|
87
|
+
const sub = AppState.addEventListener('change', (s) => s === 'active' && get());
|
|
88
|
+
return () => sub.remove();
|
|
89
|
+
}, [enabled, read]);
|
|
90
|
+
return on;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function useSetting(
|
|
94
|
+
read: () => Promise<boolean>,
|
|
95
|
+
event: 'reduceMotionChanged' | 'reduceTransparencyChanged',
|
|
96
|
+
): boolean {
|
|
97
|
+
const [on, setOn] = React.useState(false);
|
|
98
|
+
React.useEffect(() => {
|
|
99
|
+
read().then(setOn, () => setOn(false));
|
|
100
|
+
const sub = AccessibilityInfo.addEventListener(event, setOn);
|
|
101
|
+
return () => sub.remove();
|
|
102
|
+
}, [read, event]);
|
|
103
|
+
return on;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type A11y = typeof AccessibilityInfo & {
|
|
107
|
+
isDarkerSystemColorsEnabled?: () => Promise<boolean>;
|
|
108
|
+
isHighTextContrastEnabled?: () => Promise<boolean>;
|
|
109
|
+
};
|
|
110
|
+
const a11y = AccessibilityInfo as A11y;
|
|
111
|
+
const readHighContrast =
|
|
112
|
+
Platform.OS === 'ios'
|
|
113
|
+
? a11y.isDarkerSystemColorsEnabled?.bind(a11y)
|
|
114
|
+
: a11y.isHighTextContrastEnabled?.bind(a11y);
|
|
115
|
+
const readReduceMotion = () => AccessibilityInfo.isReduceMotionEnabled();
|
|
116
|
+
const readReduceTransparency = () =>
|
|
117
|
+
Platform.OS === 'ios' ? AccessibilityInfo.isReduceTransparencyEnabled() : Promise.resolve(false);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Theme, accessibility preferences, locale and strings for every
|
|
121
|
+
* @nexub/react-native component. Wrap the app once, inside the safe-area provider.
|
|
122
|
+
*/
|
|
123
|
+
export function NexubNativeProvider({
|
|
124
|
+
scheme: schemeProp = 'system',
|
|
125
|
+
contrast: contrastProp = 'system',
|
|
126
|
+
product,
|
|
127
|
+
seed,
|
|
128
|
+
seedTarget = 'primary',
|
|
129
|
+
glass = 'balanced',
|
|
130
|
+
haptics = true,
|
|
131
|
+
fonts = 'nexub',
|
|
132
|
+
locale = 'en',
|
|
133
|
+
dir,
|
|
134
|
+
messages,
|
|
135
|
+
children,
|
|
136
|
+
}: NexubNativeProviderProps) {
|
|
137
|
+
const system = useColorScheme();
|
|
138
|
+
const scheme: Scheme =
|
|
139
|
+
schemeProp === 'system' ? (system === 'dark' ? 'dark' : 'light') : schemeProp;
|
|
140
|
+
const systemHigh = useForegroundSetting(readHighContrast, contrastProp === 'system');
|
|
141
|
+
const contrast: ContrastLevel =
|
|
142
|
+
contrastProp === 'system' ? (systemHigh ? 'high' : 'standard') : contrastProp;
|
|
143
|
+
const reducedMotion = useSetting(readReduceMotion, 'reduceMotionChanged');
|
|
144
|
+
const reduceTransparency = useSetting(readReduceTransparency, 'reduceTransparencyChanged');
|
|
145
|
+
const resolvedDir = dir ?? directionOf(locale);
|
|
146
|
+
|
|
147
|
+
const theme = React.useMemo<NexubTheme>(() => {
|
|
148
|
+
const roles: Roles = {
|
|
149
|
+
...native.color.roles[contrast][scheme],
|
|
150
|
+
...(product ? native.color.products[product][contrast][scheme] : {}),
|
|
151
|
+
...(seed ? createSeedRoles(seed, { contrast, target: seedTarget })[scheme] : {}),
|
|
152
|
+
};
|
|
153
|
+
return {
|
|
154
|
+
scheme,
|
|
155
|
+
contrast,
|
|
156
|
+
product,
|
|
157
|
+
roles,
|
|
158
|
+
tokens: native,
|
|
159
|
+
elevation: (level) => (level ? native.elevation[level][scheme] : undefined),
|
|
160
|
+
glass: reduceTransparency ? 'off' : glass,
|
|
161
|
+
reducedMotion,
|
|
162
|
+
haptics,
|
|
163
|
+
fonts,
|
|
164
|
+
locale,
|
|
165
|
+
dir: resolvedDir,
|
|
166
|
+
messages: { ...defaultMessages, ...messages },
|
|
167
|
+
};
|
|
168
|
+
}, [
|
|
169
|
+
scheme,
|
|
170
|
+
contrast,
|
|
171
|
+
product,
|
|
172
|
+
seed,
|
|
173
|
+
seedTarget,
|
|
174
|
+
glass,
|
|
175
|
+
reduceTransparency,
|
|
176
|
+
reducedMotion,
|
|
177
|
+
haptics,
|
|
178
|
+
fonts,
|
|
179
|
+
locale,
|
|
180
|
+
resolvedDir,
|
|
181
|
+
messages,
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<ThemeContext.Provider value={theme}>
|
|
186
|
+
<View style={[styles.root, { direction: resolvedDir }]}>{children}</View>
|
|
187
|
+
</ThemeContext.Provider>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const styles = StyleSheet.create({ root: { flex: 1 } });
|
|
192
|
+
|
|
193
|
+
/** The active theme. Throws outside NexubNativeProvider. */
|
|
194
|
+
export function useTheme(): NexubTheme {
|
|
195
|
+
const theme = React.useContext(ThemeContext);
|
|
196
|
+
if (!theme) throw new Error('@nexub/react-native: wrap your app in <NexubNativeProvider>.');
|
|
197
|
+
return theme;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Shorthand for `useTheme().roles`. */
|
|
201
|
+
export const useRoles = (): Roles => useTheme().roles;
|
|
202
|
+
|
|
203
|
+
/** The active strings: defaults merged with the provider's overrides. */
|
|
204
|
+
export const useNexubMessages = (): NexubMessages => useTheme().messages;
|
|
205
|
+
|
|
206
|
+
/** Plays a semantic haptic, unless the user turned haptics off. */
|
|
207
|
+
export function useHaptics(): (token: HapticToken) => void {
|
|
208
|
+
const { haptics } = useTheme();
|
|
209
|
+
return React.useCallback((token) => haptics && playHaptic(token), [haptics]);
|
|
210
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Nexub faces as static TTFs (OFL) from @expo-google-fonts, one file per weight.
|
|
2
|
+
// Load them once at startup: const [ready] = useFonts(nexubFonts) (expo-font).
|
|
3
|
+
// Per-weight subpaths don't import expo-font, so bare RN apps can load the same
|
|
4
|
+
// files with their own loader.
|
|
5
|
+
import { Inter_400Regular } from '@expo-google-fonts/inter/400Regular';
|
|
6
|
+
import { Inter_500Medium } from '@expo-google-fonts/inter/500Medium';
|
|
7
|
+
import { Inter_600SemiBold } from '@expo-google-fonts/inter/600SemiBold';
|
|
8
|
+
import { Inter_700Bold } from '@expo-google-fonts/inter/700Bold';
|
|
9
|
+
import { PlusJakartaSans_600SemiBold } from '@expo-google-fonts/plus-jakarta-sans/600SemiBold';
|
|
10
|
+
import { PlusJakartaSans_700Bold } from '@expo-google-fonts/plus-jakarta-sans/700Bold';
|
|
11
|
+
import { PlusJakartaSans_800ExtraBold } from '@expo-google-fonts/plus-jakarta-sans/800ExtraBold';
|
|
12
|
+
import { GeistMono_400Regular } from '@expo-google-fonts/geist-mono/400Regular';
|
|
13
|
+
|
|
14
|
+
export const nexubFonts = {
|
|
15
|
+
Inter_400Regular,
|
|
16
|
+
Inter_500Medium,
|
|
17
|
+
Inter_600SemiBold,
|
|
18
|
+
Inter_700Bold,
|
|
19
|
+
PlusJakartaSans_600SemiBold,
|
|
20
|
+
PlusJakartaSans_700Bold,
|
|
21
|
+
PlusJakartaSans_800ExtraBold,
|
|
22
|
+
GeistMono_400Regular,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type FontFamily = 'sans' | 'display' | 'mono';
|
|
26
|
+
export type FontSource = 'nexub' | 'system';
|
|
27
|
+
|
|
28
|
+
const SANS: Record<number, string> = {
|
|
29
|
+
400: 'Inter_400Regular',
|
|
30
|
+
500: 'Inter_500Medium',
|
|
31
|
+
600: 'Inter_600SemiBold',
|
|
32
|
+
700: 'Inter_700Bold',
|
|
33
|
+
800: 'Inter_700Bold',
|
|
34
|
+
};
|
|
35
|
+
const DISPLAY: Record<number, string> = {
|
|
36
|
+
400: 'PlusJakartaSans_600SemiBold',
|
|
37
|
+
500: 'PlusJakartaSans_600SemiBold',
|
|
38
|
+
600: 'PlusJakartaSans_600SemiBold',
|
|
39
|
+
700: 'PlusJakartaSans_700Bold',
|
|
40
|
+
800: 'PlusJakartaSans_800ExtraBold',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Style for a family + weight. With static files the weight lives in the file
|
|
45
|
+
* name, so `fontWeight` is only set for the system font (Android picks the
|
|
46
|
+
* wrong file when both are set).
|
|
47
|
+
*/
|
|
48
|
+
export function fontStyle(family: FontFamily, weight: number, source: FontSource) {
|
|
49
|
+
if (source === 'system') return { fontWeight: String(weight) as '400' };
|
|
50
|
+
if (family === 'mono') return { fontFamily: 'GeistMono_400Regular' };
|
|
51
|
+
return { fontFamily: (family === 'display' ? DISPLAY : SANS)[weight] ?? SANS[400] };
|
|
52
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
|
|
3
|
+
// Semantic haptics (MOBILE_PLAN.md §4.4). Apple's rule: never on plain taps,
|
|
4
|
+
// never the only feedback. Android uses the platform constants, not the
|
|
5
|
+
// vibrator-based impact API.
|
|
6
|
+
export type HapticToken =
|
|
7
|
+
'selection' | 'toggleOn' | 'toggleOff' | 'confirm' | 'reject' | 'warning' | 'snap';
|
|
8
|
+
|
|
9
|
+
// Optional peer: without expo-haptics every haptic is a no-op.
|
|
10
|
+
let H: typeof import('expo-haptics') | undefined;
|
|
11
|
+
try {
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- optional peer, must not fail when absent
|
|
13
|
+
H = require('expo-haptics');
|
|
14
|
+
} catch {
|
|
15
|
+
H = undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ANDROID = {
|
|
19
|
+
selection: 'Segment_Tick',
|
|
20
|
+
toggleOn: 'Toggle_On',
|
|
21
|
+
toggleOff: 'Toggle_Off',
|
|
22
|
+
confirm: 'Confirm',
|
|
23
|
+
reject: 'Reject',
|
|
24
|
+
warning: 'Reject',
|
|
25
|
+
snap: 'Gesture_End',
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
export function playHaptic(token: HapticToken): void {
|
|
29
|
+
if (!H) return;
|
|
30
|
+
const done = () => {};
|
|
31
|
+
if (Platform.OS === 'android') {
|
|
32
|
+
H.performAndroidHapticsAsync(H.AndroidHaptics[ANDROID[token]]).catch(done);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (Platform.OS !== 'ios') return;
|
|
36
|
+
switch (token) {
|
|
37
|
+
case 'selection':
|
|
38
|
+
H.selectionAsync().catch(done);
|
|
39
|
+
break;
|
|
40
|
+
case 'toggleOn':
|
|
41
|
+
case 'toggleOff':
|
|
42
|
+
H.impactAsync(H.ImpactFeedbackStyle.Light).catch(done);
|
|
43
|
+
break;
|
|
44
|
+
case 'snap':
|
|
45
|
+
H.impactAsync(H.ImpactFeedbackStyle.Medium).catch(done);
|
|
46
|
+
break;
|
|
47
|
+
case 'confirm':
|
|
48
|
+
H.notificationAsync(H.NotificationFeedbackType.Success).catch(done);
|
|
49
|
+
break;
|
|
50
|
+
case 'reject':
|
|
51
|
+
H.notificationAsync(H.NotificationFeedbackType.Error).catch(done);
|
|
52
|
+
break;
|
|
53
|
+
case 'warning':
|
|
54
|
+
H.notificationAsync(H.NotificationFeedbackType.Warning).catch(done);
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Easing,
|
|
3
|
+
FadeIn,
|
|
4
|
+
FadeInDown,
|
|
5
|
+
FadeInUp,
|
|
6
|
+
FadeOut,
|
|
7
|
+
FadeOutDown,
|
|
8
|
+
FadeOutUp,
|
|
9
|
+
LinearTransition,
|
|
10
|
+
ReduceMotion,
|
|
11
|
+
SlideOutLeft,
|
|
12
|
+
SlideOutRight,
|
|
13
|
+
ZoomIn,
|
|
14
|
+
ZoomOut,
|
|
15
|
+
} from 'react-native-reanimated';
|
|
16
|
+
import { native } from '@nexub/tokens/native';
|
|
17
|
+
|
|
18
|
+
// Layout-animation presets built from the same spring tokens as the web
|
|
19
|
+
// (`motion.spring.*` → CSS linear() there, Reanimated springs here).
|
|
20
|
+
// Enter rides `spatial` (position and size, a hair of settle); exit is quicker
|
|
21
|
+
// on `effects` and never overshoots. Every preset honours the OS Reduce Motion
|
|
22
|
+
// setting (ReduceMotion.System): the change lands without travel.
|
|
23
|
+
|
|
24
|
+
export type SpringFamily = keyof typeof native.motion.spring;
|
|
25
|
+
export type SpringSpeed = keyof (typeof native.motion.spring)['spatial'];
|
|
26
|
+
|
|
27
|
+
/** `withSpring` config for a spring token, honouring Reduce Motion. */
|
|
28
|
+
export function nxSpring(family: SpringFamily = 'spatial', speed: SpringSpeed = 'default') {
|
|
29
|
+
return { ...native.motion.spring[family][speed], reduceMotion: ReduceMotion.System };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type EnterKind = 'fade' | 'up' | 'down' | 'scale';
|
|
33
|
+
export type ExitKind = 'fade' | 'up' | 'down' | 'scale' | 'start' | 'end';
|
|
34
|
+
|
|
35
|
+
const ENTER = { fade: FadeIn, up: FadeInUp, down: FadeInDown, scale: ZoomIn } as const;
|
|
36
|
+
const EXIT = { fade: FadeOut, up: FadeOutUp, down: FadeOutDown, scale: ZoomOut } as const;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Settle time of a spring token (ms): when it is within 0.1% of rest. The
|
|
40
|
+
* same rule @nexub/tokens uses for the CSS spring durations.
|
|
41
|
+
*/
|
|
42
|
+
export function springSettleMs({ stiffness, damping, mass }: { stiffness: number; damping: number; mass: number }) {
|
|
43
|
+
const dt = 1 / 1000;
|
|
44
|
+
let x = 0;
|
|
45
|
+
let v = 0;
|
|
46
|
+
let still = 0;
|
|
47
|
+
for (let t = 0; t < 3; t += dt) {
|
|
48
|
+
const a = (stiffness * (1 - x) - damping * v) / mass;
|
|
49
|
+
v += a * dt;
|
|
50
|
+
x += v * dt;
|
|
51
|
+
still = Math.abs(1 - x) < 0.001 && Math.abs(v) < 0.01 ? still + 1 : 0;
|
|
52
|
+
if (still > 16) return Math.round((t - 0.016) * 1000);
|
|
53
|
+
}
|
|
54
|
+
return 3000;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Enter and exit run as timed animations lasting the spring token's settle
|
|
58
|
+
// time, on the system decelerate / accelerate curves: Reanimated's
|
|
59
|
+
// physics-spring entering animations (`springify()` with stiffness/damping)
|
|
60
|
+
// left entering views invisible on iOS. Layout transitions move views that are
|
|
61
|
+
// already visible, so they keep the physics spring.
|
|
62
|
+
const decelerate = Easing.bezier(0, 0, 0.2, 1);
|
|
63
|
+
const accelerate = Easing.bezier(0.4, 0, 1, 1);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `entering` animation, timed to the spatial spring.
|
|
67
|
+
* `up` rises into place (rows, toasts from below); `down` drops in (banners).
|
|
68
|
+
*
|
|
69
|
+
* ```tsx
|
|
70
|
+
* <Animated.View entering={nxEnter('up')} exiting={nxExit('down')} layout={nxLayout()} />
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export function nxEnter(kind: EnterKind = 'fade', speed: SpringSpeed = 'default') {
|
|
74
|
+
return ENTER[kind]
|
|
75
|
+
.duration(springSettleMs(native.motion.spring.spatial[speed]))
|
|
76
|
+
.easing(decelerate)
|
|
77
|
+
.reduceMotion(ReduceMotion.System);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `exiting` animation, timed to the effects spring: quicker than enter.
|
|
82
|
+
* `start` / `end` slide out toward that edge (swipe-to-delete), following RTL.
|
|
83
|
+
*/
|
|
84
|
+
export function nxExit(kind: ExitKind = 'fade', speed: SpringSpeed = 'fast', rtl = false) {
|
|
85
|
+
const builder =
|
|
86
|
+
kind === 'start' || kind === 'end'
|
|
87
|
+
? (kind === 'start') !== rtl
|
|
88
|
+
? SlideOutLeft
|
|
89
|
+
: SlideOutRight
|
|
90
|
+
: EXIT[kind];
|
|
91
|
+
return builder
|
|
92
|
+
.duration(springSettleMs(native.motion.spring.effects[speed]))
|
|
93
|
+
.easing(accelerate)
|
|
94
|
+
.reduceMotion(ReduceMotion.System);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `layout` transition: siblings glide to their new place on the spatial spring. */
|
|
98
|
+
export function nxLayout(speed: SpringSpeed = 'default') {
|
|
99
|
+
const s = native.motion.spring.spatial[speed];
|
|
100
|
+
return LinearTransition.springify()
|
|
101
|
+
.stiffness(s.stiffness)
|
|
102
|
+
.damping(s.damping)
|
|
103
|
+
.mass(s.mass)
|
|
104
|
+
.reduceMotion(ReduceMotion.System);
|
|
105
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Native chrome, Nexub colours: options for the system navigation (native
|
|
2
|
+
// stack header, tabs, React Navigation theme). We theme it; we never redraw it.
|
|
3
|
+
import { fontStyle } from './fonts';
|
|
4
|
+
import { useTheme, type NexubTheme } from './ThemeProvider';
|
|
5
|
+
|
|
6
|
+
/** Native stack `screenOptions`: large titles on a surface-coloured native header, Nexub type and tint. */
|
|
7
|
+
export function nexubStackOptions(theme: NexubTheme) {
|
|
8
|
+
const { roles, fonts } = theme;
|
|
9
|
+
return {
|
|
10
|
+
headerLargeTitle: true,
|
|
11
|
+
// Opaque: a transparent header hid the expanded large title (iOS 26); the
|
|
12
|
+
// system still draws its scroll-edge effect on an opaque native header.
|
|
13
|
+
headerTransparent: false,
|
|
14
|
+
headerShadowVisible: false,
|
|
15
|
+
headerLargeTitleShadowVisible: false,
|
|
16
|
+
headerTintColor: roles.primary,
|
|
17
|
+
headerStyle: { backgroundColor: roles.surface },
|
|
18
|
+
headerTitleStyle: { color: roles['on-surface'], ...fontStyle('sans', 600, fonts) },
|
|
19
|
+
headerLargeTitleStyle: { color: roles['on-surface'], ...fontStyle('display', 700, fonts) },
|
|
20
|
+
contentStyle: { backgroundColor: roles.surface },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** React Navigation `theme` (also Expo Router's ThemeProvider). */
|
|
25
|
+
export function nexubNavigationTheme(theme: NexubTheme) {
|
|
26
|
+
const { roles, fonts, scheme } = theme;
|
|
27
|
+
const font = (weight: 400 | 500 | 600 | 700) => {
|
|
28
|
+
const f = fontStyle('sans', weight, fonts);
|
|
29
|
+
return {
|
|
30
|
+
fontFamily: 'fontFamily' in f ? (f.fontFamily as string) : 'System',
|
|
31
|
+
fontWeight: String(weight) as '400',
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
dark: scheme === 'dark',
|
|
36
|
+
colors: {
|
|
37
|
+
primary: roles.primary,
|
|
38
|
+
background: roles.surface,
|
|
39
|
+
card: roles['surface-container'],
|
|
40
|
+
text: roles['on-surface'],
|
|
41
|
+
border: roles['outline-variant'],
|
|
42
|
+
notification: roles.error,
|
|
43
|
+
},
|
|
44
|
+
fonts: { regular: font(400), medium: font(500), bold: font(600), heavy: font(700) },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Colours for native tab bars (Expo Router NativeTabs / react-native-bottom-tabs). */
|
|
49
|
+
export function nexubTabsOptions(theme: NexubTheme) {
|
|
50
|
+
const { roles, fonts } = theme;
|
|
51
|
+
return {
|
|
52
|
+
tintColor: roles.primary,
|
|
53
|
+
inactiveTintColor: roles['on-surface-variant'],
|
|
54
|
+
backgroundColor: roles['surface-container'],
|
|
55
|
+
badgeBackgroundColor: roles.error,
|
|
56
|
+
labelStyle: { ...fontStyle('sans', 500, fonts), fontSize: 11 },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** All three for the current theme: `const nav = useNexubNavigation()`. */
|
|
61
|
+
export function useNexubNavigation() {
|
|
62
|
+
const theme = useTheme();
|
|
63
|
+
return {
|
|
64
|
+
stack: nexubStackOptions(theme),
|
|
65
|
+
theme: nexubNavigationTheme(theme),
|
|
66
|
+
tabs: nexubTabsOptions(theme),
|
|
67
|
+
};
|
|
68
|
+
}
|