expo-interface 0.2.0 → 0.3.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.
Files changed (58) hide show
  1. package/README.md +87 -10
  2. package/package.json +6 -1
  3. package/src/accent.tsx +21 -8
  4. package/src/button/button.css +6 -0
  5. package/src/button/index.android.tsx +5 -1
  6. package/src/button/index.ios.tsx +5 -1
  7. package/src/button/index.tsx +6 -1
  8. package/src/button/types.ts +13 -0
  9. package/src/color-picker/color-picker.css +70 -0
  10. package/src/color-picker/index.android.tsx +77 -49
  11. package/src/color-picker/index.ios.tsx +58 -11
  12. package/src/color-picker/index.tsx +65 -15
  13. package/src/color-picker/types.ts +9 -3
  14. package/src/context-menu/index.android.tsx +39 -11
  15. package/src/context-menu/index.ios.tsx +3 -1
  16. package/src/context-menu/index.tsx +24 -7
  17. package/src/fab/fab.css +72 -0
  18. package/src/fab/index.android.tsx +72 -0
  19. package/src/fab/index.ios.tsx +68 -0
  20. package/src/fab/index.tsx +37 -0
  21. package/src/fab/shared.ts +23 -0
  22. package/src/fab/types.ts +39 -0
  23. package/src/field-group/index.android.tsx +22 -16
  24. package/src/field-group/index.tsx +36 -5
  25. package/src/field-group/index.web.tsx +25 -9
  26. package/src/field-group/shared.tsx +49 -0
  27. package/src/field-group/types.ts +25 -0
  28. package/src/header-menu/index.tsx +76 -0
  29. package/src/host/index.tsx +32 -0
  30. package/src/index.ts +31 -5
  31. package/src/keyboard/index.tsx +81 -0
  32. package/src/keyboard/library.native.ts +17 -0
  33. package/src/keyboard/library.ts +12 -0
  34. package/src/keyboard/types.ts +20 -0
  35. package/src/list-item/index.android.tsx +23 -6
  36. package/src/list-item/index.tsx +20 -4
  37. package/src/list-item/index.web.tsx +60 -0
  38. package/src/list-item/list-item.css +65 -0
  39. package/src/list-item/types.ts +21 -0
  40. package/src/menu/index.android.tsx +22 -9
  41. package/src/menu/index.ios.tsx +26 -11
  42. package/src/menu/index.tsx +26 -10
  43. package/src/menu/list.tsx +25 -10
  44. package/src/menu/menu.css +52 -0
  45. package/src/menu/types.ts +41 -2
  46. package/src/scheme.ts +140 -0
  47. package/src/screen/index.tsx +49 -5
  48. package/src/tab-stack/index.tsx +15 -2
  49. package/src/tabs/index.tsx +2 -1
  50. package/src/tabs/index.web.tsx +29 -4
  51. package/src/tabs/types.ts +20 -2
  52. package/src/text-field/index.android.tsx +30 -6
  53. package/src/text-field/index.ios.tsx +16 -3
  54. package/src/text-field/index.tsx +24 -4
  55. package/src/text-field/inline.tsx +87 -0
  56. package/src/text-field/shared.ts +40 -1
  57. package/src/text-field/types.ts +47 -2
  58. package/src/theme.ts +44 -5
@@ -1,9 +1,20 @@
1
1
  import type {TextFieldProps} from './types';
2
- import type {TextStyle} from 'react-native';
2
+ import type {NativeSyntheticEvent, TextInputKeyPressEventData, TextStyle} from 'react-native';
3
3
 
4
+ import {useRef} from 'react';
4
5
  import {StyleSheet, TextInput} from 'react-native';
5
6
  import {fonts, fontWeights, theme, variants} from '../theme';
6
- import {keyboardTypeFor, useTextValue} from './shared';
7
+ import {InlineTextField} from './inline';
8
+ import {keyboardTypeFor, useAutoFocus, useTextValue} from './shared';
9
+
10
+ /**
11
+ * The `row` variant is the form row below; `inline` is the borderless field
12
+ * for a React Native layout (the same `TextInput`, sized to its room).
13
+ */
14
+ export function TextField(props: TextFieldProps) {
15
+ if (props.variant === 'inline') return <InlineTextField {...props}/>;
16
+ return <RowTextField {...props}/>;
17
+ }
7
18
 
