@nexub/react-native 0.0.0-stage → 2.1.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 +111 -2
- package/lib/module/components/actions.js +222 -0
- package/lib/module/components/actions.js.map +1 -0
- package/lib/module/components/containers.js +292 -0
- package/lib/module/components/containers.js.map +1 -0
- package/lib/module/components/display.js +266 -0
- package/lib/module/components/display.js.map +1 -0
- package/lib/module/components/feedback.js +464 -0
- package/lib/module/components/feedback.js.map +1 -0
- package/lib/module/components/inputs.js +746 -0
- package/lib/module/components/inputs.js.map +1 -0
- package/lib/module/components/mobile.js +811 -0
- package/lib/module/components/mobile.js.map +1 -0
- package/lib/module/components/overlays.js +418 -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 +30 -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/index.js +7 -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 +77 -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/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 +62 -0
- package/lib/typescript/components/display.d.ts.map +1 -0
- package/lib/typescript/components/feedback.d.ts +93 -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 +37 -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/index.d.ts +7 -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/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 +341 -0
- package/src/components/display.tsx +314 -0
- package/src/components/feedback.tsx +522 -0
- package/src/components/inputs.tsx +851 -0
- package/src/components/mobile.tsx +879 -0
- package/src/components/overlays.tsx +485 -0
- package/src/components/shared.ts +54 -0
- package/src/hooks/useControllableState.ts +27 -0
- package/src/index.ts +175 -0
- package/src/patterns/app.tsx +715 -0
- package/src/patterns/auth.tsx +266 -0
- package/src/patterns/index.ts +57 -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 +94 -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/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,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
|
+
}
|