@mrmeg/expo-ui 0.25.0 → 0.25.1
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/CHANGELOG.md +32 -0
- package/LLM_USAGE.md +12 -0
- package/README.md +19 -1
- package/dist/components/DismissKeyboard.d.ts +8 -6
- package/dist/components/DismissKeyboard.js +9 -7
- package/dist/components/TextInput.d.ts +1 -1
- package/dist/components/TextInput.js +35 -28
- package/dist/components/keyboardDismiss.js +126 -55
- package/dist/components/keyboardFocusRegistry.d.ts +6 -4
- package/dist/constants/spacing.d.ts +2 -2
- package/dist/constants/spacing.js +2 -2
- package/dist/design-system.json +2 -2
- package/dist/state/themeStore.d.ts +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,38 @@
|
|
|
3
3
|
All notable changes to `@mrmeg/expo-ui` are documented here. This project
|
|
4
4
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
5
|
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.25.1]
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **Android password toggles retain the native field.** The secure/plain Host
|
|
13
|
+
keys previously replaced the Compose field on every eye press, leaving the
|
|
14
|
+
keyboard visible but disconnecting subsequent typing. Android now changes
|
|
15
|
+
password transformation on one mounted Host/input/ref, preserving focus,
|
|
16
|
+
selection, and the input connection. iOS retains its two-field handoff.
|
|
17
|
+
- **Non-scrolling drags no longer trigger tap-away dismissal.** `DismissKeyboard`
|
|
18
|
+
gives its boundary sole tap-dismiss ownership (`keyboardShouldPersistTaps="always"`)
|
|
19
|
+
instead of letting RN ScrollView independently blur a registered hosted field
|
|
20
|
+
on release. Both tap-away dismissal and text-surface focus check final travel
|
|
21
|
+
even when move events are missed, and cancel multitouch. Actual scrolling still
|
|
22
|
+
dismisses through the existing platform-specific scroll behavior.
|
|
23
|
+
|
|
24
|
+
Consumer note: raw `Pressable` labels made from `StyledText` must set
|
|
25
|
+
`selectable={false}`. On Android selectable label text can take native focus from
|
|
26
|
+
an input even when the press handler does not dismiss. The template's auth
|
|
27
|
+
controls and standalone scroll shell are corrected separately in app code;
|
|
28
|
+
updating this package does not update a consuming app's copied forms.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **`spacing.radiusMd` is 10 instead of 12.** Buttons, inputs, selects, toggles,
|
|
33
|
+
tabs, menus, popovers, tooltips, list items and every other component on the
|
|
34
|
+
default radius render slightly squarer. `radiusSm` (8) and `radiusLg` (14) are
|
|
35
|
+
unchanged, so the scale still increases monotonically. Apps that want the old
|
|
36
|
+
look can pass `borderRadius: 12` through `setShape` or a `style` override.
|
|
37
|
+
|
|
6
38
|
## [0.25.0]
|
|
7
39
|
|
|
8
40
|
### Added
|
package/LLM_USAGE.md
CHANGED
|
@@ -74,6 +74,13 @@ avoidance is `react-native-keyboard-controller`, so mount its `KeyboardProvider`
|
|
|
74
74
|
above `UIProvider`; use `KeyboardAvoidingView` directly only for a subtree with
|
|
75
75
|
custom behavior.
|
|
76
76
|
|
|
77
|
+
`DismissKeyboard` owns tap dismissal; its ScrollView uses
|
|
78
|
+
`keyboardShouldPersistTaps="always"` so RN cannot independently blur a hosted
|
|
79
|
+
field after a non-scrolling drag. Use `always` for your own scroll view inside
|
|
80
|
+
that boundary too. Dead-space taps dismiss on release; movement and multitouch
|
|
81
|
+
cancel. Actual scrolling retains platform-specific dismissal. Submit handlers
|
|
82
|
+
call `dismissKeyboard()` explicitly.
|
|
83
|
+
|
|
77
84
|
`BottomSheet` renders the platform's native sheet through `@expo/ui` (iOS
|
|
78
85
|
SwiftUI `.sheet()`, Android Material3 `ModalBottomSheet`, web `vaul`). The
|
|
79
86
|
platform owns gestures and keyboard avoidance: `swipeEnabled`, `avoidKeyboard`,
|
|
@@ -255,6 +262,11 @@ Check this before creating a new app-local primitive. All components come from
|
|
|
255
262
|
`BodyText`, `CaptionText`, `LabelText`, `EyebrowText`, `MonoText`, `SerifText`,
|
|
256
263
|
`SansSerifText`, `SerifBoldText`, `SansSerifBoldText`.
|
|
257
264
|
|
|
265
|
+
Set `selectable={false}` on text labels inside app-owned `Pressable`s. Selectable
|
|
266
|
+
Android text can take input focus even if the press handler never dismisses the
|
|
267
|
+
keyboard. Package controls already scope label selectability; ordinary text
|
|
268
|
+
should remain selectable.
|
|
269
|
+
|
|
258
270
|
## Component Selection Rules
|
|
259
271
|
|
|
260
272
|
- `Button` commands · `Toggle` one pressed state · `ToggleGroup` a related set · `Switch` binary settings · `RadioGroup` few exclusive choices · `Select` longer option sets.
|
package/README.md
CHANGED
|
@@ -110,6 +110,20 @@ For a subtree with custom keyboard behavior, use `KeyboardAvoidingView`
|
|
|
110
110
|
directly (`behavior`, `automaticOffset`, `contentContainerStyle`,
|
|
111
111
|
`keyboardVerticalOffset`).
|
|
112
112
|
|
|
113
|
+
### Keyboard dismissal
|
|
114
|
+
|
|
115
|
+
`DismissKeyboard` owns tap-away dismissal without claiming touches. Its scroll
|
|
116
|
+
view uses `keyboardShouldPersistTaps="always"`: RN's `handled` policy can blur
|
|
117
|
+
Expo-hosted fields independently when a finger travels without scrolling.
|
|
118
|
+
Single-finger dead-space taps dismiss on release; movement past 10 logical units
|
|
119
|
+
and multitouch cancel the tap. Real scrolling still dismisses (interactive on
|
|
120
|
+
iOS, an explicit scroll-begin handler on Android).
|
|
121
|
+
|
|
122
|
+
If a form supplies its own ScrollView inside `DismissKeyboard scrollable={false}`
|
|
123
|
+
or an app-level `useKeyboardDismissResponder` boundary, use `always` there too.
|
|
124
|
+
Avoid nested keyboard-avoiding wrappers; the package reuses `UIProvider`'s owner.
|
|
125
|
+
Call `dismissKeyboard()` explicitly from submit handlers.
|
|
126
|
+
|
|
113
127
|
## Theme System
|
|
114
128
|
|
|
115
129
|
```tsx
|
|
@@ -210,7 +224,7 @@ too, because @react-navigation parses them with a color library.
|
|
|
210
224
|
|
|
211
225
|
`spacing` exposes an 8px base scale (`xxs` 2, `xs` 4, `sm` 8, `smd` 12, `md`
|
|
212
226
|
16, `mdl` 20, `lg` 24, `xl` 32, `xxl` 48, `xxxl` 64), radii (`radiusNone` 0,
|
|
213
|
-
`radiusXs` 4, `radiusSm` 8, `radiusMd`
|
|
227
|
+
`radiusXs` 4, `radiusSm` 8, `radiusMd` 10, `radiusLg` 14, `radiusXl` 18,
|
|
214
228
|
`radius2xl` 24, `radiusFull` 9999), icon sizes (`iconXs` 12, `iconSm` 16,
|
|
215
229
|
`iconMd` 24, `iconLg` 32, `iconXl` 48), and the semantic density tokens package
|
|
216
230
|
components read from. Prefer the semantic names so a density tune lands
|
|
@@ -300,6 +314,10 @@ import { BodyText, CaptionText, HeadingText, StyledText } from "@mrmeg/expo-ui/c
|
|
|
300
314
|
- `selectable`: defaults to `true`; package controls disable it for labels and
|
|
301
315
|
interactive chrome where accidental drag selection would feel broken.
|
|
302
316
|
|
|
317
|
+
For app-owned `Pressable` labels, explicitly set `selectable={false}` on the
|
|
318
|
+
nested `StyledText`. On Android selectable label text can take focus and the IME
|
|
319
|
+
connection from an active input. Preserve selection for ordinary readable text.
|
|
320
|
+
|
|
303
321
|
Aliases: `DisplayText`, `TitleText`, `HeadingText`, `SubheadingText`,
|
|
304
322
|
`BodyText`, `CaptionText`, `LabelText`, `EyebrowText`, `MonoText` (code, IDs,
|
|
305
323
|
tabular figures), `SerifText`, `SansSerifText`, `SerifBoldText`,
|
|
@@ -12,15 +12,17 @@ type Props = {
|
|
|
12
12
|
* Wrapper for a screen (or form) that dismisses the keyboard when the user taps
|
|
13
13
|
* outside of a text input.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
* field
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* The wrapper never claims the touch: buttons, other fields and the focused
|
|
16
|
+
* field's own gestures win. It dismisses only an unclaimed single-finger tap
|
|
17
|
+
* within the travel slop, even if no scroll/move event was delivered. The inner
|
|
18
|
+
* ScrollView uses `always` to leave tap dismissal to this boundary; RN's
|
|
19
|
+
* `handled` policy can independently blur registered hosted inputs on release
|
|
20
|
+
* after a drag that produced no scroll. See `useKeyboardDismissResponder`.
|
|
20
21
|
*
|
|
21
22
|
* A drag on the inner ScrollView also hides the keyboard: `interactive` on iOS
|
|
22
23
|
* (UIKit resigns any first responder, SwiftUI fields included) and an explicit
|
|
23
|
-
* dismiss on drag start on Android,
|
|
24
|
+
* dismiss on drag start on Android, using the package's window-independent
|
|
25
|
+
* native blur handle with a keyboard-controller fallback.
|
|
24
26
|
*/
|
|
25
27
|
export declare function DismissKeyboard({ children, style, avoidKeyboard, scrollable, }: Props): React.JSX.Element;
|
|
26
28
|
export {};
|
|
@@ -6,20 +6,22 @@ import { dismissKeyboard, useKeyboardDismissResponder } from "./keyboardDismiss.
|
|
|
6
6
|
* Wrapper for a screen (or form) that dismisses the keyboard when the user taps
|
|
7
7
|
* outside of a text input.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* field
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* The wrapper never claims the touch: buttons, other fields and the focused
|
|
10
|
+
* field's own gestures win. It dismisses only an unclaimed single-finger tap
|
|
11
|
+
* within the travel slop, even if no scroll/move event was delivered. The inner
|
|
12
|
+
* ScrollView uses `always` to leave tap dismissal to this boundary; RN's
|
|
13
|
+
* `handled` policy can independently blur registered hosted inputs on release
|
|
14
|
+
* after a drag that produced no scroll. See `useKeyboardDismissResponder`.
|
|
14
15
|
*
|
|
15
16
|
* A drag on the inner ScrollView also hides the keyboard: `interactive` on iOS
|
|
16
17
|
* (UIKit resigns any first responder, SwiftUI fields included) and an explicit
|
|
17
|
-
* dismiss on drag start on Android,
|
|
18
|
+
* dismiss on drag start on Android, using the package's window-independent
|
|
19
|
+
* native blur handle with a keyboard-controller fallback.
|
|
18
20
|
*/
|
|
19
21
|
export function DismissKeyboard({ children, style, avoidKeyboard = true, scrollable = true, }) {
|
|
20
22
|
const responderProps = useKeyboardDismissResponder();
|
|
21
23
|
const hasKeyboardAvoidance = useKeyboardAvoidance();
|
|
22
|
-
const content = scrollable ? (_jsx(ScrollView, { style: { flex: 1 }, contentContainerStyle: { flexGrow: 1, justifyContent: "center" }, keyboardShouldPersistTaps: "
|
|
24
|
+
const content = scrollable ? (_jsx(ScrollView, { style: { flex: 1 }, contentContainerStyle: { flexGrow: 1, justifyContent: "center" }, keyboardShouldPersistTaps: "always", keyboardDismissMode: Platform.OS === "ios" ? "interactive" : "none", onScrollBeginDrag: Platform.OS === "android" ? dismissKeyboard : undefined, showsVerticalScrollIndicator: false, children: children })) : (children);
|
|
23
25
|
if (!avoidKeyboard || hasKeyboardAvoidance) {
|
|
24
26
|
return (_jsx(View, { style: { flex: 1 }, ...responderProps, children: content }));
|
|
25
27
|
}
|
|
@@ -10,7 +10,7 @@ export type TextInputSize = "sm" | "md" | "lg";
|
|
|
10
10
|
export type TextInputVariant = "outline" | "filled" | "underlined";
|
|
11
11
|
interface TextInputCustomProps extends TextInputProps {
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
13
|
+
* RNTextInput ref on web; an RN-compatible imperative handle on native.
|
|
14
14
|
*
|
|
15
15
|
* Written as `ComponentRef<typeof RNTextInput>` rather than RN 0.88's
|
|
16
16
|
* `TextInputInstance` alias so the emitted declarations stay valid on
|
|
@@ -191,11 +191,10 @@ function WebTextInput({ variant = "outline", size = "md", label, helperText, err
|
|
|
191
191
|
* never round-trips through React state — eliminating the cursor flicker seen on
|
|
192
192
|
* controlled RN inputs.
|
|
193
193
|
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
* layering RN views over the native host view.
|
|
194
|
+
* This path supports label / helper / error text and a password visibility
|
|
195
|
+
* toggle laid out beside the native Host. Clear buttons, left/right elements,
|
|
196
|
+
* and the error icon remain web-only; RN overlays are not layered over the
|
|
197
|
+
* native host view.
|
|
199
198
|
*/
|
|
200
199
|
function NativeTextInput({ variant = "outline", size = "md", label, helperText, errorText, error, required, rows, forceLight, inputMode, onChangeText, onFocus, onBlur, value, defaultValue, editable = true, multiline, secureTextEntry, showSecureEntryToggle, ref, wrapperStyle,
|
|
201
200
|
// Web-only affordances. Destructured out of `rest` so they're NOT forwarded to
|
|
@@ -231,9 +230,9 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
|
|
|
231
230
|
// parity) once it settles.
|
|
232
231
|
const queuedTogglesRef = useRef(0);
|
|
233
232
|
const toggleRef = useRef(() => { });
|
|
234
|
-
//
|
|
235
|
-
// view so React
|
|
236
|
-
// instead of reusing one
|
|
233
|
+
// iOS-only per-flavour mount generation. Bumped whenever a flavour becomes
|
|
234
|
+
// the incoming view so React mounts a FRESH native view (whose `autoFocus`
|
|
235
|
+
// will run) instead of reusing one still unmounting from the previous handoff.
|
|
237
236
|
const generationRef = useRef({ secure: 0, plain: 0 });
|
|
238
237
|
// Last text the JS side knows about; see handleChangeText.
|
|
239
238
|
const lastTextRef = useRef(value ?? defaultValue ?? "");
|
|
@@ -256,12 +255,18 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
|
|
|
256
255
|
// RNTextInput because that's what the public TextInputCustomProps declares.
|
|
257
256
|
// We expose the subset consumers use, plus a `setNativeProps` shim so the
|
|
258
257
|
// uncontrolled AuthTextField can push corrected text into the native buffer.
|
|
259
|
-
//
|
|
260
|
-
// iOS
|
|
258
|
+
// Android keeps one field/ref as visualTransformation changes in place.
|
|
259
|
+
// iOS has one ref per flavour; both are mounted only during a handoff.
|
|
260
|
+
const androidRef = useRef(null);
|
|
261
261
|
const secureRef = useRef(null);
|
|
262
262
|
const plainRef = useRef(null);
|
|
263
|
-
const activeInput = useCallback(() =>
|
|
263
|
+
const activeInput = useCallback(() => {
|
|
264
|
+
if (Platform.OS === "android")
|
|
265
|
+
return androidRef.current;
|
|
266
|
+
return (activeSecureRef.current ? secureRef : plainRef).current;
|
|
267
|
+
}, []);
|
|
264
268
|
const blurAll = useCallback(() => {
|
|
269
|
+
androidRef.current?.blur();
|
|
265
270
|
secureRef.current?.blur();
|
|
266
271
|
plainRef.current?.blur();
|
|
267
272
|
}, []);
|
|
@@ -282,19 +287,15 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
|
|
|
282
287
|
}, []);
|
|
283
288
|
const parentOnFocus = onFocus;
|
|
284
289
|
const parentOnBlur = onBlur;
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
// React is unmounting.
|
|
290
|
+
// On iOS, ignore focus/blur from the outgoing flavour during a handoff or
|
|
291
|
+
// unmount. Android events always belong to the same mounted field.
|
|
288
292
|
const handleFocus = useCallback((secure) => {
|
|
289
|
-
if (secure !== activeSecureRef.current)
|
|
293
|
+
if (Platform.OS === "ios" && secure !== activeSecureRef.current)
|
|
290
294
|
return;
|
|
291
295
|
isFocusedRef.current = true;
|
|
292
|
-
// Register
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
// Blurring through the field's own ref is window-independent — inside a
|
|
296
|
-
// native bottom sheet `KeyboardController.dismiss()` can miss, the ref
|
|
297
|
-
// never does.
|
|
296
|
+
// Register the field's own blur handle for tap-away dismissal and the
|
|
297
|
+
// BottomSheet overlay. RN supports Expo-hosted field interop; this direct
|
|
298
|
+
// ref also targets the actual field inside a native sheet's window.
|
|
298
299
|
setKeyboardFocusedInput(focusRegistryToken, blurAll);
|
|
299
300
|
if (handoffRef.current) {
|
|
300
301
|
// The incoming view took first responder; the parent never saw focus leave.
|
|
@@ -305,7 +306,7 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
|
|
|
305
306
|
parentOnFocus?.();
|
|
306
307
|
}, [blurAll, finishHandoff, focusRegistryToken, parentOnFocus]);
|
|
307
308
|
const handleBlur = useCallback((secure) => {
|
|
308
|
-
if (secure !== activeSecureRef.current)
|
|
309
|
+
if (Platform.OS === "ios" && secure !== activeSecureRef.current)
|
|
309
310
|
return;
|
|
310
311
|
isFocusedRef.current = false;
|
|
311
312
|
restoreAfterSecureHandoffRef.current = false;
|
|
@@ -321,10 +322,12 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
|
|
|
321
322
|
}
|
|
322
323
|
restoreAfterSecureHandoffRef.current = false;
|
|
323
324
|
const outgoingSecure = activeSecureRef.current;
|
|
324
|
-
if (
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
325
|
+
if (Platform.OS === "ios") {
|
|
326
|
+
if (outgoingSecure)
|
|
327
|
+
generationRef.current.plain += 1;
|
|
328
|
+
else
|
|
329
|
+
generationRef.current.secure += 1;
|
|
330
|
+
}
|
|
328
331
|
if (Platform.OS === "ios" && isFocusedRef.current) {
|
|
329
332
|
const timer = setTimeout(() => {
|
|
330
333
|
// The incoming view never took first responder. Rather than unmount
|
|
@@ -460,7 +463,7 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
|
|
|
460
463
|
fontSize: sizeConfig.fontSize,
|
|
461
464
|
...(nativeFontFamily ? { fontFamily: nativeFontFamily } : null),
|
|
462
465
|
};
|
|
463
|
-
return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, size: "base", fontWeight: "medium", style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) })), _jsxs(View, { style: [surfaceStyle, hasSecureToggle && styles.nativeRow], hitSlop: { top: surfaceHitSlop, bottom: surfaceHitSlop }, ...surfaceResponderProps, children: [[true, false].map((secure) => {
|
|
466
|
+
return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, size: "base", fontWeight: "medium", style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) })), _jsxs(View, { style: [surfaceStyle, hasSecureToggle && styles.nativeRow], hitSlop: { top: surfaceHitSlop, bottom: surfaceHitSlop }, ...surfaceResponderProps, children: [(Platform.OS === "android" ? [effectiveSecureTextEntry] : [true, false]).map((secure) => {
|
|
464
467
|
const active = secure === effectiveSecureTextEntry;
|
|
465
468
|
const outgoing = handoff != null && secure === handoff.outgoingSecure;
|
|
466
469
|
if (!active && !outgoing)
|
|
@@ -472,7 +475,11 @@ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
|
|
|
472
475
|
? hasSecureToggle
|
|
473
476
|
? styles.nativeHostFlex
|
|
474
477
|
: styles.nativeHost
|
|
475
|
-
: styles.nativeHostHandoff, pointerEvents: inLayout ? "auto" : "none", accessibilityElementsHidden: !inLayout, importantForAccessibility: inLayout ? "auto" : "no-hide-descendants", children: _jsx(ExpoTextInput, { ...rest, autoFocus: rest.autoFocus || (handoff != null && active), ref: secure ? secureRef : plainRef, value: state, defaultValue: defaultValue, onChangeText: handleChangeText, onFocus: () => handleFocus(secure), onBlur: () => handleBlur(secure), editable: editable, multiline: multiline, rows: rows, inputMode: inputMode, secureTextEntry: secure, placeholderTextColor: theme.colors.textDim, style: boxStyle, textStyle: textStyle }) },
|
|
478
|
+
: styles.nativeHostHandoff, pointerEvents: inLayout ? "auto" : "none", accessibilityElementsHidden: !inLayout, importantForAccessibility: inLayout ? "auto" : "no-hide-descendants", children: _jsx(ExpoTextInput, { ...rest, autoFocus: rest.autoFocus || (handoff != null && active), ref: Platform.OS === "android" ? androidRef : secure ? secureRef : plainRef, value: state, defaultValue: defaultValue, onChangeText: handleChangeText, onFocus: () => handleFocus(secure), onBlur: () => handleBlur(secure), editable: editable, multiline: multiline, rows: rows, inputMode: inputMode, secureTextEntry: secure, placeholderTextColor: theme.colors.textDim, style: boxStyle, textStyle: textStyle }) }, Platform.OS === "android"
|
|
479
|
+
? "android"
|
|
480
|
+
: secure
|
|
481
|
+
? `secure-${generationRef.current.secure}`
|
|
482
|
+
: `plain-${generationRef.current.plain}`));
|
|
476
483
|
}), hasSecureToggle && (_jsx(Pressable, { style: styles.nativePasswordToggle, onPress: togglePasswordVisible, accessibilityLabel: passwordVisible ? "Hide password" : "Show password", accessibilityRole: "button", children: _jsx(Icon, { name: passwordVisible ? "eye-off" : "eye", size: spacing.iconSm + 4, color: "textDim" }) }))] }), !!(helperText || errorText) && (_jsx(StyledText, { selectable: false, size: "sm", style: [styles.helperText, hasError && styles.errorText], children: errorText || helperText }))] }));
|
|
477
484
|
}
|
|
478
485
|
const createStyles = (theme, variant, size) => StyleSheet.create({
|
|
@@ -1,34 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tap-away keyboard dismissal that never steals a touch.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Let the deepest interactive view win the responder negotiation, and blur on
|
|
5
|
+
* release only for an unclaimed, single-finger tap within the travel slop.
|
|
6
|
+
* Hosted `@expo/ui` inputs register with RN's `TextInputState`. A ScrollView
|
|
7
|
+
* using `keyboardShouldPersistTaps="handled"` can therefore claim dead space
|
|
8
|
+
* and blur on release if it observed no scroll, even after substantial travel.
|
|
9
|
+
* `DismissKeyboard`'s ScrollView uses `always` so this boundary owns tap dismissal
|
|
10
|
+
* with an explicit travel policy, independent of whether content can scroll.
|
|
11
|
+
* Two signals need no rectangles and no responder ownership:
|
|
10
12
|
*
|
|
11
13
|
* 1. `onStartShouldSetResponder` in the *bubble* phase is only ever called on a
|
|
12
|
-
* view when no descendant claimed the touch. Pressables
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* view when no descendant claimed the touch. Pressables and RN TextInputs
|
|
15
|
+
* claim, as can scroll views depending on their tap policy. Being asked
|
|
16
|
+
* means no descendant took responder ownership, not necessarily dead space.
|
|
15
17
|
* 2. The package `TextInput` tags touches that begin on its surface
|
|
16
18
|
* (`markTextInputTouchStart`). Its bubble handler runs before any enclosing
|
|
17
19
|
* boundary's, so the boundary can tell a tap on *any* native field — focused
|
|
18
20
|
* or not — from dead space, and leave focus handoff, double-tap selection
|
|
19
21
|
* and the eye toggle to the platform.
|
|
20
22
|
*
|
|
21
|
-
* The boundary always answers `false`, so
|
|
22
|
-
* controls (SwiftUI/Compose fields, sliders) keep receiving every touch phase
|
|
23
|
+
* The boundary always answers `false`, so it never becomes the JS responder.
|
|
24
|
+
* Native controls (SwiftUI/Compose fields, sliders) keep receiving every touch phase
|
|
23
25
|
* on both platforms, and scroll views take over drags exactly as before.
|
|
24
26
|
* Dismissal happens in `onTouchEnd`, a plain bubbling event that fires whether
|
|
25
|
-
* or not anyone became the responder
|
|
27
|
+
* or not anyone became the responder. Moves cancel permanently once past the
|
|
28
|
+
* slop; release coordinates also enforce it when move delivery was missed.
|
|
29
|
+
* Cancellation and multitouch drop the tap without rearming another finger.
|
|
26
30
|
*/
|
|
27
31
|
import { useMemo, useRef } from "react";
|
|
28
32
|
import { Platform } from "react-native";
|
|
29
33
|
import { KeyboardController, useKeyboardState } from "./keyboardController";
|
|
30
34
|
import { dismissKeyboardFocusedInput, hasKeyboardFocusedInput } from "./keyboardFocusRegistry.js";
|
|
31
|
-
/**
|
|
35
|
+
/** Maximum displacement per axis (RN logical units) for a tap, scrollable or not. */
|
|
32
36
|
const MOVE_SLOP = 10;
|
|
33
37
|
function keyOf(event) {
|
|
34
38
|
const { identifier, timestamp } = event.nativeEvent;
|
|
@@ -42,6 +46,10 @@ function isSameEvent(key, event) {
|
|
|
42
46
|
}
|
|
43
47
|
let textInputTouch = null;
|
|
44
48
|
let claimedTouch = null;
|
|
49
|
+
// An ancestor can observe a second finger outside an inner boundary/surface.
|
|
50
|
+
// Invalidate already-armed taps there too, even if that finger lifts first and
|
|
51
|
+
// the inner view only receives its original finger's final touches=[] event.
|
|
52
|
+
let multiTouchVersion = 0;
|
|
45
53
|
/**
|
|
46
54
|
* `onStartShouldSetResponder` for a native text-field surface: decline the
|
|
47
55
|
* responder (the native field keeps every touch phase) but tag the touch so an
|
|
@@ -72,6 +80,77 @@ function movedPastSlop(tap, event) {
|
|
|
72
80
|
return (Math.abs(event.nativeEvent.pageX - tap.pageX) > MOVE_SLOP ||
|
|
73
81
|
Math.abs(event.nativeEvent.pageY - tap.pageY) > MOVE_SLOP);
|
|
74
82
|
}
|
|
83
|
+
/** Shared single-finger travel policy for tap-away dismissal and surface focus. */
|
|
84
|
+
function createTapTracker() {
|
|
85
|
+
let pending = null;
|
|
86
|
+
let armedVersion = multiTouchVersion;
|
|
87
|
+
let lastStart = null;
|
|
88
|
+
const active = new Set();
|
|
89
|
+
function readTouches(event) {
|
|
90
|
+
const { touches } = event.nativeEvent;
|
|
91
|
+
if (touches == null)
|
|
92
|
+
return false;
|
|
93
|
+
active.clear();
|
|
94
|
+
touches.forEach(({ identifier }) => active.add(identifier));
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
start(event) {
|
|
99
|
+
// Negotiation precedes the plain bubbling touch-start for the same event.
|
|
100
|
+
// Observe it once; a child-claimed start reaches only onTouchStart, which
|
|
101
|
+
// clears any stale pending tap but never arms one.
|
|
102
|
+
if (isSameEvent(lastStart, event))
|
|
103
|
+
return false;
|
|
104
|
+
lastStart = keyOf(event);
|
|
105
|
+
pending = null;
|
|
106
|
+
// RN's array is authoritative, including after a missed end. The set is
|
|
107
|
+
// also a fallback for callers/tests omitting arrays, retaining other down
|
|
108
|
+
// fingers even after movement has cancelled the pending tap.
|
|
109
|
+
if (!readTouches(event))
|
|
110
|
+
active.add(event.nativeEvent.identifier);
|
|
111
|
+
if (active.size > 1 || (event.nativeEvent.changedTouches?.length ?? 1) > 1) {
|
|
112
|
+
multiTouchVersion += 1;
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
return active.size === 1;
|
|
116
|
+
},
|
|
117
|
+
arm(event) {
|
|
118
|
+
pending = { ...keyOf(event), pageX: event.nativeEvent.pageX, pageY: event.nativeEvent.pageY };
|
|
119
|
+
armedVersion = multiTouchVersion;
|
|
120
|
+
},
|
|
121
|
+
move(event) {
|
|
122
|
+
readTouches(event);
|
|
123
|
+
if (active.size > 1 || (event.nativeEvent.changedTouches?.length ?? 1) > 1) {
|
|
124
|
+
multiTouchVersion += 1;
|
|
125
|
+
pending = null;
|
|
126
|
+
}
|
|
127
|
+
else if (isSameTouch(pending, event) && movedPastSlop(pending, event)) {
|
|
128
|
+
pending = null;
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
end(event) {
|
|
132
|
+
const tap = pending;
|
|
133
|
+
pending = null;
|
|
134
|
+
// On touch end, touches contains the REMAINING fingers (empty on the last
|
|
135
|
+
// release); changedTouches contains those that ended. Never treat the
|
|
136
|
+
// final empty array as an invalid single-finger tap.
|
|
137
|
+
if (!readTouches(event)) {
|
|
138
|
+
active.delete(event.nativeEvent.identifier);
|
|
139
|
+
event.nativeEvent.changedTouches?.forEach(({ identifier }) => active.delete(identifier));
|
|
140
|
+
}
|
|
141
|
+
if (active.size === 0)
|
|
142
|
+
lastStart = null;
|
|
143
|
+
if (active.size > 0 || (event.nativeEvent.changedTouches?.length ?? 1) > 1) {
|
|
144
|
+
multiTouchVersion += 1;
|
|
145
|
+
}
|
|
146
|
+
return (armedVersion === multiTouchVersion &&
|
|
147
|
+
active.size === 0 &&
|
|
148
|
+
(event.nativeEvent.changedTouches?.length ?? 1) <= 1 &&
|
|
149
|
+
isSameTouch(tap, event) &&
|
|
150
|
+
!movedPastSlop(tap, event));
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
75
154
|
/**
|
|
76
155
|
* View props for the painted surface around a native (`@expo/ui`) text field.
|
|
77
156
|
*
|
|
@@ -87,31 +166,30 @@ function movedPastSlop(tap, event) {
|
|
|
87
166
|
export function useTextInputSurfaceResponder(focus) {
|
|
88
167
|
const focusRef = useRef(focus);
|
|
89
168
|
focusRef.current = focus;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
tap.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}), []);
|
|
169
|
+
return useMemo(() => {
|
|
170
|
+
const tap = createTapTracker();
|
|
171
|
+
return {
|
|
172
|
+
onStartShouldSetResponder: (event) => {
|
|
173
|
+
markTextInputTouchStart(event);
|
|
174
|
+
if (tap.start(event))
|
|
175
|
+
tap.arm(event);
|
|
176
|
+
return false;
|
|
177
|
+
},
|
|
178
|
+
onTouchStart: (event) => {
|
|
179
|
+
tap.start(event);
|
|
180
|
+
},
|
|
181
|
+
onTouchMove: (event) => {
|
|
182
|
+
tap.move(event);
|
|
183
|
+
},
|
|
184
|
+
onTouchEnd: (event) => {
|
|
185
|
+
if (tap.end(event))
|
|
186
|
+
focusRef.current();
|
|
187
|
+
},
|
|
188
|
+
onTouchCancel: (event) => {
|
|
189
|
+
tap.end(event);
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}, []);
|
|
115
193
|
}
|
|
116
194
|
/**
|
|
117
195
|
* View props for a tap-away keyboard-dismiss boundary. Spread onto the wrapper
|
|
@@ -123,13 +201,14 @@ export function useKeyboardDismissResponder() {
|
|
|
123
201
|
const isVisible = useKeyboardState((state) => state.isVisible);
|
|
124
202
|
const isVisibleRef = useRef(isVisible);
|
|
125
203
|
isVisibleRef.current = isVisible;
|
|
126
|
-
const pending = useRef(null);
|
|
127
204
|
return useMemo(() => {
|
|
128
205
|
if (Platform.OS === "web")
|
|
129
206
|
return {};
|
|
207
|
+
const tap = createTapTracker();
|
|
130
208
|
return {
|
|
131
209
|
onStartShouldSetResponder: (event) => {
|
|
132
|
-
|
|
210
|
+
if (!tap.start(event))
|
|
211
|
+
return false;
|
|
133
212
|
// `hasKeyboardFocusedInput` covers the window where a field has focus but
|
|
134
213
|
// keyboard-controller has not reported the keyboard yet (or a hardware
|
|
135
214
|
// keyboard is attached and there is no software keyboard to observe).
|
|
@@ -141,29 +220,21 @@ export function useKeyboardDismissResponder() {
|
|
|
141
220
|
if (isSameEvent(claimedTouch, event))
|
|
142
221
|
return false;
|
|
143
222
|
claimedTouch = keyOf(event);
|
|
144
|
-
|
|
223
|
+
tap.arm(event);
|
|
145
224
|
return false;
|
|
146
225
|
},
|
|
147
226
|
onTouchStart: (event) => {
|
|
148
|
-
|
|
149
|
-
// event, so a fresh pending tap matches here; anything else is stale
|
|
150
|
-
// (e.g. a touch whose end never reached this view).
|
|
151
|
-
if (pending.current && !isSameEvent(pending.current, event))
|
|
152
|
-
pending.current = null;
|
|
227
|
+
tap.start(event);
|
|
153
228
|
},
|
|
154
229
|
onTouchMove: (event) => {
|
|
155
|
-
|
|
156
|
-
pending.current = null;
|
|
157
|
-
}
|
|
230
|
+
tap.move(event);
|
|
158
231
|
},
|
|
159
232
|
onTouchEnd: (event) => {
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
pending.current = null;
|
|
163
|
-
dismissKeyboard();
|
|
233
|
+
if (tap.end(event))
|
|
234
|
+
dismissKeyboard();
|
|
164
235
|
},
|
|
165
|
-
onTouchCancel: () => {
|
|
166
|
-
|
|
236
|
+
onTouchCancel: (event) => {
|
|
237
|
+
tap.end(event);
|
|
167
238
|
},
|
|
168
239
|
};
|
|
169
240
|
}, []);
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Registry of the currently focused native (`@expo/ui`) text field.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Hosted `@expo/ui` inputs also register with React Native's `TextInputState`.
|
|
5
|
+
* This package registry supplies a window-independent native blur handle and
|
|
6
|
+
* focus presence even when keyboard-controller cannot observe the keyboard
|
|
7
|
+
* (for example, in an isolated native sheet window). The package `TextInput`
|
|
8
|
+
* registers on focus; tap-away dismissal (`keyboardDismiss.ts`) and the
|
|
9
|
+
* `BottomSheet` overlay resign the field through that handle.
|
|
8
10
|
*/
|
|
9
11
|
export type KeyboardFocusedInputToken = object;
|
|
10
12
|
/**
|
|
@@ -36,7 +36,7 @@ export declare const spacing: {
|
|
|
36
36
|
readonly radiusNone: 0;
|
|
37
37
|
readonly radiusXs: 4;
|
|
38
38
|
readonly radiusSm: 8;
|
|
39
|
-
readonly radiusMd:
|
|
39
|
+
readonly radiusMd: 10;
|
|
40
40
|
readonly radiusLg: 14;
|
|
41
41
|
readonly radiusXl: 18;
|
|
42
42
|
readonly radius2xl: 24;
|
|
@@ -47,5 +47,5 @@ export declare const spacing: {
|
|
|
47
47
|
readonly iconLg: 32;
|
|
48
48
|
readonly iconXl: 48;
|
|
49
49
|
};
|
|
50
|
-
export declare const base: 8, xxs: 2, xs: 4, sm: 8, smd: 12, md: 16, mdl: 20, lg: 24, xl: 32, xxl: 48, xxxl: 64, gutter: 16, gutterVertical: 24, screenPadding: 16, sectionSpacing: 24, rowPaddingY: 10, rowPaddingX: 16, rowGap: 12, rowMinHeight: 40, formRowMinHeight: 32, cardPadding: 16, dialogPadding: 20, touchTarget: 44, buttonPadding: 10, inputPadding: 10, listItemSpacing: 8, radiusNone: 0, radiusXs: 4, radiusSm: 8, radiusMd:
|
|
50
|
+
export declare const base: 8, xxs: 2, xs: 4, sm: 8, smd: 12, md: 16, mdl: 20, lg: 24, xl: 32, xxl: 48, xxxl: 64, gutter: 16, gutterVertical: 24, screenPadding: 16, sectionSpacing: 24, rowPaddingY: 10, rowPaddingX: 16, rowGap: 12, rowMinHeight: 40, formRowMinHeight: 32, cardPadding: 16, dialogPadding: 20, touchTarget: 44, buttonPadding: 10, inputPadding: 10, listItemSpacing: 8, radiusNone: 0, radiusXs: 4, radiusSm: 8, radiusMd: 10, radiusLg: 14, radiusXl: 18, radius2xl: 24, radiusFull: 9999, iconXs: 12, iconSm: 16, iconMd: 24, iconLg: 32, iconXl: 48;
|
|
51
51
|
export declare const space: (multiplier: number) => number;
|
|
@@ -38,11 +38,11 @@ export const spacing = {
|
|
|
38
38
|
buttonPadding: 10, // Default button padding
|
|
39
39
|
inputPadding: 10, // Default input padding
|
|
40
40
|
listItemSpacing: 8, // Space between list items
|
|
41
|
-
// Border radius — shadcn-inspired scale (radiusMd =
|
|
41
|
+
// Border radius — shadcn-inspired scale (radiusMd = 10px default)
|
|
42
42
|
radiusNone: 0,
|
|
43
43
|
radiusXs: 4,
|
|
44
44
|
radiusSm: 8,
|
|
45
|
-
radiusMd:
|
|
45
|
+
radiusMd: 10,
|
|
46
46
|
radiusLg: 14,
|
|
47
47
|
radiusXl: 18,
|
|
48
48
|
radius2xl: 24,
|
package/dist/design-system.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"package": "@mrmeg/expo-ui",
|
|
4
|
-
"version": "0.25.
|
|
4
|
+
"version": "0.25.1",
|
|
5
5
|
"tokens": {
|
|
6
6
|
"spacing": {
|
|
7
7
|
"entries": [
|
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
},
|
|
128
128
|
{
|
|
129
129
|
"name": "radiusMd",
|
|
130
|
-
"value":
|
|
130
|
+
"value": 10
|
|
131
131
|
},
|
|
132
132
|
{
|
|
133
133
|
"name": "radiusLg",
|
|
@@ -36,7 +36,7 @@ export type ShapeOverrides = {
|
|
|
36
36
|
button?: {
|
|
37
37
|
/**
|
|
38
38
|
* Border radius applied to every Button preset. Package default:
|
|
39
|
-
* `spacing.radiusMd` (
|
|
39
|
+
* `spacing.radiusMd` (10). Use 9999 for pill buttons. A caller `style`
|
|
40
40
|
* still wins over this, as it always has.
|
|
41
41
|
*/
|
|
42
42
|
borderRadius?: number;
|