@startsimpli/ui-native 0.1.3 → 0.1.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui-native",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "React Native UI primitives for StartSimpli apps (View/Text/Pressable), fed by shared design tokens",
5
5
  "license": "MIT",
6
6
  "homepage": "https://startsimpli.com",
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { palette, type ColorScheme, type ColorToken } from '../tokens';
3
+ import { contrastRatio } from '../contrast';
4
+ import { makeTheme } from '../theme';
5
+ import { alertTextStyle, alertTitleStyle, type AlertVariant } from '../styles';
6
+
7
+ const pairs: [ColorToken, ColorToken, number][] = [
8
+ ['foreground', 'background', 7],
9
+ ['cardForeground', 'card', 7],
10
+ ['mutedForeground', 'background', 4.5],
11
+ ['primaryForeground', 'primary', 4.5],
12
+ ['warningForeground', 'warning', 4.5],
13
+ // Colored text drawn straight on the page (error and status text).
14
+ ['primary', 'background', 4.5],
15
+ ];
16
+
17
+ describe.each(['light', 'dark'] as ColorScheme[])('%s palette contrast (WCAG AA)', (scheme) => {
18
+ it.each(pairs)('%s on %s is at least %d:1', (fg, bg, min) => {
19
+ const ratio = contrastRatio(palette[scheme][fg], palette[scheme][bg]);
20
+ expect(ratio, `${fg} on ${bg} = ${ratio.toFixed(2)}`).toBeGreaterThanOrEqual(min);
21
+ });
22
+ });
23
+
24
+ describe.each(['light', 'dark'] as ColorScheme[])('%s Alert text', (scheme) => {
25
+ const theme = makeTheme(scheme);
26
+ it.each(['default', 'destructive', 'notice'] as AlertVariant[])('%s title and body are readable on the card', (variant) => {
27
+ for (const style of [alertTitleStyle(theme, variant), alertTextStyle(theme, variant)]) {
28
+ const ratio = contrastRatio(String(style.color), theme.colors.card);
29
+ expect(ratio, `${variant} = ${ratio.toFixed(2)}`).toBeGreaterThanOrEqual(4.5);
30
+ }
31
+ });
32
+ });
@@ -119,6 +119,13 @@ describe('TextArea + FormField', () => {
119
119
  });
120
120
 
