@startsimpli/ui-native 0.1.2 → 0.1.4
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/__tests__/contrast.test.ts +32 -0
- package/src/__tests__/forms.test.tsx +7 -0
- package/src/__tests__/interactive.test.tsx +1 -2
- package/src/__tests__/keyboard.test.tsx +32 -0
- package/src/__tests__/large-text.test.tsx +27 -0
- package/src/__tests__/styles-primitives.test.ts +3 -2
- package/src/__tests__/tabbar.test.tsx +24 -0
- package/src/components/ChatComposer.tsx +9 -6
- package/src/components/Header.tsx +2 -2
- package/src/components/ListRow.tsx +2 -2
- package/src/components/SearchBar.tsx +2 -1
- package/src/components/StatTile.tsx +2 -2
- package/src/components/TabBar.tsx +1 -1
- package/src/components/useKeyboardInset.ts +22 -0
- package/src/contrast.ts +20 -0
- package/src/logic/keyboard.ts +4 -0
- package/src/styles.ts +8 -4
package/package.json
CHANGED
|
@@ -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('
|
|
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('
|
|
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.
|
|
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
|
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { TabBar } from '../components/TabBar';
|
|
3
|
+
import { flat, render } from '../../test/render';
|
|
4
|
+
|
|
5
|
+
describe('TabBar labels never wrap or clip', () => {
|
|
6
|
+
const items = ['Home', 'GO', 'Notes', 'To-dos', 'Connections'].map((label) => ({ key: label, label }));
|
|
7
|
+
const r = render(<TabBar items={items} activeKey="Home" onChange={() => {}} />);
|
|
8
|
+
it('every label is one line that truncates with an ellipsis', () => {
|
|
9
|
+
const labels = r.root.findAll((n) => n.type === 'Text');
|
|
10
|
+
expect(labels).toHaveLength(5);
|
|
11
|
+
for (const l of labels) {
|
|
12
|
+
expect(l.props.numberOfLines).toBe(1);
|
|
13
|
+
expect(l.props.ellipsizeMode).toBe('tail');
|
|
14
|
+
}
|
|
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
|
+
});
|
|
19
|
+
it('items can shrink below their label width so the ellipsis can happen', () => {
|
|
20
|
+
for (const tab of r.root.findAll((n) => n.props.accessibilityRole === 'tab')) {
|
|
21
|
+
expect(flat(tab.props.style).minWidth).toBe(0);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
|
-
import {
|
|
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
|
-
/**
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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 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
|
+
}
|
package/src/contrast.ts
ADDED
|
@@ -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
|
-
|
|
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:
|
|
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 === '
|
|
212
|
+
color: variant === 'default' ? theme.colors.mutedForeground : theme.colors.foreground,
|
|
212
213
|
};
|
|
213
214
|
}
|
|
214
215
|
|
|
@@ -467,6 +468,9 @@ export function tabItemStyle(theme: Theme): ViewStyle {
|
|
|
467
468
|
justifyContent: 'center',
|
|
468
469
|
gap: 2,
|
|
469
470
|
paddingVertical: theme.spacing.xs,
|
|
471
|
+
// Lets a long label shrink to the slot and ellipsize instead of widening it.
|
|
472
|
+
minWidth: 0,
|
|
473
|
+
paddingHorizontal: 2,
|
|
470
474
|
};
|
|
471
475
|
}
|
|
472
476
|
|