8
19
  /**
9
20
  * On web the field mirrors the native iOS/Android row: a borderless, full-width
@@ -11,11 +22,12 @@ import {keyboardTypeFor, useTextValue} from './shared';
11
22
  * typography so it sits flush inside a `FieldGroup.Section`. The browser focus
12
23
  * outline is suppressed to match the chromeless iOS `Form` look.
13
24
  */
14
- export function TextField({
25
+ function RowTextField({
15
26
  placeholder,
16
27
  value,
17
28
  onChangeText,
18
29
  onSubmit,
30
+ onKeyPress,
19
31
  disabled,
20
32
  secureTextEntry,
21
33
  keyboardType,
@@ -23,16 +35,21 @@ export function TextField({
23
35
  autoCorrect,
24
36
  multiline,
25
37
  autoFocus,
38
+ returnKeyType,
39
+ submitBehavior,
26
40
  maxLength,
27
41
  accentColor,
28
42
  testID,
29
43
  style,
30
44
  }: TextFieldProps) {
45
+ const input = useRef<TextInput>(null);
31
46
  const [current, setValue] = useTextValue(value, onChangeText);
32
47
  const cursor = accentColor ?? (theme.tint as string);
48
+ useAutoFocus(input, autoFocus);
33
49
 
34
50
  return (
35
51
  <TextInput
52
+ ref={input}
36
53
  value={current}
37
54
  onChangeText={setValue}
38
55
  placeholder={placeholder}
@@ -43,11 +60,14 @@ export function TextField({
43
60
  autoCapitalize={autoCapitalize}
44
61
  autoCorrect={autoCorrect}
45
62
  multiline={multiline}
46
- autoFocus={autoFocus}
47
63
  maxLength={maxLength}
48
64
  cursorColor={cursor}
49
65
  selectionColor={cursor}
66
+ returnKeyType={returnKeyType}
67
+ submitBehavior={submitBehavior}
50
68
  onSubmitEditing={onSubmit ? event => onSubmit(event.nativeEvent.text) : undefined}
69
+ onKeyPress={onKeyPress ? (event: NativeSyntheticEvent<TextInputKeyPressEventData & {shiftKey?: boolean}>) =>
70
+ onKeyPress(event.nativeEvent.key, event.nativeEvent.shiftKey === true) : undefined}
51
71
  aria-label={placeholder}
52
72
  testID={testID}
53
73
  style={[styles.input, disabled && styles.disabled, style]}
@@ -0,0 +1,87 @@
1
+ import type {NativeSyntheticEvent, TextInputKeyPressEventData} from 'react-native';
2
+ import type {TextFieldProps} from './types';
3
+ import {useRef} from 'react';
4
+ import {StyleSheet, TextInput} from 'react-native';
5
+ import {fonts, fontWeights, spacing, useColor} from '../theme';
6
+ import {keyboardTypeFor, useAutoFocus, useTextValue} from './shared';
7
+
8
+ /**
9
+ * The `inline` variant: a borderless React Native `TextInput` on every
10
+ * platform, for a field that sits inside a React Native layout (a search
11
+ * row in a toolbar, a prompt in a bar) where the native form control would
12
+ * need a host of its own. It grows to the room it is given, focuses on
13
+ * mount when asked (and makes sure the keyboard came on Android), and steps
14
+ * with the keyboard's action key (`returnKeyType` with `submitBehavior`).
15
+ */
16
+ export function InlineTextField({
17
+ placeholder,
18
+ value,
19
+ onChangeText,
20
+ onSubmit,
21
+ onKeyPress,
22
+ disabled,
23
+ secureTextEntry,
24
+ keyboardType,
25
+ autoCapitalize,
26
+ autoCorrect,
27
+ multiline,
28
+ autoFocus,
29
+ returnKeyType,
30
+ submitBehavior,
31
+ maxLength,
32
+ accentColor,
33
+ testID,
34
+ style,
35
+ }: TextFieldProps) {
36
+ const input = useRef<TextInput>(null);
37
+ const [current, setValue] = useTextValue(value, onChangeText);
38
+ const label = useColor('label');
39
+ const placeholderColor = useColor('tertiaryLabel');
40
+ const tint = useColor('tint');
41
+ const cursor = accentColor ?? tint;
42
+ useAutoFocus(input, autoFocus);
43
+
44
+ return (
45
+ <TextInput
46
+ ref={input}
47
+ value={current}
48
+ onChangeText={setValue}
49
+ placeholder={placeholder}
50
+ placeholderTextColor={placeholderColor}
51
+ editable={!disabled}
52
+ secureTextEntry={secureTextEntry}
53
+ keyboardType={keyboardTypeFor(keyboardType)}
54
+ autoCapitalize={autoCapitalize}
55
+ autoCorrect={autoCorrect}
56
+ multiline={multiline}
57
+ maxLength={maxLength}
58
+ cursorColor={cursor}
59
+ selectionColor={cursor}
60
+ returnKeyType={returnKeyType}
61
+ submitBehavior={submitBehavior}
62
+ onSubmitEditing={onSubmit ? event => onSubmit(event.nativeEvent.text) : undefined}
63
+ onKeyPress={onKeyPress ? (event: NativeSyntheticEvent<TextInputKeyPressEventData & {shiftKey?: boolean}>) =>
64
+ onKeyPress(event.nativeEvent.key, event.nativeEvent.shiftKey === true) : undefined}
65
+ aria-label={placeholder}
66
+ testID={testID}
67
+ style={[styles.input, {color: label}, disabled && styles.disabled, style]}
68
+ />
69
+ );
70
+ }
71
+
72
+ const styles = StyleSheet.create({
73
+ input: {
74
+ flexGrow: 1,
75
+ flexShrink: 1,
76
+ minWidth: 0,
77
+ margin: 0,
78
+ paddingVertical: spacing.one,
79
+ paddingHorizontal: spacing.two,
80
+ fontSize: 14,
81
+ fontFamily: fonts?.sans,
82
+ fontWeight: fontWeights.normal,
83
+ },
84
+ disabled: {
85
+ opacity: 0.4,
86
+ },
87
+ });
@@ -1,6 +1,9 @@
1
- import type {TextFieldKeyboard} from './types';
1
+ import type {RefObject} from 'react';
2
+ import type {TextInput} from 'react-native';
2
3
  import type {ObservableState} from '@expo/ui';
4
+ import type {TextFieldKeyboard} from './types';
3
5
  import {useCallback, useEffect, useState} from 'react';
6
+ import {Keyboard, Platform} from 'react-native';
4
7
 
5
8
  /**
6
9
  * Keyboard variants understood by both React Native's `keyboardType` prop and
@@ -14,6 +17,9 @@ type AppleKeyboardType =
14
17
  | 'decimal-pad'
15
18
  | 'url';
16
19
 
20
+ /** How long after the mount the keyboard is looked for (`focusField`). */
21
+ export const FOCUS_RETRY_MS = 150;
22
+
17
23
  /**
18
24
  * Bridges controlled and uncontrolled usage on web, mirroring `useDateValue`.
19
25
  * When `value` is provided the component is controlled; otherwise it falls back
@@ -83,3 +89,36 @@ export function keyboardTypeFor(type: TextFieldKeyboard | undefined): AppleKeybo
83
89
  return 'default';
84
90
  }
85
91
  }
92
+
93
+ /**
94
+ * Focuses a React Native `TextInput` that just mounted, and makes sure its
95
+ * keyboard came: on Android the first focus asks for the keyboard before the
96
+ * field is laid out and served by the input method ("Ignoring
97
+ * showSoftInput() as view is not served"), which leaves a caret in the field
98
+ * and no keyboard; and a second `focus()` on a focused field is a no-op in
99
+ * React Native. So a moment later, if the keyboard is still down, the field
100
+ * is blurred and focused again, a fresh request the input method takes.
101
+ * Returns the effect's cleanup.
102
+ */
103
+ export function focusField(input: RefObject<TextInput | null>): () => void {
104
+ input.current?.focus();
105
+ if (Platform.OS !== 'android') return () => undefined;
106
+ const again = setTimeout(() => {
107
+ const field = input.current;
108
+ if (!field || Keyboard.isVisible()) return;
109
+ field.blur();
110
+ field.focus();
111
+ }, FOCUS_RETRY_MS);
112
+ return () => clearTimeout(again);
113
+ }
114
+
115
+ /**
116
+ * Focuses the field once it is mounted when `autoFocus` is set (see
117
+ * `focusField`), for the React Native based fields (web, `inline`).
118
+ */
119
+ export function useAutoFocus(input: RefObject<TextInput | null>, autoFocus: boolean | undefined): void {
120
+ useEffect(() => {
121
+ if (!autoFocus) return;
122
+ return focusField(input);
123
+ }, [autoFocus, input]);
124
+ }
@@ -12,6 +12,25 @@ export type TextFieldKeyboard =
12
12
  /** Automatic capitalization behaviour while typing. */
13
13
  export type TextFieldCapitalize = 'none' | 'sentences' | 'words' | 'characters';
14
14
 
15
+ /** What the keyboard's action key says, and reports through `onSubmit`. */
16
+ export type TextFieldReturnKey = 'done' | 'go' | 'next' | 'search' | 'send';
17
+
18
+ /**
19
+ * What happens to the focus when the action key is pressed. `blurAndSubmit`
20
+ * closes the keyboard; `submit` keeps the field focused so the next press
21
+ * submits again (an inline search stepping through its matches).
22
+ */
23
+ export type TextFieldSubmitBehavior = 'blurAndSubmit' | 'submit';
24
+
25
+ /**
26
+ * How the field is drawn. `row` is the native form row (SwiftUI `TextField`,
27
+ * a Compose `TextField` stripped of its container) meant for a
28
+ * `FieldGroup.Section`; `inline` is a borderless React Native `TextInput` on
29
+ * every platform, for a field that sits inside a React Native layout (a
30
+ * search row in a toolbar) where the native control would need a host.
31
+ */
32
+ export type TextFieldVariant = 'row' | 'inline';
33
+
15
34
  /**
16
35
  * Cross-platform single/multi-line text input with a conformed iOS-style
17
36
  * appearance — a borderless field whose placeholder doubles as the row label,
@@ -30,6 +49,12 @@ export interface TextFieldProps {
30
49
  onChangeText?: (text: string) => void;
31
50
  /** Called when the user presses the keyboard return key. Receives the text. */
32
51
  onSubmit?: (text: string) => void;
52
+ /**
53
+ * Called on a key press with the key's name (`Enter`, `Escape`, `a`) and
54
+ * whether Shift was held, for keyboard handling the platform does not
55
+ * cover. `inline` variant only.
56
+ */
57
+ onKeyPress?: (key: string, shiftKey: boolean) => void;
33
58
  /** Disables editing and dims the field. */
34
59
  disabled?: boolean;
35
60
  /** Masks the input for sensitive values such as passwords. */
@@ -51,14 +76,34 @@ export interface TextFieldProps {
51
76
  autoCorrect?: boolean;
52
77
  /** Allows multiple lines of input that grow vertically. */
53
78
  multiline?: boolean;
54
- /** Focuses the field automatically when mounted. */
79
+ /**
80
+ * Focuses the field once it is mounted, and makes sure its keyboard came:
81
+ * on Android the first focus can be asked for before the field is served
82
+ * by the input method and dropped, so a moment later, if the keyboard is
83
+ * still down, the field is focused again.
84
+ */
55
85
  autoFocus?: boolean;
86
+ /**
87
+ * What the keyboard's action key says. The key reports through `onSubmit`.
88
+ * @default 'done'
89
+ */
90
+ returnKeyType?: TextFieldReturnKey;
91
+ /**
92
+ * Whether the action key closes the keyboard.
93
+ * @default 'blurAndSubmit'
94
+ */
95
+ submitBehavior?: TextFieldSubmitBehavior;
96
+ /**
97
+ * The field's look.
98
+ * @default 'row'
99
+ */
100
+ variant?: TextFieldVariant;
56
101
  /** Maximum number of characters allowed. Truncates natively as the user types. */
57
102
  maxLength?: number;
58
103
  /** Tint applied to the cursor/selection (web/android) and the field (iOS). */
59
104
  accentColor?: string;
60
105
  /** Identifier used to locate the component in end-to-end tests. */
61
106
  testID?: string;
62
- /** Style applied to the text content (web only). */
107
+ /** Style applied to the text content (web and the `inline` variant). */
63
108
  style?: StyleProp<TextStyle>;
64
109
  }
package/src/theme.ts CHANGED
@@ -2,14 +2,18 @@ import './global.css';
2
2
  import type {CSSProperties} from 'react';
3
3
  import type {ColorValue, TextStyle} from 'react-native';
4
4
 
5
+ import {useMemo} from 'react';
5
6
  import {DefaultTheme} from 'expo-router';
6
- import {Platform, PlatformColor, useColorScheme} from 'react-native';
7
+ import {Platform, PlatformColor} from 'react-native';
7
8
  import {TypographyVariant, TypographyStyle} from './typography/types';
8
9
  import {ACCENT_SEED, onAccent, useAccentSeed} from './accent';
10
+ import {useColorScheme} from './scheme';
9
11
 
10
12
  export type VariantMap = Record<TypographyVariant, TypographyStyle>;
11
13
  export type ColorTokens = keyof typeof colors[keyof typeof colors];
12
14
  export type ColorValues = typeof colors[keyof typeof colors];
15
+ /** A resolved palette: one plain color string per token (see `usePalette`). */
16
+ export type Palette = Record<ColorTokens, string>;
13
17
  export type ColorNative = ColorValue | (() => ColorValue);
14
18
 
15
19
  export const VALID_STYLES = [
@@ -378,7 +382,7 @@ export function useNavTheme() {
378
382
  if (Platform.OS === 'web') return nav;
379
383
  /* eslint-disable react-hooks/rules-of-hooks -- Platform.OS is a runtime constant. */
380
384
  const seed = useAccentSeed();
381
- const palette = colors[useColorScheme() === 'dark' ? 'dark' : 'light'];
385
+ const palette = colors[useColorScheme()];
382
386
  /* eslint-enable react-hooks/rules-of-hooks */
383
387
  return {
384
388
  ...nav,
@@ -445,13 +449,30 @@ export function useColor(token: ColorTokens): string {
445
449
  if (Platform.OS === 'web') return theme[token] as string;
446
450
  /* eslint-disable react-hooks/rules-of-hooks -- Platform.OS is a runtime constant. */
447
451
  const seed = useAccentSeed();
448
- const scheme = useColorScheme() === 'dark' ? 'dark' : 'light';
452
+ const scheme = useColorScheme();
449
453
  /* eslint-enable react-hooks/rules-of-hooks */
450
454
  if (token === 'tint') return seed;
451
455
  if (token === 'onTint') return onAccent(seed);
452
456
  return colors[scheme][token];
453
457
  }
454
458
 
459
+ /**
460
+ * The resolved palette of the current scheme, with the live accent seed as
461
+ * `tint` and its contrast as `onTint`: plain color strings on every platform,
462
+ * including web. `useColor` stays the right call for styles (on web it hands
463
+ * out the CSS variable, which follows the scheme without a re-render);
464
+ * `usePalette` is for canvases, native views and anything else that cannot
465
+ * read a variable.
466
+ */
467
+ export function usePalette(): Palette {
468
+ const seed = useAccentSeed();
469
+ const scheme = useColorScheme();
470
+ return useMemo(
471
+ () => ({...colors[scheme], tint: seed, onTint: onAccent(seed)}),
472
+ [scheme, seed],
473
+ );
474
+ }
475
+
455
476
  export function getPlatformToken(specifics: {
456
477
  default: ColorValue;
457
478
  android: ColorNative;
@@ -471,10 +492,20 @@ export function getPlatformToken(specifics: {
471
492
  }
472
493
  }
473
494
 
495
+ /**
496
+ * The palette as `--color-*` custom properties for `+html.tsx`: the light
497
+ * values on `:root`, the dark ones under the `prefers-color-scheme` media
498
+ * query, and both again keyed on `data-theme`, which `setColorScheme` (and
499
+ * the `getThemeBootScript` boot script) set on the root element to force a
500
+ * scheme regardless of the system's. `tint`/`onTint` are the same in both
501
+ * schemes, so the forced palettes leave them to the defaults and to
502
+ * `AccentProvider`.
503
+ */
474
504
  export function getThemeCSS(): string {
475
505
  const format = (s: string) => s.replace(/[A-Z]/g, v => `-${v.toLowerCase()}`);
476
- const render = (o: ColorValues) => Object.entries(o).map(([k,v]) =>
477
- `\t\t${`--color-${format(k)}`}: ${v};`).join('\n');
506
+ const render = (o: ColorValues, skipAccent = false) => Object.entries(o)
507
+ .filter(([k]) => !skipAccent || (k !== 'tint' && k !== 'onTint'))
508
+ .map(([k,v]) => `\t\t${`--color-${format(k)}`}: ${v};`).join('\n');
478
509
  return `
479
510
  :root {
480
511
  color-scheme: light dark;
@@ -485,6 +516,14 @@ export function getThemeCSS(): string {
485
516
  ${render(colors.dark)}
486
517
  }
487
518
  }
519
+ :root[data-theme="light"] {
520
+ color-scheme: light;
521
+ ${render(colors.light, true)}
522
+ }
523
+ :root[data-theme="dark"] {
524
+ color-scheme: dark;
525
+ ${render(colors.dark, true)}
526
+ }
488
527
  `;
489
528
  }
490
529