@compsych/mobile-ui 1.0.10 → 1.0.13
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/package.json +1 -1
- package/src/components/ActionSheet/index.tsx +124 -57
- package/src/components/Alert/index.tsx +19 -10
- package/src/components/Avatar/index.tsx +95 -92
- package/src/components/Badge/index.tsx +39 -36
- package/src/components/BodyText/index.tsx +5 -5
- package/src/components/Breadcrumb/index.tsx +32 -30
- package/src/components/Button/Button.test.tsx +13 -7
- package/src/components/Button/index.tsx +114 -92
- package/src/components/ChatBubble/ChatBubble.test.tsx +77 -0
- package/src/components/ChatBubble/index.tsx +187 -0
- package/src/components/ChatInput/ChatInput.test.tsx +90 -0
- package/src/components/ChatInput/index.tsx +263 -0
- package/src/components/Checkbox/index.tsx +24 -21
- package/src/components/Chip/index.tsx +75 -72
- package/src/components/Divider/index.tsx +3 -3
- package/src/components/EmptyState/index.tsx +72 -55
- package/src/components/HeaderText/index.tsx +5 -5
- package/src/components/Input/index.tsx +40 -37
- package/src/components/List/index.tsx +2 -3
- package/src/components/Pagination/index.tsx +56 -43
- package/src/components/PlanCard/index.tsx +118 -50
- package/src/components/ProgressTracker/index.tsx +44 -26
- package/src/components/PromotionCard/index.tsx +217 -212
- package/src/components/RadioButton/index.tsx +27 -25
- package/src/components/ScreenContainer/ScreenContainer.test.tsx +12 -2
- package/src/components/ScreenContainer/index.tsx +22 -12
- package/src/components/SegmentedControl/index.tsx +37 -30
- package/src/components/SelectionCard/index.tsx +38 -26
- package/src/components/ServiceCard/index.tsx +162 -130
- package/src/components/Slider/index.tsx +77 -77
- package/src/components/Snackbar/index.tsx +69 -57
- package/src/components/Switch/index.tsx +13 -12
- package/src/components/Tooltip/index.tsx +3 -3
- package/src/index.ts +13 -0
- package/src/theme/ThemeContext.ts +11 -0
- package/src/theme/ThemeProvider.tsx +40 -0
- package/src/theme/__tests__/ThemeProvider.test.tsx +101 -0
- package/src/theme/__tests__/defaultTheme.test.ts +36 -0
- package/src/theme/__tests__/mergeTheme.test.ts +94 -0
- package/src/theme/defaultTheme.ts +15 -0
- package/src/theme/index.ts +8 -0
- package/src/theme/mergeTheme.ts +47 -0
- package/src/theme/types.ts +39 -0
- package/src/theme/useTheme.ts +16 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import React, { useContext } from 'react';
|
|
2
|
+
|
|
3
|
+
import { Text } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import { render } from '@testing-library/react-native';
|
|
6
|
+
|
|
7
|
+
import { sys } from '../../tokens';
|
|
8
|
+
import { ThemeContext } from '../ThemeContext';
|
|
9
|
+
import { ThemeProvider } from '../ThemeProvider';
|
|
10
|
+
|
|
11
|
+
function ThemeConsumer() {
|
|
12
|
+
const theme = useContext(ThemeContext);
|
|
13
|
+
return (
|
|
14
|
+
<Text testID="primary-color">
|
|
15
|
+
{theme.colorRoles.accent.primary.sysPrimary}
|
|
16
|
+
</Text>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('ThemeProvider', () => {
|
|
21
|
+
it('renders children', () => {
|
|
22
|
+
const { getByText } = render(
|
|
23
|
+
<ThemeProvider>
|
|
24
|
+
<Text>hello</Text>
|
|
25
|
+
</ThemeProvider>,
|
|
26
|
+
);
|
|
27
|
+
expect(getByText('hello')).toBeTruthy();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('provides the GuidanceNow default theme when no override is given', () => {
|
|
31
|
+
const { getByTestId } = render(
|
|
32
|
+
<ThemeProvider>
|
|
33
|
+
<ThemeConsumer />
|
|
34
|
+
</ThemeProvider>,
|
|
35
|
+
);
|
|
36
|
+
expect(getByTestId('primary-color').props.children).toBe(
|
|
37
|
+
sys.colorRoles.accent.primary.sysPrimary,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('provides the merged custom theme when an override is supplied', () => {
|
|
42
|
+
const { getByTestId } = render(
|
|
43
|
+
<ThemeProvider
|
|
44
|
+
theme={{
|
|
45
|
+
colorRoles: { accent: { primary: { sysPrimary: '#c00000' } } },
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
<ThemeConsumer />
|
|
49
|
+
</ThemeProvider>,
|
|
50
|
+
);
|
|
51
|
+
expect(getByTestId('primary-color').props.children).toBe('#c00000');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('keeps default values for tokens not included in the override', () => {
|
|
55
|
+
function ErrorColorConsumer() {
|
|
56
|
+
const theme = useContext(ThemeContext);
|
|
57
|
+
return (
|
|
58
|
+
<Text testID="error-color">{theme.colorRoles.error.sysError}</Text>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const { getByTestId } = render(
|
|
63
|
+
<ThemeProvider
|
|
64
|
+
theme={{
|
|
65
|
+
colorRoles: { accent: { primary: { sysPrimary: '#c00000' } } },
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
<ErrorColorConsumer />
|
|
69
|
+
</ThemeProvider>,
|
|
70
|
+
);
|
|
71
|
+
expect(getByTestId('error-color').props.children).toBe(
|
|
72
|
+
sys.colorRoles.error.sysError,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('works correctly when nested — inner provider wins', () => {
|
|
77
|
+
function Consumer() {
|
|
78
|
+
const theme = useContext(ThemeContext);
|
|
79
|
+
return (
|
|
80
|
+
<Text testID="color">{theme.colorRoles.accent.primary.sysPrimary}</Text>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const { getByTestId } = render(
|
|
85
|
+
<ThemeProvider
|
|
86
|
+
theme={{
|
|
87
|
+
colorRoles: { accent: { primary: { sysPrimary: '#aaaaaa' } } },
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<ThemeProvider
|
|
91
|
+
theme={{
|
|
92
|
+
colorRoles: { accent: { primary: { sysPrimary: '#bbbbbb' } } },
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
<Consumer />
|
|
96
|
+
</ThemeProvider>
|
|
97
|
+
</ThemeProvider>,
|
|
98
|
+
);
|
|
99
|
+
expect(getByTestId('color').props.children).toBe('#bbbbbb');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { sys } from '../../tokens';
|
|
2
|
+
import { defaultTheme } from '../defaultTheme';
|
|
3
|
+
|
|
4
|
+
describe('defaultTheme', () => {
|
|
5
|
+
it('equals the GuidanceNow default token values', () => {
|
|
6
|
+
expect(defaultTheme).toEqual(sys);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('exposes colorRoles tokens at the correct paths', () => {
|
|
10
|
+
expect(defaultTheme.colorRoles.accent.primary.sysPrimary).toBe(
|
|
11
|
+
sys.colorRoles.accent.primary.sysPrimary,
|
|
12
|
+
);
|
|
13
|
+
expect(defaultTheme.colorRoles.error.sysError).toBe(
|
|
14
|
+
sys.colorRoles.error.sysError,
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('exposes dimension tokens at the correct paths', () => {
|
|
19
|
+
expect(defaultTheme.dimensions.borderRadius.sysRadiusMd).toBe(
|
|
20
|
+
sys.dimensions.borderRadius.sysRadiusMd,
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('exposes typeScale tokens at the correct paths', () => {
|
|
25
|
+
expect(defaultTheme.typeScale.bodyMedium.sysFontSize).toBe(
|
|
26
|
+
sys.typeScale.bodyMedium.sysFontSize,
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('is typed as Theme — readable without a ThemeProvider in scope', () => {
|
|
31
|
+
// This is a compile-time check: accessing a property on the widened Theme type
|
|
32
|
+
// (not the narrow `as const` literal type) must not require a type cast.
|
|
33
|
+
const primary: string = defaultTheme.colorRoles.accent.primary.sysPrimary;
|
|
34
|
+
expect(typeof primary).toBe('string');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { sys } from '../../tokens';
|
|
2
|
+
import { mergeTheme } from '../mergeTheme';
|
|
3
|
+
|
|
4
|
+
describe('mergeTheme', () => {
|
|
5
|
+
it('returns the default GuidanceNow theme when no override is provided', () => {
|
|
6
|
+
expect(mergeTheme()).toEqual(sys);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('returns the default theme when override is undefined', () => {
|
|
10
|
+
expect(mergeTheme(undefined)).toEqual(sys);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('overrides a single top-level color token', () => {
|
|
14
|
+
const result = mergeTheme({
|
|
15
|
+
colorRoles: { accent: { primary: { sysPrimary: '#c00000' } } },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
expect(result.colorRoles.accent.primary.sysPrimary).toBe('#c00000');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('preserves all non-overridden default values', () => {
|
|
22
|
+
const result = mergeTheme({
|
|
23
|
+
colorRoles: { accent: { primary: { sysPrimary: '#c00000' } } },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(result.colorRoles.accent.primary.sysOnPrimary).toBe(
|
|
27
|
+
sys.colorRoles.accent.primary.sysOnPrimary,
|
|
28
|
+
);
|
|
29
|
+
expect(result.colorRoles.error.sysError).toBe(
|
|
30
|
+
sys.colorRoles.error.sysError,
|
|
31
|
+
);
|
|
32
|
+
expect(result.dimensions.borderRadius.sysRadiusMd).toBe(
|
|
33
|
+
sys.dimensions.borderRadius.sysRadiusMd,
|
|
34
|
+
);
|
|
35
|
+
expect(result.typeScale.bodyMedium.sysFontSize).toBe(
|
|
36
|
+
sys.typeScale.bodyMedium.sysFontSize,
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('overrides a numeric dimension token', () => {
|
|
41
|
+
const result = mergeTheme({
|
|
42
|
+
dimensions: { borderRadius: { sysRadiusMd: 99 } },
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(result.dimensions.borderRadius.sysRadiusMd).toBe(99);
|
|
46
|
+
expect(result.dimensions.borderRadius.sysRadiusSm).toBe(
|
|
47
|
+
sys.dimensions.borderRadius.sysRadiusSm,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('overrides a typeScale token', () => {
|
|
52
|
+
const result = mergeTheme({
|
|
53
|
+
typeScale: { bodyMedium: { sysFontSize: 18 } },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(result.typeScale.bodyMedium.sysFontSize).toBe(18);
|
|
57
|
+
expect(result.typeScale.bodyMedium.sysLineHeight).toBe(
|
|
58
|
+
sys.typeScale.bodyMedium.sysLineHeight,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('ignores null override values and keeps defaults', () => {
|
|
63
|
+
const result = mergeTheme({
|
|
64
|
+
colorRoles: {
|
|
65
|
+
accent: { primary: { sysPrimary: null as unknown as string } },
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(result.colorRoles.accent.primary.sysPrimary).toBe(
|
|
70
|
+
sys.colorRoles.accent.primary.sysPrimary,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('handles an empty override object without changing anything', () => {
|
|
75
|
+
const result = mergeTheme({});
|
|
76
|
+
expect(result).toEqual(sys);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('handles deeply partial overrides spanning multiple branches', () => {
|
|
80
|
+
const result = mergeTheme({
|
|
81
|
+
colorRoles: { accent: { primary: { sysPrimary: '#aabbcc' } } },
|
|
82
|
+
dimensions: { borderRadius: { sysRadiusFull: 500 } },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(result.colorRoles.accent.primary.sysPrimary).toBe('#aabbcc');
|
|
86
|
+
expect(result.dimensions.borderRadius.sysRadiusFull).toBe(500);
|
|
87
|
+
expect(result.colorRoles.error.sysError).toBe(
|
|
88
|
+
sys.colorRoles.error.sysError,
|
|
89
|
+
);
|
|
90
|
+
expect(result.dimensions.borderRadius.sysRadiusMd).toBe(
|
|
91
|
+
sys.dimensions.borderRadius.sysRadiusMd,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { sys } from '../tokens';
|
|
2
|
+
import type { Theme } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The GuidanceNow default theme — the full resolved token tree typed as `Theme`
|
|
6
|
+
* (widened, mutable types) so consumers can read individual values without a
|
|
7
|
+
* ThemeProvider in scope.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* import { defaultTheme } from '@compsych/mobile-ui';
|
|
11
|
+
*
|
|
12
|
+
* const primaryColor = defaultTheme.colorRoles.accent.primary.sysPrimary;
|
|
13
|
+
* const bodyFont = defaultTheme.typeScale.body.md.fontFamily;
|
|
14
|
+
*/
|
|
15
|
+
export const defaultTheme: Theme = sys as unknown as Theme;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { sys } from '../tokens';
|
|
2
|
+
import type { Theme, ThemeOverride } from './types';
|
|
3
|
+
|
|
4
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
5
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function deepMerge(
|
|
9
|
+
base: Record<string, unknown>,
|
|
10
|
+
override: Record<string, unknown>,
|
|
11
|
+
): Record<string, unknown> {
|
|
12
|
+
const result: Record<string, unknown> = { ...base };
|
|
13
|
+
|
|
14
|
+
for (const key of Object.keys(override)) {
|
|
15
|
+
const overrideVal = override[key];
|
|
16
|
+
const baseVal = base[key];
|
|
17
|
+
|
|
18
|
+
if (overrideVal === undefined || overrideVal === null) {
|
|
19
|
+
// Ignore nullish overrides — preserve the default
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (isPlainObject(baseVal) && isPlainObject(overrideVal)) {
|
|
24
|
+
result[key] = deepMerge(baseVal, overrideVal);
|
|
25
|
+
} else {
|
|
26
|
+
result[key] = overrideVal;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Deep-merges a partial ThemeOverride on top of the GuidanceNow defaults.
|
|
35
|
+
* Any key omitted from the override keeps its default value, so missing or
|
|
36
|
+
* invalid overrides can never break the UI.
|
|
37
|
+
*/
|
|
38
|
+
export function mergeTheme(override?: ThemeOverride): Theme {
|
|
39
|
+
if (!override) {
|
|
40
|
+
return sys as unknown as Theme;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return deepMerge(
|
|
44
|
+
sys as unknown as Record<string, unknown>,
|
|
45
|
+
override as Record<string, unknown>,
|
|
46
|
+
) as Theme;
|
|
47
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { sys } from '../tokens';
|
|
2
|
+
|
|
3
|
+
// Strip `as const` readonly modifiers and widen literal types to their
|
|
4
|
+
// primitive base (string, number, boolean) so consumers can supply any valid
|
|
5
|
+
// CSS color or numeric value in a ThemeOverride — not just the exact GuidanceNow
|
|
6
|
+
// hex literals.
|
|
7
|
+
type DeepWritable<T> = {
|
|
8
|
+
-readonly [P in keyof T]: DeepWritable<T[P]>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type WidenLiterals<T> = T extends string
|
|
12
|
+
? string
|
|
13
|
+
: T extends number
|
|
14
|
+
? number
|
|
15
|
+
: T extends boolean
|
|
16
|
+
? boolean
|
|
17
|
+
: T extends object
|
|
18
|
+
? { [K in keyof T]: WidenLiterals<T[K]> }
|
|
19
|
+
: T;
|
|
20
|
+
|
|
21
|
+
/** The full resolved theme object consumed by every component via useTheme(). */
|
|
22
|
+
export type Theme = WidenLiterals<DeepWritable<typeof sys>>;
|
|
23
|
+
|
|
24
|
+
type DeepPartial<T> = T extends object
|
|
25
|
+
? { [P in keyof T]?: DeepPartial<T[P]> }
|
|
26
|
+
: T;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A partial theme override — any subset of the token tree.
|
|
30
|
+
* Missing keys fall back to the GuidanceNow defaults.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* const brandTheme: ThemeOverride = {
|
|
34
|
+
* colorRoles: {
|
|
35
|
+
* accent: { primary: { sysPrimary: '#c00000' } },
|
|
36
|
+
* },
|
|
37
|
+
* };
|
|
38
|
+
*/
|
|
39
|
+
export type ThemeOverride = DeepPartial<Theme>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useContext } from 'react';
|
|
2
|
+
|
|
3
|
+
import { ThemeContext } from './ThemeContext';
|
|
4
|
+
import type { Theme } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns the active resolved theme. Components use this hook to read token
|
|
8
|
+
* values at render time so they automatically reflect any ThemeProvider
|
|
9
|
+
* override in the tree.
|
|
10
|
+
*
|
|
11
|
+
* Falls back to the GuidanceNow default theme when called outside a
|
|
12
|
+
* ThemeProvider.
|
|
13
|
+
*/
|
|
14
|
+
export function useTheme(): Theme {
|
|
15
|
+
return useContext(ThemeContext);
|
|
16
|
+
}
|