@newtonedev/components 0.1.7 → 0.1.8
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/dist/composites/actions/Button/Button.d.ts.map +1 -1
- package/dist/composites/form-controls/Select/Select.styles.d.ts.map +1 -1
- package/dist/composites/form-controls/TextInput/TextInput.styles.d.ts.map +1 -1
- package/dist/composites/form-controls/Toggle/Toggle.styles.d.ts.map +1 -1
- package/dist/composites/range-inputs/ColorScaleSlider/ColorScaleSlider.styles.d.ts.map +1 -1
- package/dist/composites/range-inputs/HueSlider/HueSlider.styles.d.ts.map +1 -1
- package/dist/composites/range-inputs/Slider/Slider.styles.d.ts.map +1 -1
- package/dist/fonts/GoogleFontLoader.d.ts +5 -4
- package/dist/fonts/GoogleFontLoader.d.ts.map +1 -1
- package/dist/fonts/SelfHostedFontLoader.d.ts +14 -0
- package/dist/fonts/SelfHostedFontLoader.d.ts.map +1 -0
- package/dist/fonts/buildGoogleFontsUrl.d.ts +1 -16
- package/dist/fonts/buildGoogleFontsUrl.d.ts.map +1 -1
- package/dist/fonts/measureFont.d.ts +18 -0
- package/dist/fonts/measureFont.d.ts.map +1 -0
- package/dist/fonts/reportQueue.d.ts +7 -0
- package/dist/fonts/reportQueue.d.ts.map +1 -0
- package/dist/fonts/useLocalCalibration.d.ts +19 -0
- package/dist/fonts/useLocalCalibration.d.ts.map +1 -0
- package/dist/fonts/useTypographyCalibrations.d.ts +11 -0
- package/dist/fonts/useTypographyCalibrations.d.ts.map +1 -0
- package/dist/index.cjs +628 -422
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +7 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +567 -376
- package/dist/index.js.map +1 -1
- package/dist/primitives/Icon/Icon.types.d.ts +1 -1
- package/dist/primitives/Icon/Icon.types.d.ts.map +1 -1
- package/dist/primitives/Text/Text.d.ts +33 -8
- package/dist/primitives/Text/Text.d.ts.map +1 -1
- package/dist/primitives/Text/Text.spans.d.ts +22 -0
- package/dist/primitives/Text/Text.spans.d.ts.map +1 -0
- package/dist/primitives/Text/Text.types.d.ts +75 -27
- package/dist/primitives/Text/Text.types.d.ts.map +1 -1
- package/dist/primitives/Text/index.d.ts +23 -2
- package/dist/primitives/Text/index.d.ts.map +1 -1
- package/dist/primitives/index.d.ts +1 -1
- package/dist/primitives/index.d.ts.map +1 -1
- package/dist/registry/codegen.d.ts.map +1 -1
- package/dist/registry/registry.d.ts.map +1 -1
- package/dist/registry/types.d.ts +2 -0
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/theme/NewtoneProvider.d.ts +9 -1
- package/dist/theme/NewtoneProvider.d.ts.map +1 -1
- package/dist/theme/defaults.d.ts +1 -0
- package/dist/theme/defaults.d.ts.map +1 -1
- package/dist/theme/types.d.ts +48 -32
- package/dist/theme/types.d.ts.map +1 -1
- package/dist/theme/useBreakpoint.d.ts +9 -0
- package/dist/theme/useBreakpoint.d.ts.map +1 -0
- package/dist/tokens/computeTokens.d.ts +9 -22
- package/dist/tokens/computeTokens.d.ts.map +1 -1
- package/dist/tokens/types.d.ts +40 -22
- package/dist/tokens/types.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/composites/actions/Button/Button.styles.ts +3 -3
- package/src/composites/actions/Button/Button.tsx +3 -2
- package/src/composites/form-controls/Select/Select.styles.ts +8 -8
- package/src/composites/form-controls/Select/Select.tsx +1 -1
- package/src/composites/form-controls/Select/SelectOption.tsx +3 -3
- package/src/composites/form-controls/TextInput/TextInput.styles.ts +5 -5
- package/src/composites/form-controls/Toggle/Toggle.styles.ts +3 -3
- package/src/composites/range-inputs/ColorScaleSlider/ColorScaleSlider.styles.ts +6 -6
- package/src/composites/range-inputs/HueSlider/HueSlider.styles.ts +9 -9
- package/src/composites/range-inputs/Slider/Slider.styles.ts +9 -9
- package/src/fonts/GoogleFontLoader.tsx +25 -10
- package/src/fonts/SelfHostedFontLoader.tsx +44 -0
- package/src/fonts/buildGoogleFontsUrl.ts +2 -31
- package/src/fonts/measureFont.ts +42 -0
- package/src/fonts/reportQueue.ts +54 -0
- package/src/fonts/useLocalCalibration.ts +97 -0
- package/src/fonts/useTypographyCalibrations.ts +15 -0
- package/src/index.ts +16 -7
- package/src/primitives/Frame/Frame.tsx +3 -3
- package/src/primitives/Icon/Icon.tsx +1 -1
- package/src/primitives/Icon/Icon.types.ts +1 -1
- package/src/primitives/Text/Text.spans.ts +57 -0
- package/src/primitives/Text/Text.tsx +205 -53
- package/src/primitives/Text/Text.types.ts +80 -27
- package/src/primitives/Text/index.ts +27 -3
- package/src/primitives/index.ts +3 -2
- package/src/registry/codegen.ts +1 -0
- package/src/registry/registry.ts +55 -53
- package/src/registry/types.ts +2 -0
- package/src/theme/NewtoneProvider.tsx +18 -2
- package/src/theme/defaults.ts +8 -28
- package/src/theme/types.ts +63 -33
- package/src/theme/useBreakpoint.ts +14 -0
- package/src/tokens/computeTokens.ts +23 -19
- package/src/tokens/types.ts +10 -24
- package/dist/fonts/googleFonts.d.ts +0 -20
- package/dist/fonts/googleFonts.d.ts.map +0 -1
- package/src/fonts/googleFonts.ts +0 -87
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { measureAvgCharWidth } from './measureFont';
|
|
3
|
+
|
|
4
|
+
const STORAGE_KEY = 'newtone:font-metrics:v1';
|
|
5
|
+
const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
6
|
+
|
|
7
|
+
interface CacheEntry {
|
|
8
|
+
readonly ratio: number;
|
|
9
|
+
readonly measuredAt: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type FontMetricsCache = Record<string, CacheEntry>;
|
|
13
|
+
|
|
14
|
+
function readCache(): FontMetricsCache {
|
|
15
|
+
if (typeof localStorage === 'undefined') return {};
|
|
16
|
+
try {
|
|
17
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
18
|
+
return raw ? (JSON.parse(raw) as FontMetricsCache) : {};
|
|
19
|
+
} catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeCache(cache: FontMetricsCache): void {
|
|
25
|
+
if (typeof localStorage === 'undefined') return;
|
|
26
|
+
try {
|
|
27
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(cache));
|
|
28
|
+
} catch {
|
|
29
|
+
// Ignore write errors (quota exceeded, private browsing)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function cacheKey(fontFamily: string, fontWeight: number): string {
|
|
34
|
+
return `${fontFamily}:${fontWeight}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Returns the avgCharWidth ratio for a font, using a device-local measurement
|
|
39
|
+
* cached in localStorage (Layer 2 calibration).
|
|
40
|
+
*
|
|
41
|
+
* Resolution order:
|
|
42
|
+
* 1. localStorage cache (if fresh, < 7 days old)
|
|
43
|
+
* 2. `baseCalibration` (Layer 1 — editor-time canvas measurement from theme config)
|
|
44
|
+
* 3. 0.55 (fallback constant)
|
|
45
|
+
*
|
|
46
|
+
* On mount, measures the font if the cache is stale or absent, then updates
|
|
47
|
+
* localStorage and triggers a re-render with the device-accurate ratio.
|
|
48
|
+
*
|
|
49
|
+
* @param fontFamily - CSS font family name, e.g. "Inter"
|
|
50
|
+
* @param fontWeight - CSS font-weight number, e.g. 400
|
|
51
|
+
* @param fallback - CSS fallback stack, e.g. "sans-serif"
|
|
52
|
+
* @param baseCalibration - Layer 1 ratio from `themeConfig.typography.calibrations`
|
|
53
|
+
*/
|
|
54
|
+
export function useLocalCalibration(
|
|
55
|
+
fontFamily: string,
|
|
56
|
+
fontWeight: number,
|
|
57
|
+
fallback: string,
|
|
58
|
+
baseCalibration?: number,
|
|
59
|
+
): number {
|
|
60
|
+
const key = cacheKey(fontFamily, fontWeight);
|
|
61
|
+
|
|
62
|
+
// Initialise synchronously from cache so the first render uses a calibrated ratio
|
|
63
|
+
const [ratio, setRatio] = useState<number>(() => {
|
|
64
|
+
const cache = readCache();
|
|
65
|
+
const entry = cache[key];
|
|
66
|
+
if (entry && Date.now() - entry.measuredAt < TTL_MS) {
|
|
67
|
+
return entry.ratio;
|
|
68
|
+
}
|
|
69
|
+
return baseCalibration ?? 0.55;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const cache = readCache();
|
|
74
|
+
const entry = cache[key];
|
|
75
|
+
if (entry && Date.now() - entry.measuredAt < TTL_MS) {
|
|
76
|
+
// Cache is fresh — no re-measurement needed
|
|
77
|
+
if (entry.ratio !== ratio) setRatio(entry.ratio);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Cache is stale or absent — measure in the background
|
|
82
|
+
let cancelled = false;
|
|
83
|
+
measureAvgCharWidth(fontFamily, fontWeight, fallback).then((measured) => {
|
|
84
|
+
if (cancelled) return;
|
|
85
|
+
const updated = { ...readCache(), [key]: { ratio: measured, measuredAt: Date.now() } };
|
|
86
|
+
writeCache(updated);
|
|
87
|
+
setRatio(measured);
|
|
88
|
+
});
|
|
89
|
+
return () => {
|
|
90
|
+
cancelled = true;
|
|
91
|
+
};
|
|
92
|
+
// Intentionally excluding `ratio` from deps — we only want to re-measure on key/font change
|
|
93
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
94
|
+
}, [key, fontFamily, fontWeight, fallback]);
|
|
95
|
+
|
|
96
|
+
return ratio;
|
|
97
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useNewtoneTheme } from '../theme/NewtoneProvider';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns the per-font avgCharWidth calibrations from the current theme config.
|
|
5
|
+
*
|
|
6
|
+
* These are Layer 1 (editor-time) calibrations — measured with canvas at publish
|
|
7
|
+
* time and stored in `themeConfig.typography.calibrations`. Use `useLocalCalibration`
|
|
8
|
+
* to overlay device-local (Layer 2) measurements on top.
|
|
9
|
+
*
|
|
10
|
+
* @returns Record of fontFamily → avgCharWidthRatio, or an empty object if absent.
|
|
11
|
+
*/
|
|
12
|
+
export function useTypographyCalibrations(): Record<string, number> {
|
|
13
|
+
const { config } = useNewtoneTheme();
|
|
14
|
+
return config.typography.calibrations ?? {};
|
|
15
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,11 +8,18 @@ export type {
|
|
|
8
8
|
NewtoneThemeConfig,
|
|
9
9
|
NewtoneThemeContext,
|
|
10
10
|
FontConfig,
|
|
11
|
+
FontWeights,
|
|
12
|
+
FontSlot,
|
|
11
13
|
TokenOverrides,
|
|
14
|
+
FontSizeScale,
|
|
15
|
+
LineHeightScale,
|
|
16
|
+
RoleSizeStep,
|
|
17
|
+
RoleScale,
|
|
18
|
+
RoleScales,
|
|
12
19
|
} from './theme/types';
|
|
13
20
|
export { useFrameContext } from './theme/FrameContext';
|
|
14
21
|
export type { FrameContextValue } from './theme/FrameContext';
|
|
15
|
-
export { DEFAULT_THEME_CONFIG } from './theme/defaults';
|
|
22
|
+
export { DEFAULT_THEME_CONFIG, DEFAULT_FONT_SIZES, DEFAULT_LINE_HEIGHTS, DEFAULT_ROLE_SCALES } from './theme/defaults';
|
|
16
23
|
|
|
17
24
|
// Tokens
|
|
18
25
|
export { useTokens } from './tokens/useTokens';
|
|
@@ -88,16 +95,17 @@ export type { IconProps } from './primitives/Icon/Icon.types';
|
|
|
88
95
|
export { Wrapper } from './primitives/Wrapper/Wrapper';
|
|
89
96
|
export type { WrapperProps } from './primitives/Wrapper/Wrapper.types';
|
|
90
97
|
|
|
91
|
-
export { Text } from './primitives/Text
|
|
98
|
+
export { Text } from './primitives/Text';
|
|
92
99
|
export type {
|
|
93
100
|
TextProps,
|
|
94
101
|
TextSize,
|
|
95
102
|
TextWeight,
|
|
96
103
|
TextColor,
|
|
97
|
-
|
|
98
|
-
|
|
104
|
+
TextScope,
|
|
105
|
+
TextRole,
|
|
99
106
|
TextAlign,
|
|
100
|
-
|
|
107
|
+
TextSpanProps,
|
|
108
|
+
} from './primitives/Text';
|
|
101
109
|
|
|
102
110
|
export { AppShell } from './composites/layout/AppShell/AppShell';
|
|
103
111
|
export type { AppShellProps } from './composites/layout/AppShell/AppShell.types';
|
|
@@ -128,9 +136,10 @@ export {
|
|
|
128
136
|
} from './registry';
|
|
129
137
|
|
|
130
138
|
// Fonts
|
|
131
|
-
export { GOOGLE_FONTS, SYSTEM_FONTS } from './fonts/googleFonts';
|
|
132
|
-
export type { GoogleFontEntry, SystemFontEntry } from './fonts/googleFonts';
|
|
133
139
|
export { buildGoogleFontsUrl } from './fonts/buildGoogleFontsUrl';
|
|
140
|
+
export { measureAvgCharWidth } from './fonts/measureFont';
|
|
141
|
+
export { useLocalCalibration } from './fonts/useLocalCalibration';
|
|
142
|
+
export { useTypographyCalibrations } from './fonts/useTypographyCalibrations';
|
|
134
143
|
|
|
135
144
|
// Re-export core engine types for convenience
|
|
136
145
|
export type {
|
|
@@ -246,9 +246,9 @@ export function Frame({
|
|
|
246
246
|
const textStyle = useMemo<TextStyle>(
|
|
247
247
|
() => ({
|
|
248
248
|
color: srgbToHex(tokens.textPrimary.srgb),
|
|
249
|
-
fontSize: tokens.typography.
|
|
250
|
-
fontFamily: tokens.typography.fonts.
|
|
251
|
-
lineHeight: tokens.typography.
|
|
249
|
+
fontSize: tokens.typography.fontSizes['05'],
|
|
250
|
+
fontFamily: tokens.typography.fonts.main.family,
|
|
251
|
+
lineHeight: tokens.typography.lineHeights['06'],
|
|
252
252
|
}),
|
|
253
253
|
[tokens],
|
|
254
254
|
);
|
|
@@ -39,7 +39,7 @@ export function Icon({
|
|
|
39
39
|
// instead of rebuilding the style object on every render.
|
|
40
40
|
const iconStyle = useMemo<TextStyle>(() => {
|
|
41
41
|
// Use the provided size, or fall back to the theme's default text size.
|
|
42
|
-
const fontSize = size ?? tokens.typography.
|
|
42
|
+
const fontSize = size ?? tokens.typography.fontSizes['05'];
|
|
43
43
|
|
|
44
44
|
// Round to nearest Material Symbols optical size (20, 24, 40, 48)
|
|
45
45
|
// for optimal stroke weight and detail rendering.
|
|
@@ -25,7 +25,7 @@ export interface IconProps {
|
|
|
25
25
|
*/
|
|
26
26
|
readonly name: string;
|
|
27
27
|
|
|
28
|
-
/** Font size in pixels. @default tokens.typography.
|
|
28
|
+
/** Font size in pixels. @default tokens.typography.fontSizes['05'] (16px) */
|
|
29
29
|
readonly size?: number;
|
|
30
30
|
|
|
31
31
|
/** Optical size for variable font axis. Adjusts stroke weight for readability at small sizes. @default same as size */
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import React, { useContext, useMemo } from 'react';
|
|
2
|
+
import { Text as RNText } from 'react-native';
|
|
3
|
+
import type { TextStyle } from 'react-native';
|
|
4
|
+
import { useTokens } from '../../tokens/useTokens';
|
|
5
|
+
import { TextScopeContext, resolveTextColor } from './Text';
|
|
6
|
+
import type { TextSpanProps, TextWeight } from './Text.types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generic inline span — constrained sub-component for inline text formatting.
|
|
10
|
+
* Inherits font family, size, and line height from the parent Text.
|
|
11
|
+
* Only inline formatting properties (color, weight, italic, underline, highlight) are exposed.
|
|
12
|
+
*/
|
|
13
|
+
export function TextSpan({ children, color, weight, italic, underline, highlight, style }: TextSpanProps) {
|
|
14
|
+
const tokens = useTokens(1);
|
|
15
|
+
const scopeCtx = useContext(TextScopeContext);
|
|
16
|
+
|
|
17
|
+
const spanStyle = useMemo<TextStyle>(() => {
|
|
18
|
+
const s: TextStyle = {};
|
|
19
|
+
if (color) s.color = resolveTextColor(color, tokens);
|
|
20
|
+
if (weight && scopeCtx) s.fontWeight = String(scopeCtx.weights[weight]) as TextStyle['fontWeight'];
|
|
21
|
+
if (italic) s.fontStyle = 'italic';
|
|
22
|
+
if (underline) s.textDecorationLine = 'underline';
|
|
23
|
+
if (highlight) s.backgroundColor = resolveTextColor(highlight, tokens);
|
|
24
|
+
return s;
|
|
25
|
+
}, [tokens, scopeCtx, color, weight, italic, underline, highlight]);
|
|
26
|
+
|
|
27
|
+
return React.createElement(
|
|
28
|
+
RNText,
|
|
29
|
+
{ style: style ? [spanStyle, ...(Array.isArray(style) ? style : [style])] : spanStyle },
|
|
30
|
+
children,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Bold span — applies bold weight inline. */
|
|
35
|
+
export function TextBold(props: Omit<TextSpanProps, 'weight'>) {
|
|
36
|
+
return React.createElement(TextSpan, { ...props, weight: 'bold' as TextWeight });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Medium-weight span — applies medium weight inline. */
|
|
40
|
+
export function TextMedium(props: Omit<TextSpanProps, 'weight'>) {
|
|
41
|
+
return React.createElement(TextSpan, { ...props, weight: 'medium' as TextWeight });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Italic span — applies italic style inline. */
|
|
45
|
+
export function TextItalic(props: Omit<TextSpanProps, 'italic'>) {
|
|
46
|
+
return React.createElement(TextSpan, { ...props, italic: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Underline span — applies underline decoration inline. */
|
|
50
|
+
export function TextUnderline(props: Omit<TextSpanProps, 'underline'>) {
|
|
51
|
+
return React.createElement(TextSpan, { ...props, underline: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Highlight span — applies background highlight using a semantic color token. */
|
|
55
|
+
export function TextHighlight(props: Omit<TextSpanProps, 'highlight'> & { readonly highlight: TextSpanProps['highlight'] }) {
|
|
56
|
+
return React.createElement(TextSpan, props as TextSpanProps);
|
|
57
|
+
}
|
|
@@ -1,16 +1,32 @@
|
|
|
1
|
-
import React, { useMemo } from 'react';
|
|
2
|
-
import { Text as RNText } from 'react-native';
|
|
3
|
-
import type { TextStyle } from 'react-native';
|
|
1
|
+
import React, { createContext, useMemo, useState, useEffect } from 'react';
|
|
2
|
+
import { Text as RNText, View } from 'react-native';
|
|
3
|
+
import type { LayoutChangeEvent, TextStyle } from 'react-native';
|
|
4
4
|
import { srgbToHex } from 'newtone';
|
|
5
|
+
import { resolveResponsiveSize, estimateLineWidths, BREAKPOINT_ROLE_SCALE, scaleRoleStep, REFERENCE_LINE_HEIGHT_RATIO, buildFontFeatureSettings, SEMANTIC_WEIGHT_MAP, ROLE_DEFAULT_WEIGHTS } from '@newtonedev/fonts';
|
|
5
6
|
import { useTokens } from '../../tokens/useTokens';
|
|
7
|
+
import { useNewtoneTheme } from '../../theme/NewtoneProvider';
|
|
6
8
|
import type { UseTokensResult } from '../../tokens/useTokens';
|
|
7
9
|
import type { TextProps, TextColor } from './Text.types';
|
|
10
|
+
import { useLocalCalibration } from '../../fonts/useLocalCalibration';
|
|
11
|
+
import { useTypographyCalibrations } from '../../fonts/useTypographyCalibrations';
|
|
12
|
+
import { enqueueObservation } from '../../fonts/reportQueue';
|
|
13
|
+
import { useBreakpoint } from '../../theme/useBreakpoint';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Context to pass the active scope's weight map to span sub-components.
|
|
17
|
+
* Spans inherit font family from the parent RN Text, but need the scope's
|
|
18
|
+
* weight mapping to resolve semantic weight tokens (regular/medium/bold)
|
|
19
|
+
* to numeric CSS values.
|
|
20
|
+
*/
|
|
21
|
+
export const TextScopeContext = createContext<{
|
|
22
|
+
readonly weights: { readonly regular: number; readonly medium: number; readonly bold: number };
|
|
23
|
+
} | null>(null);
|
|
8
24
|
|
|
9
25
|
/**
|
|
10
26
|
* Resolve a semantic text color to a hex string from the current tokens.
|
|
11
27
|
* Neutral text colors are top-level; palette colors use nested PaletteTokens.fill.
|
|
12
28
|
*/
|
|
13
|
-
function resolveTextColor(color: TextColor, tokens: UseTokensResult): string {
|
|
29
|
+
export function resolveTextColor(color: TextColor, tokens: UseTokensResult): string {
|
|
14
30
|
switch (color) {
|
|
15
31
|
case 'primary': return srgbToHex(tokens.textPrimary.srgb);
|
|
16
32
|
case 'secondary': return srgbToHex(tokens.textSecondary.srgb);
|
|
@@ -23,78 +39,214 @@ function resolveTextColor(color: TextColor, tokens: UseTokensResult): string {
|
|
|
23
39
|
}
|
|
24
40
|
}
|
|
25
41
|
|
|
42
|
+
/** Adaptive roles that support responsive font sizing. */
|
|
43
|
+
const ADAPTIVE_ROLES = new Set(['headline', 'title', 'heading', 'subheading']);
|
|
44
|
+
|
|
45
|
+
/** Maps heading-like roles to HTML heading levels for semantic rendering on web. */
|
|
46
|
+
const ROLE_HEADING_LEVEL: Readonly<Record<string, number>> = {
|
|
47
|
+
headline: 1,
|
|
48
|
+
title: 2,
|
|
49
|
+
heading: 3,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Estimate total character count from potentially nested React children. */
|
|
53
|
+
function extractCharacterCount(node: React.ReactNode): number {
|
|
54
|
+
if (typeof node === 'string') return node.length;
|
|
55
|
+
if (typeof node === 'number') return String(node).length;
|
|
56
|
+
if (!node) return 0;
|
|
57
|
+
if (Array.isArray(node)) {
|
|
58
|
+
return node.reduce((sum: number, child) => sum + extractCharacterCount(child), 0);
|
|
59
|
+
}
|
|
60
|
+
if (typeof node === 'object' && 'props' in node) {
|
|
61
|
+
return extractCharacterCount((node as React.ReactElement).props?.children);
|
|
62
|
+
}
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
26
66
|
/**
|
|
27
|
-
* Token-aware text primitive.
|
|
67
|
+
* Token-aware text primitive with semantic scope + role API.
|
|
28
68
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
69
|
+
* - `scope` selects the font family (main, display, mono, currency)
|
|
70
|
+
* - `role` sets default weight; fontSize and lineHeight resolved from config's role mapping
|
|
71
|
+
* - `size` selects within the role's 3-step scale (sm, md, lg)
|
|
72
|
+
* - `responsive` enables container-width-aware font sizing for adaptive roles
|
|
31
73
|
*
|
|
32
74
|
* @example
|
|
33
75
|
* ```tsx
|
|
34
|
-
* <Text>Body text</Text>
|
|
35
|
-
* <Text
|
|
36
|
-
* <Text
|
|
76
|
+
* <Text>Body text (default)</Text>
|
|
77
|
+
* <Text role="headline" scope="display">Hero title</Text>
|
|
78
|
+
* <Text role="label">Form label</Text>
|
|
79
|
+
* <Text scope="mono" role="caption">const x = 42;</Text>
|
|
80
|
+
* <Text role="body">Regular <Text.Bold>bold</Text.Bold> text</Text>
|
|
81
|
+
* <Text role="headline" responsive>Responsive headline</Text>
|
|
37
82
|
* ```
|
|
38
83
|
*/
|
|
39
|
-
|
|
84
|
+
function TextBase({
|
|
40
85
|
children,
|
|
41
|
-
|
|
42
|
-
|
|
86
|
+
scope = 'main',
|
|
87
|
+
role = 'body',
|
|
43
88
|
color = 'primary',
|
|
44
|
-
|
|
45
|
-
lineHeight = 'normal',
|
|
89
|
+
size: sizeOverride,
|
|
46
90
|
align,
|
|
47
91
|
numberOfLines,
|
|
48
92
|
elevation = 1,
|
|
49
93
|
style,
|
|
50
|
-
|
|
51
|
-
accessibilityRole,
|
|
52
|
-
// Testing & platform
|
|
94
|
+
accessibilityRole: accessibilityRoleOverride,
|
|
53
95
|
testID,
|
|
54
96
|
nativeID,
|
|
55
97
|
ref,
|
|
98
|
+
responsive = false,
|
|
99
|
+
centerVertically = false,
|
|
100
|
+
features,
|
|
56
101
|
}: TextProps) {
|
|
57
|
-
// Get the current theme's design tokens (colors, fonts, spacing).
|
|
58
|
-
// The elevation parameter affects which background shade the tokens reference.
|
|
59
102
|
const tokens = useTokens(elevation);
|
|
103
|
+
const { config, reportingEndpoint } = useNewtoneTheme();
|
|
104
|
+
|
|
105
|
+
const size = sizeOverride ?? 'md';
|
|
106
|
+
const fontSlot = tokens.typography.fonts[scope];
|
|
107
|
+
// roleWeights from config (user-customized), falling back to canonical defaults
|
|
108
|
+
const resolvedFontWeight = config.typography.roleWeights?.[role] ?? ROLE_DEFAULT_WEIGHTS[role];
|
|
109
|
+
|
|
110
|
+
// --- Breakpoint-aware step ---
|
|
111
|
+
const breakpoint = useBreakpoint();
|
|
112
|
+
const baseStep = config.typography.roles[role][size];
|
|
113
|
+
const bpScale = BREAKPOINT_ROLE_SCALE[breakpoint][role];
|
|
114
|
+
const step = bpScale === 1.0 ? baseStep : scaleRoleStep(baseStep, bpScale);
|
|
115
|
+
|
|
116
|
+
// --- Responsive sizing (Layer 2 calibration) ---
|
|
117
|
+
// Hooks are called unconditionally; results are used only when `responsive && isAdaptive`.
|
|
118
|
+
const calibrations = useTypographyCalibrations();
|
|
119
|
+
const fontSlotFull = config.typography.fonts[scope]; // FontSlot with config.fallback
|
|
120
|
+
const localRatio = useLocalCalibration(
|
|
121
|
+
fontSlot.family,
|
|
122
|
+
SEMANTIC_WEIGHT_MAP.regular,
|
|
123
|
+
fontSlotFull.config.fallback,
|
|
124
|
+
calibrations[fontSlot.family],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const isAdaptive = ADAPTIVE_ROLES.has(role);
|
|
128
|
+
const [containerWidth, setContainerWidth] = useState<number | null>(null);
|
|
129
|
+
const characterCount = useMemo(() => extractCharacterCount(children), [children]);
|
|
130
|
+
|
|
131
|
+
const resolvedStep = useMemo(
|
|
132
|
+
() =>
|
|
133
|
+
resolveResponsiveSize(
|
|
134
|
+
{
|
|
135
|
+
role,
|
|
136
|
+
size,
|
|
137
|
+
responsive: responsive && isAdaptive,
|
|
138
|
+
fontFamily: fontSlot.family,
|
|
139
|
+
maxFontSize: step.fontSize,
|
|
140
|
+
minFontSize: Math.max(8, Math.round(step.fontSize * 0.7)),
|
|
141
|
+
},
|
|
142
|
+
config.typography.roles,
|
|
143
|
+
containerWidth != null ? { containerWidth, characterCount } : undefined,
|
|
144
|
+
{ [fontSlot.family]: localRatio },
|
|
145
|
+
),
|
|
146
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
147
|
+
[role, size, responsive, isAdaptive, fontSlot.family, step.fontSize, config.typography.roles, containerWidth, characterCount, localRatio],
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// --- Layer 3: report observations for cross-site rolling averages ---
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (!reportingEndpoint || !responsive || !isAdaptive || containerWidth == null) return;
|
|
153
|
+
const lineWidths = estimateLineWidths(characterCount, containerWidth, resolvedStep.fontSize, localRatio);
|
|
154
|
+
const lastLine = lineWidths[lineWidths.length - 1];
|
|
155
|
+
enqueueObservation(reportingEndpoint, {
|
|
156
|
+
fontFamily: fontSlot.family,
|
|
157
|
+
fontWeight: resolvedFontWeight,
|
|
158
|
+
role,
|
|
159
|
+
size,
|
|
160
|
+
fontSize: resolvedStep.fontSize,
|
|
161
|
+
containerWidth,
|
|
162
|
+
characterCount,
|
|
163
|
+
lineCount: lineWidths.length,
|
|
164
|
+
lastLineRatio: containerWidth > 0 ? lastLine / containerWidth : 1,
|
|
165
|
+
});
|
|
166
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
167
|
+
}, [reportingEndpoint, resolvedStep.fontSize, containerWidth]);
|
|
60
168
|
|
|
61
|
-
// Build the text style from the theme tokens.
|
|
62
|
-
// Wrapped in useMemo so it only recalculates when the inputs actually change,
|
|
63
|
-
// instead of recalculating on every render (which would be wasteful).
|
|
64
169
|
const resolvedStyle = useMemo<TextStyle>(() => {
|
|
65
|
-
|
|
66
|
-
|
|
170
|
+
const activeStep = responsive && isAdaptive ? resolvedStep : step;
|
|
171
|
+
|
|
172
|
+
// Font metrics corrections (adjustLineHeight, verticalCenterOffset, features)
|
|
173
|
+
const currentMetrics = config.typography.fontMetrics?.[fontSlot.family];
|
|
174
|
+
|
|
175
|
+
// Phase 3: Correct line height for font's natural vertical extent (snap to 4px grid)
|
|
176
|
+
const correctedLineHeight = currentMetrics
|
|
177
|
+
? Math.round((activeStep.lineHeight * currentMetrics.naturalLineHeightRatio / REFERENCE_LINE_HEIGHT_RATIO) / 4) * 4
|
|
178
|
+
: activeStep.lineHeight;
|
|
179
|
+
|
|
180
|
+
// Phase 4: Visual centering correction (opt-in via centerVertically prop, snap to 0.5px)
|
|
181
|
+
const vcOffset = centerVertically && currentMetrics
|
|
182
|
+
? Math.round(currentMetrics.verticalCenterOffset * activeStep.fontSize * 2) / 2
|
|
183
|
+
: 0;
|
|
184
|
+
|
|
185
|
+
// Phase 5: OpenType feature settings
|
|
186
|
+
const activeFeatures = features
|
|
187
|
+
? (currentMetrics?.features ? features.filter(tag => currentMetrics.features.includes(tag)) : [...features])
|
|
188
|
+
: [];
|
|
189
|
+
const featureSettings = activeFeatures.length > 0 ? buildFontFeatureSettings(activeFeatures) : undefined;
|
|
190
|
+
|
|
67
191
|
return {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
// Font weight is stored as a number (e.g. 400, 600) but React Native expects a string.
|
|
72
|
-
fontWeight: String(tokens.typography.weight[weight]) as TextStyle['fontWeight'],
|
|
73
|
-
// Convert the theme color from internal sRGB format to a hex string (e.g. '#1a1a1a').
|
|
192
|
+
fontFamily: fontSlot.family,
|
|
193
|
+
fontSize: activeStep.fontSize,
|
|
194
|
+
fontWeight: String(resolvedFontWeight) as TextStyle['fontWeight'],
|
|
74
195
|
color: resolveTextColor(color, tokens),
|
|
75
|
-
|
|
76
|
-
lineHeight: fontSize * tokens.typography.lineHeight[lineHeight],
|
|
196
|
+
lineHeight: correctedLineHeight,
|
|
77
197
|
textAlign: align,
|
|
198
|
+
...(vcOffset !== 0 ? { transform: [{ translateY: vcOffset }] } : {}),
|
|
199
|
+
...(featureSettings ? { fontFeatureSettings: featureSettings } as any : {}),
|
|
78
200
|
};
|
|
79
|
-
}, [tokens,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
201
|
+
}, [tokens, fontSlot, step, resolvedStep, responsive, isAdaptive, resolvedFontWeight, color, align, config.typography.fontMetrics, centerVertically, features]);
|
|
202
|
+
|
|
203
|
+
// Auto-derive accessibility role and heading level for heading-like typography roles.
|
|
204
|
+
// react-native-web uses aria-level to render the correct <h1>/<h2>/<h3> element.
|
|
205
|
+
const inferredA11yRole = (role === 'headline' || role === 'title' || role === 'heading')
|
|
206
|
+
? 'header' as const
|
|
207
|
+
: undefined;
|
|
208
|
+
const effectiveA11yRole = accessibilityRoleOverride ?? inferredA11yRole;
|
|
209
|
+
const ariaLevel = effectiveA11yRole === 'header' ? ROLE_HEADING_LEVEL[role] : undefined;
|
|
210
|
+
|
|
211
|
+
// Scope context value for span sub-components (stable reference — weights are constant)
|
|
212
|
+
const scopeCtx = useMemo(() => ({ weights: SEMANTIC_WEIGHT_MAP }), []);
|
|
213
|
+
|
|
214
|
+
const textNode = (
|
|
215
|
+
<TextScopeContext.Provider value={scopeCtx}>
|
|
216
|
+
<RNText
|
|
217
|
+
ref={ref}
|
|
218
|
+
testID={testID}
|
|
219
|
+
nativeID={nativeID}
|
|
220
|
+
accessibilityRole={effectiveA11yRole}
|
|
221
|
+
aria-level={ariaLevel}
|
|
222
|
+
style={style
|
|
223
|
+
? [resolvedStyle, ...(Array.isArray(style) ? style : [style])]
|
|
224
|
+
: resolvedStyle
|
|
225
|
+
}
|
|
226
|
+
numberOfLines={numberOfLines}
|
|
227
|
+
>
|
|
228
|
+
{children}
|
|
229
|
+
</RNText>
|
|
230
|
+
</TextScopeContext.Provider>
|
|
99
231
|
);
|
|
232
|
+
|
|
233
|
+
// Wrap in a View for container measurement when responsive mode is active on an adaptive role.
|
|
234
|
+
// onLayout fires whenever the container's dimensions change, providing the measured width.
|
|
235
|
+
if (responsive && isAdaptive) {
|
|
236
|
+
return (
|
|
237
|
+
<View
|
|
238
|
+
onLayout={(e: LayoutChangeEvent) => {
|
|
239
|
+
const w = e.nativeEvent.layout.width;
|
|
240
|
+
if (w > 0) setContainerWidth(w);
|
|
241
|
+
}}
|
|
242
|
+
style={{ width: '100%' }}
|
|
243
|
+
>
|
|
244
|
+
{textNode}
|
|
245
|
+
</View>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return textNode;
|
|
100
250
|
}
|
|
251
|
+
|
|
252
|
+
export { TextBase };
|