121
121
  describe('SearchBar', () => {
122
+ it('caps text scaling on the field and glyph so the glyph never covers the placeholder', () => {
123
+ const r = render(<SearchBar testID="sb2" value="" onChangeText={() => {}} />);
124
+ const input = r.root.findAll((n) => n.props.testID === 'sb2' && n.props.placeholder)[0];
125
+ expect(input.props.maxFontSizeMultiplier).toBeLessThanOrEqual(1.3);
126
+ const glyph = r.root.findAll((n) => n.type === 'Text' && n.props.children === '🔍')[0];
127
+ expect(glyph.props.maxFontSizeMultiplier).toBeLessThanOrEqual(1.3);
128
+ });
122
129
  it('reports text, submit, and clears with an accessible button', () => {
123
130
  const log: string[] = [];
124
131
  const r = render(<SearchBar testID="sb" value="milk" onChangeText={(t) => log.push('t:' + t)} onSubmit={(t) => log.push('s:' + t)} onClear={() => log.push('clear')} />);
@@ -95,10 +95,9 @@ describe('MessageBubble', () => {
95
95
  });
96
96
 
97
97
  describe('ChatComposer', () => {
98
- it('keyboard-avoiding wrapper, send disabled until text', () => {
98
+ it('send disabled until text (keyboard lift is covered in keyboard.test.tsx)', () => {
99
99
  const sent: string[] = [];
100
100
  const r = render(<ChatComposer testID="c" onSend={(t) => sent.push(t)} placeholder="Ask" />);
101
- expect(r.root.findAllByType('KeyboardAvoidingView' as any).length).toBe(1);
102
101
  const send = hostById(r, 'c-send');
103
102
  expect(send.props.accessibilityLabel).toBe('Send message');
104
103
  expect(send.props.disabled).toBe(true);
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { act, create } from 'react-test-renderer';
3
+ import { Keyboard } from 'react-native';
4
+ import { ChatComposer } from '../components/ChatComposer';
5
+ import { ThemeProvider } from '../components/ThemeProvider';
6
+ import { lightTheme } from '../theme';
7
+ import { keyboardOverlap } from '../logic/keyboard';
8
+ import { flat } from '../../test/render';
9
+
10
+ describe('keyboardOverlap', () => {
11
+ it('is how far the view bottom sits below the keyboard top, never negative', () => {
12
+ expect(keyboardOverlap(780, 520)).toBe(260);
13
+ expect(keyboardOverlap(400, 520)).toBe(0);
14
+ });
15
+ });
16
+
17
+ describe('ChatComposer above a tab bar', () => {
18
+ it('lifts clear of the keyboard (not behind it) and drops back when it hides', () => {
19
+ // Composer bottom edge at y=700 in the window; keyboard opens with its top at y=450.
20
+ let r!: ReturnType<typeof create>;
21
+ act(() => {
22
+ r = create(<ThemeProvider theme={lightTheme}><ChatComposer testID="c" onSend={() => {}} /></ThemeProvider>,
23
+ { createNodeMock: () => ({ measureInWindow: (cb: (x: number, y: number, w: number, h: number) => void) => cb(0, 640, 390, 60) }) });
24
+ });
25
+ const paddingBottom = () => flat(r.root.findAll((n) => typeof n.type === 'string' && n.props.testID === undefined && flat(n.props.style).borderTopWidth !== undefined)[0]?.props.style).paddingBottom as number;
26
+ const before = paddingBottom();
27
+ act(() => { (Keyboard as any).emit('keyboardWillChangeFrame', { endCoordinates: { screenY: 450 } }); });
28
+ expect(paddingBottom()).toBeGreaterThanOrEqual(before + 250);
29
+ act(() => { (Keyboard as any).emit('keyboardWillHide', {}); });
30
+ expect(paddingBottom()).toBe(before);
31
+ });
32
+ });
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Header } from '../components/Header';
3
+ import { ListRow } from '../components/ListRow';
4
+ import { StatTile } from '../components/StatTile';
5
+ import { render } from '../../test/render';
6
+
7
+ const texts = (r: ReturnType<typeof render>) => r.root.findAll((n) => n.type === 'Text');
8
+
9
+ describe('large Dynamic Type', () => {
10
+ it('Header titles wrap to two lines instead of ellipsizing a word like "Connectio..."', () => {
11
+ const title = texts(render(<Header title="Connections" subtitle="3 connected accounts" />))[0];
12
+ expect(title.props.numberOfLines).toBe(2);
13
+ expect(title.props.maxFontSizeMultiplier).toBeLessThanOrEqual(1.6);
14
+ });
15
+ it('StatTile value and caption cap scaling so captions do not break mid-word', () => {
16
+ const [value, label] = texts(render(<StatTile value="3" label="Documents" />));
17
+ expect(value.props.maxFontSizeMultiplier).toBeLessThanOrEqual(1.3);
18
+ expect(label.props.maxFontSizeMultiplier).toBeLessThanOrEqual(1.2);
19
+ expect(label.props.numberOfLines).toBe(1);
20
+ });
21
+ it('ListRow keeps a very long title and subtitle to two lines each so one row cannot fill the screen', () => {
22
+ const [title, subtitle] = texts(render(<ListRow title={'x'.repeat(300)} subtitle={'y'.repeat(300)} />));
23
+ expect(title.props.numberOfLines).toBe(2);
24
+ expect(subtitle.props.numberOfLines).toBe(2);
25
+ expect(title.props.ellipsizeMode ?? 'tail').toBe('tail');
26
+ });
27
+ });
@@ -96,9 +96,10 @@ describe('progress', () => {
96
96
  });
97
97
 
98
98
  describe('alert', () => {
99
- it('default vs destructive variants color the border + title', () => {
99
+ it('destructive colors the border; titles stay foreground so they read on dark; notice uses the warning border', () => {
100
100
  expect(alertStyle(t, 'destructive').borderColor).toBe(t.colors.destructive);
101
- expect(alertTitleStyle(t, 'destructive').color).toBe(t.colors.destructive);
101
+ expect(alertTitleStyle(t, 'destructive').color).toBe(t.colors.foreground);
102
+ expect(alertStyle(t, 'notice').borderColor).toBe(t.colors.warning);
102
103
  expect(alertTitleStyle(t, 'default').color).toBe(t.colors.foreground);
103
104
  });
104
105
  });
@@ -13,6 +13,9 @@ describe('TabBar labels never wrap or clip', () => {
13
13
  expect(l.props.ellipsizeMode).toBe('tail');
14
14
  }
15
15
  });
16
+ it('label text scaling is capped so five labels fit at the largest Dynamic Type', () => {
17
+ for (const l of r.root.findAll((n) => n.type === 'Text')) expect(l.props.maxFontSizeMultiplier).toBeLessThanOrEqual(1.3);
18
+ });
16
19
  it('items can shrink below their label width so the ellipsis can happen', () => {
17
20
  for (const tab of r.root.findAll((n) => n.props.accessibilityRole === 'tab')) {
18
21
  expect(flat(tab.props.style).minWidth).toBe(0);
@@ -1,9 +1,10 @@
1
- import { useState } from 'react';
2
- import { KeyboardAvoidingView, Platform, Pressable, Text as RNText, View, type StyleProp, type ViewStyle } from 'react-native';
1
+ import { useRef, useState } from 'react';
2
+ import { Pressable, Text as RNText, View, type StyleProp, type ViewStyle } from 'react-native';
3
3
  import { Input } from './Input';
4
4
  import { useTheme } from './ThemeProvider';
5
5
  import { composerStyle, sendButtonStyle, sendGlyphStyle } from '../styles-chat';
6
6
  import { canSend } from '../logic/chat';
7
+ import { useKeyboardInset } from './useKeyboardInset';
7
8
 
8
9
  export interface ChatComposerProps {
9
10
  onSend: (text: string) => void;
@@ -13,7 +14,7 @@ export interface ChatComposerProps {
13
14
  placeholder?: string;
14
15
  /** Disables sending while an answer is in flight. */
15
16
  sending?: boolean;
16
- /** Extra offset for headers above the keyboard-avoiding view. */
17
+ /** Unused since the composer measures the keyboard itself; kept so callers still compile. */
17
18
  keyboardVerticalOffset?: number;
18
19
  /** Bottom safe-area padding (home indicator). */
19
20
  bottomInset?: number;
@@ -24,6 +25,8 @@ export interface ChatComposerProps {
24
25
  /** Keyboard-avoiding message input with a send button. Trims text on send. */
25
26
  export function ChatComposer({ onSend, value, onChangeText, placeholder = 'Message', sending = false, keyboardVerticalOffset = 0, bottomInset = 0, style, testID }: ChatComposerProps) {
26
27
  const theme = useTheme();
28
+ const outer = useRef<View>(null);
29
+ const lift = useKeyboardInset(outer);
27
30
  const [own, setOwn] = useState('');
28
31
  const controlled = value !== undefined;
29
32
  const text = controlled ? value : own;
@@ -37,8 +40,8 @@ export function ChatComposer({ onSend, value, onChangeText, placeholder = 'Messa
37
40
  };
38
41
 
39
42
  return (
40
- <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={keyboardVerticalOffset}>
41
- <View style={[composerStyle(theme), { paddingBottom: bottomInset + theme.spacing.sm }, style]}>
43
+ <View ref={outer} collapsable={false}>
44
+ <View style={[composerStyle(theme), { paddingBottom: lift + (lift > 0 ? 0 : bottomInset) + theme.spacing.sm }, style]}>
42
45
  <Input
43
46
  testID={id('input')}
44
47
  value={text}
@@ -60,6 +63,6 @@ export function ChatComposer({ onSend, value, onChangeText, placeholder = 'Messa
60
63
  <RNText style={sendGlyphStyle(theme, enabled)}>↑</RNText>
61
64
  </Pressable>
62
65
  </View>
63
- </KeyboardAvoidingView>
66
+ </View>
64
67
  );
65
68
  }
@@ -37,8 +37,8 @@ export function Header({ title, subtitle, onBack, backLabel = 'Back', trailing,
37
37
  </Pressable>
38
38
  ) : null}
39
39
  <View style={{ flex: 1 }}>
40
- <RNText accessibilityRole="header" numberOfLines={1} style={[textStyle(theme, 'heading'), { fontSize: theme.fontSizes.lg }]}>{title}</RNText>
41
- {subtitle ? <RNText numberOfLines={1} style={textStyle(theme, 'caption')}>{subtitle}</RNText> : null}
40
+ <RNText accessibilityRole="header" numberOfLines={2} maxFontSizeMultiplier={1.6} style={[textStyle(theme, 'heading'), { fontSize: theme.fontSizes.lg }]}>{title}</RNText>
41
+ {subtitle ? <RNText numberOfLines={2} maxFontSizeMultiplier={1.4} style={textStyle(theme, 'caption')}>{subtitle}</RNText> : null}
42
42
  </View>
43
43
  {trailing ? <View style={{ flexDirection: 'row', alignItems: 'center', gap: theme.spacing.sm }}>{trailing}</View> : null}
44
44
  </View>
@@ -22,8 +22,8 @@ export function ListRow({ title, subtitle, leading, trailing, onPress, style }:
22
22
  <>
23
23
  {leading ? <View>{leading}</View> : null}
24
24
  <View style={{ flex: 1 }}>
25
- <RNText style={listRowTitleStyle(theme)}>{title}</RNText>
26
- {subtitle ? <RNText style={listRowSubtitleStyle(theme)}>{subtitle}</RNText> : null}
25
+ <RNText numberOfLines={2} style={listRowTitleStyle(theme)}>{title}</RNText>
26
+ {subtitle ? <RNText numberOfLines={2} style={listRowSubtitleStyle(theme)}>{subtitle}</RNText> : null}
27
27
  </View>
28
28
  {trailing ? <View>{trailing}</View> : null}
29
29
  </>
@@ -31,10 +31,11 @@ export function SearchBar({ value, onChangeText, onSubmit, onClear, placeholder
31
31
  autoCapitalize="none"
32
32
  autoCorrect={false}
33
33
  autoFocus={autoFocus}
34
+ maxFontSizeMultiplier={1.3}
34
35
  clearButtonMode="never"
35
36
  style={{ paddingLeft: theme.spacing.md + 22, paddingRight: value ? 44 : theme.spacing.md, borderRadius: theme.radii.full }}
36
37
  />
37
- <RNText pointerEvents="none" accessibilityElementsHidden importantForAccessibility="no" style={{ position: 'absolute', left: theme.spacing.md, color: theme.colors.mutedForeground, fontSize: theme.fontSizes.sm }}>🔍</RNText>
38
+ <RNText maxFontSizeMultiplier={1.3} pointerEvents="none" accessibilityElementsHidden importantForAccessibility="no" style={{ position: 'absolute', left: theme.spacing.md, color: theme.colors.mutedForeground, fontSize: theme.fontSizes.sm }}>🔍</RNText>
38
39
  {value ? (
39
40
  <Pressable
40
41
  testID={testID ? `${testID}-clear` : undefined}
@@ -62,8 +62,8 @@ export function StatTile({
62
62
  ) : null}
63
63
  </View>
64
64
  )}
65
- <RNText style={statValueStyle(theme)}>{value}</RNText>
66
- <RNText style={statLabelStyle(theme)}>{label}</RNText>
65
+ <RNText maxFontSizeMultiplier={1.3} style={statValueStyle(theme)}>{value}</RNText>
66
+ <RNText numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.8} maxFontSizeMultiplier={1.2} style={statLabelStyle(theme)}>{label}</RNText>
67
67
  </View>
68
68
  );
69
69
  }
@@ -41,7 +41,7 @@ export function TabBar({ items, activeKey, onChange, style }: TabBarProps) {
41
41
  style={tabItemStyle(theme)}
42
42
  >
43
43
  {item.icon?.({ active, color })}
44
- <RNText numberOfLines={1} ellipsizeMode="tail" style={tabLabelStyle(theme, active)}>{item.label}</RNText>
44
+ <RNText numberOfLines={1} ellipsizeMode="tail" maxFontSizeMultiplier={1.3} style={tabLabelStyle(theme, active)}>{item.label}</RNText>
45
45
  </Pressable>
46
46
  );
47
47
  })}
@@ -0,0 +1,22 @@
1
+ import { useEffect, useState, type RefObject } from 'react';
2
+ import { Keyboard, Platform } from 'react-native';
3
+ import { keyboardOverlap } from '../logic/keyboard';
4
+
5
+ interface Measurable { measureInWindow(cb: (x: number, y: number, w: number, h: number) => void): void }
6
+
7
+ /**
8
+ * Bottom padding that keeps the measured view above the keyboard. Measures the view against the keyboard's real
9
+ * frame, so it still works when the view sits above a tab bar (KeyboardAvoidingView left the Ask composer under the keys there).
10
+ * The view's own bottom edge does not move when this padding is applied, so re-measuring is stable.
11
+ */
12
+ export function useKeyboardInset(ref: RefObject<Measurable | null>): number {
13
+ const [inset, setInset] = useState(0);
14
+ useEffect(() => {
15
+ const change = Keyboard.addListener(Platform.OS === 'ios' ? 'keyboardWillChangeFrame' : 'keyboardDidShow', (e) => {
16
+ ref.current?.measureInWindow((_x, y, _w, h) => setInset(keyboardOverlap(y + h, e.endCoordinates.screenY)));
17
+ });
18
+ const hide = Keyboard.addListener(Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', () => setInset(0));
19
+ return () => { change.remove(); hide.remove(); };
20
+ }, [ref]);
21
+ return inset;
22
+ }
@@ -0,0 +1,20 @@
1
+ /** WCAG contrast between two HSL colors, as channel strings ("H S% L%") or the theme's hsl(h, s%, l%) form. */
2
+ function toRgb(channels: string): [number, number, number] {
3
+ const [h, s, l] = (channels.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number);
4
+ const sat = s / 100;
5
+ const light = l / 100;
6
+ const k = (n: number) => (n + h / 30) % 12;
7
+ const a = sat * Math.min(light, 1 - light);
8
+ const f = (n: number) => light - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
9
+ return [f(0), f(8), f(4)];
10
+ }
11
+
12
+ function luminance(channels: string): number {
13
+ const [r, g, b] = toRgb(channels).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
14
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
15
+ }
16
+
17
+ export function contrastRatio(a: string, b: string): number {
18
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
19
+ return (hi + 0.05) / (lo + 0.05);
20
+ }
@@ -0,0 +1,4 @@
1
+ /** How far a view whose bottom edge is at `bottomY` (window coordinates) must lift to clear a keyboard whose top is at `keyboardTop`. */
2
+ export function keyboardOverlap(bottomY: number, keyboardTop: number): number {
3
+ return Math.max(0, Math.round(bottomY - keyboardTop));
4
+ }
package/src/styles.ts CHANGED
@@ -185,7 +185,8 @@ export function progressFillStyle(theme: Theme, value: number): ViewStyle {
185
185
 
186
186
  /* ------------------------------------------------------------------ Alert -- */
187
187
 
188
- export type AlertVariant = 'default' | 'destructive';
188
+ /** `notice` is a heads-up (warning border); `destructive` is an error. Both keep body text at the foreground colors, which read on the card in light and dark (the destructive token itself does not on dark). */
189
+ export type AlertVariant = 'default' | 'destructive' | 'notice';
189
190
 
190
191
  export function alertStyle(theme: Theme, variant: AlertVariant): ViewStyle {
191
192
  return {
@@ -193,7 +194,7 @@ export function alertStyle(theme: Theme, variant: AlertVariant): ViewStyle {
193
194
  borderRadius: theme.radii.lg,
194
195
  borderWidth: 1,
195
196
  backgroundColor: theme.colors.card,
196
- borderColor: variant === 'destructive' ? theme.colors.destructive : theme.colors.border,
197
+ borderColor: variant === 'destructive' ? theme.colors.destructive : variant === 'notice' ? theme.colors.warning : theme.colors.border,
197
198
  };
198
199
  }
199
200
 
@@ -201,14 +202,14 @@ export function alertTitleStyle(theme: Theme, variant: AlertVariant): TextStyle
201
202
  return {
202
203
  fontSize: theme.fontSizes.base,
203
204
  fontWeight: theme.fontWeights.semibold as TextStyle['fontWeight'],
204
- color: variant === 'destructive' ? theme.colors.destructive : theme.colors.foreground,
205
+ color: theme.colors.foreground,
205
206
  };
206
207
  }
207
208
 
208
209
  export function alertTextStyle(theme: Theme, variant: AlertVariant): TextStyle {
209
210
  return {
210
211
  fontSize: theme.fontSizes.sm,
211
- color: variant === 'destructive' ? theme.colors.destructive : theme.colors.mutedForeground,
212
+ color: variant === 'default' ? theme.colors.mutedForeground : theme.colors.foreground,
212
213
  };
213
214
  }
214
215