@mrmeg/expo-ui 0.22.0 → 0.24.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.
@@ -11,6 +11,7 @@ export * from "./Checkbox.js";
11
11
  export * from "./Collapsible.js";
12
12
  export * from "./Dialog.js";
13
13
  export * from "./DismissKeyboard.js";
14
+ export * from "./keyboardDismiss.js";
14
15
  export * from "./Drawer.js";
15
16
  export * from "./DropdownMenu.js";
16
17
  export * from "./EmptyState.js";
@@ -0,0 +1,37 @@
1
+ import { type GestureResponderEvent, type ViewProps } from "react-native";
2
+ /**
3
+ * `onStartShouldSetResponder` for a native text-field surface: decline the
4
+ * responder (the native field keeps every touch phase) but tag the touch so an
5
+ * enclosing tap-away boundary leaves it alone. The package `TextInput` uses
6
+ * this through {@link useTextInputSurfaceResponder}.
7
+ */
8
+ export declare function markTextInputTouchStart(event: GestureResponderEvent): boolean;
9
+ /**
10
+ * Hide the software keyboard for whatever is focused. Prefers the registered
11
+ * native field's own blur handle (window-independent), then falls back to
12
+ * `KeyboardController.dismiss()`, which resigns at the IME level and covers RN
13
+ * or third-party inputs. No-op on web, which has no software keyboard to hide.
14
+ */
15
+ export declare function dismissKeyboard(): void;
16
+ export type TextInputSurfaceResponderProps = Pick<ViewProps, "onStartShouldSetResponder" | "onTouchStart" | "onTouchMove" | "onTouchEnd" | "onTouchCancel">;
17
+ /**
18
+ * View props for the painted surface around a native (`@expo/ui`) text field.
19
+ *
20
+ * Two jobs. It tags every touch that begins on the surface so the tap-away
21
+ * boundary leaves it alone (see {@link markTextInputTouchStart}). And it
22
+ * focuses the field on release of a tap that no child claimed: the SwiftUI
23
+ * `TextField` only hit-tests its text line, so taps on the box's vertical
24
+ * padding never reach it, and a tap on the field's `hitSlop` never can. Calling
25
+ * `focus` is idempotent when the native field already took the tap, and the eye
26
+ * toggle is a `Pressable` that claims the responder first, so it never fires
27
+ * for that.
28
+ */
29
+ export declare function useTextInputSurfaceResponder(focus: () => void): TextInputSurfaceResponderProps;
30
+ export type KeyboardDismissResponderProps = Pick<ViewProps, "onStartShouldSetResponder" | "onTouchStart" | "onTouchMove" | "onTouchEnd" | "onTouchCancel">;
31
+ /**
32
+ * View props for a tap-away keyboard-dismiss boundary. Spread onto the wrapper
33
+ * `View` that encloses the screen (or a form). Nesting is safe: the innermost
34
+ * boundary owns each touch, so the keyboard is dismissed once. Returns `{}` on
35
+ * web.
36
+ */
37
+ export declare function useKeyboardDismissResponder(): KeyboardDismissResponderProps;
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Tap-away keyboard dismissal that never steals a touch.
3
+ *
4
+ * React Native's own `keyboardShouldPersistTaps="handled"` is the model: let the
5
+ * deepest interactive view win the responder negotiation, and blur on *release*
6
+ * only when nothing else took the tap and the finger did not scroll. RN cannot
7
+ * apply that to the native `@expo/ui` field (it is invisible to
8
+ * `TextInputState`), so this module re-creates the same decision with two
9
+ * signals that need no rectangles and no responder ownership:
10
+ *
11
+ * 1. `onStartShouldSetResponder` in the *bubble* phase is only ever called on a
12
+ * view when no descendant claimed the touch. Pressables, RN TextInputs and
13
+ * scroll views all claim, so being asked at all means "this tap is on dead
14
+ * space or on a native view that does not participate".
15
+ * 2. The package `TextInput` tags touches that begin on its surface
16
+ * (`markTextInputTouchStart`). Its bubble handler runs before any enclosing
17
+ * boundary's, so the boundary can tell a tap on *any* native field — focused
18
+ * or not — from dead space, and leave focus handoff, double-tap selection
19
+ * and the eye toggle to the platform.
20
+ *
21
+ * The boundary always answers `false`, so no JS responder is ever set: native
22
+ * controls (SwiftUI/Compose fields, sliders) keep receiving every touch phase
23
+ * on both platforms, and scroll views take over drags exactly as before.
24
+ * Dismissal happens in `onTouchEnd`, a plain bubbling event that fires whether
25
+ * or not anyone became the responder; a drag past the slop or a cancel drops it.
26
+ */
27
+ import { useMemo, useRef } from "react";
28
+ import { Platform } from "react-native";
29
+ import { KeyboardController, useKeyboardState } from "./keyboardController";
30
+ import { dismissKeyboardFocusedInput, hasKeyboardFocusedInput } from "./keyboardFocusRegistry.js";
31
+ /** Finger travel (pt) after which a touch counts as a scroll, not a tap. */
32
+ const MOVE_SLOP = 10;
33
+ function keyOf(event) {
34
+ const { identifier, timestamp } = event.nativeEvent;
35
+ return { identifier, timestamp };
36
+ }
37
+ /** Same native event: the responder negotiation hands one event to every listener. */
38
+ function isSameEvent(key, event) {
39
+ return (key != null &&
40
+ key.identifier === event.nativeEvent.identifier &&
41
+ key.timestamp === event.nativeEvent.timestamp);
42
+ }
43
+ let textInputTouch = null;
44
+ let claimedTouch = null;
45
+ /**
46
+ * `onStartShouldSetResponder` for a native text-field surface: decline the
47
+ * responder (the native field keeps every touch phase) but tag the touch so an
48
+ * enclosing tap-away boundary leaves it alone. The package `TextInput` uses
49
+ * this through {@link useTextInputSurfaceResponder}.
50
+ */
51
+ export function markTextInputTouchStart(event) {
52
+ textInputTouch = keyOf(event);
53
+ return false;
54
+ }
55
+ /**
56
+ * Hide the software keyboard for whatever is focused. Prefers the registered
57
+ * native field's own blur handle (window-independent), then falls back to
58
+ * `KeyboardController.dismiss()`, which resigns at the IME level and covers RN
59
+ * or third-party inputs. No-op on web, which has no software keyboard to hide.
60
+ */
61
+ export function dismissKeyboard() {
62
+ if (Platform.OS === "web")
63
+ return;
64
+ if (!dismissKeyboardFocusedInput()) {
65
+ void KeyboardController.dismiss();
66
+ }
67
+ }
68
+ function isSameTouch(tap, event) {
69
+ return tap != null && tap.identifier === event.nativeEvent.identifier;
70
+ }
71
+ function movedPastSlop(tap, event) {
72
+ return (Math.abs(event.nativeEvent.pageX - tap.pageX) > MOVE_SLOP ||
73
+ Math.abs(event.nativeEvent.pageY - tap.pageY) > MOVE_SLOP);
74
+ }
75
+ /**
76
+ * View props for the painted surface around a native (`@expo/ui`) text field.
77
+ *
78
+ * Two jobs. It tags every touch that begins on the surface so the tap-away
79
+ * boundary leaves it alone (see {@link markTextInputTouchStart}). And it
80
+ * focuses the field on release of a tap that no child claimed: the SwiftUI
81
+ * `TextField` only hit-tests its text line, so taps on the box's vertical
82
+ * padding never reach it, and a tap on the field's `hitSlop` never can. Calling
83
+ * `focus` is idempotent when the native field already took the tap, and the eye
84
+ * toggle is a `Pressable` that claims the responder first, so it never fires
85
+ * for that.
86
+ */
87
+ export function useTextInputSurfaceResponder(focus) {
88
+ const focusRef = useRef(focus);
89
+ focusRef.current = focus;
90
+ const tap = useRef(null);
91
+ return useMemo(() => ({
92
+ onStartShouldSetResponder: (event) => {
93
+ markTextInputTouchStart(event);
94
+ tap.current = { ...keyOf(event), pageX: event.nativeEvent.pageX, pageY: event.nativeEvent.pageY };
95
+ return false;
96
+ },
97
+ onTouchStart: (event) => {
98
+ if (tap.current && !isSameEvent(tap.current, event))
99
+ tap.current = null;
100
+ },
101
+ onTouchMove: (event) => {
102
+ if (isSameTouch(tap.current, event) && movedPastSlop(tap.current, event))
103
+ tap.current = null;
104
+ },
105
+ onTouchEnd: (event) => {
106
+ if (!isSameTouch(tap.current, event))
107
+ return;
108
+ tap.current = null;
109
+ focusRef.current();
110
+ },
111
+ onTouchCancel: () => {
112
+ tap.current = null;
113
+ },
114
+ }), []);
115
+ }
116
+ /**
117
+ * View props for a tap-away keyboard-dismiss boundary. Spread onto the wrapper
118
+ * `View` that encloses the screen (or a form). Nesting is safe: the innermost
119
+ * boundary owns each touch, so the keyboard is dismissed once. Returns `{}` on
120
+ * web.
121
+ */
122
+ export function useKeyboardDismissResponder() {
123
+ const isVisible = useKeyboardState((state) => state.isVisible);
124
+ const isVisibleRef = useRef(isVisible);
125
+ isVisibleRef.current = isVisible;
126
+ const pending = useRef(null);
127
+ return useMemo(() => {
128
+ if (Platform.OS === "web")
129
+ return {};
130
+ return {
131
+ onStartShouldSetResponder: (event) => {
132
+ pending.current = null;
133
+ // `hasKeyboardFocusedInput` covers the window where a field has focus but
134
+ // keyboard-controller has not reported the keyboard yet (or a hardware
135
+ // keyboard is attached and there is no software keyboard to observe).
136
+ if (!isVisibleRef.current && !hasKeyboardFocusedInput())
137
+ return false;
138
+ if (isSameEvent(textInputTouch, event))
139
+ return false;
140
+ // An inner boundary already runs this touch (bubble order is inner-first).
141
+ if (isSameEvent(claimedTouch, event))
142
+ return false;
143
+ claimedTouch = keyOf(event);
144
+ pending.current = { ...claimedTouch, pageX: event.nativeEvent.pageX, pageY: event.nativeEvent.pageY };
145
+ return false;
146
+ },
147
+ onTouchStart: (event) => {
148
+ // Bubbling touch events run after the negotiation for the same native
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;
153
+ },
154
+ onTouchMove: (event) => {
155
+ if (isSameTouch(pending.current, event) && movedPastSlop(pending.current, event)) {
156
+ pending.current = null;
157
+ }
158
+ },
159
+ onTouchEnd: (event) => {
160
+ if (!isSameTouch(pending.current, event))
161
+ return;
162
+ pending.current = null;
163
+ dismissKeyboard();
164
+ },
165
+ onTouchCancel: () => {
166
+ pending.current = null;
167
+ },
168
+ };
169
+ }, []);
170
+ }
@@ -1,6 +1,11 @@
1
- import type { GestureResponderEvent } from "react-native";
2
- import type { FocusedInputLayoutChangedEvent } from "react-native-keyboard-controller";
3
- export type KeyboardFocusedInputLayout = FocusedInputLayoutChangedEvent["layout"];
1
+ /**
2
+ * Registry of the currently focused native (`@expo/ui`) text field.
3
+ *
4
+ * React Native's `TextInputState` never learns about SwiftUI / Compose fields,
5
+ * so `Keyboard.dismiss()` cannot blur them. The package `TextInput` registers a
6
+ * blur handle here on focus; tap-away dismissal (`keyboardDismiss.ts`) and the
7
+ * `BottomSheet` overlay resign the field through it.
8
+ */
4
9
  export type KeyboardFocusedInputToken = object;
5
10
  /**
6
11
  * Subscribe to focus presence changes. Pairs with {@link hasKeyboardFocusedInput}
@@ -9,16 +14,8 @@ export type KeyboardFocusedInputToken = object;
9
14
  * keyboards raised inside a native sheet's isolated window.
10
15
  */
11
16
  export declare function subscribeKeyboardFocus(listener: () => void): () => void;
12
- export declare function setKeyboardFocusedInputLayout(token: KeyboardFocusedInputToken, layout: KeyboardFocusedInputLayout, blur?: () => void): void;
13
- export declare function clearKeyboardFocusedInputLayout(token: KeyboardFocusedInputToken): void;
14
- export declare function getKeyboardFocusedInputLayout(): {
15
- x: number;
16
- y: number;
17
- width: number;
18
- height: number;
19
- absoluteX: number;
20
- absoluteY: number;
21
- } | undefined;
17
+ export declare function setKeyboardFocusedInput(token: KeyboardFocusedInputToken, blur: () => void): void;
18
+ export declare function clearKeyboardFocusedInput(token: KeyboardFocusedInputToken): void;
22
19
  /** Whether any native field currently holds focus. Stable boolean for snapshots. */
23
20
  export declare function hasKeyboardFocusedInput(): boolean;
24
21
  /**
@@ -26,4 +23,3 @@ export declare function hasKeyboardFocusedInput(): boolean;
26
23
  * `true` if a field was focused and a blur handle was available.
27
24
  */
28
25
  export declare function dismissKeyboardFocusedInput(): boolean;
29
- export declare function isTouchInsideKeyboardFocusedInput(event: GestureResponderEvent, nativeLayout?: KeyboardFocusedInputLayout | null): boolean;
@@ -1,4 +1,3 @@
1
- const FOCUSED_INPUT_TOUCH_SLOP = 16;
2
1
  let focusedInput = null;
3
2
  const listeners = new Set();
4
3
  function emit() {
@@ -16,23 +15,22 @@ export function subscribeKeyboardFocus(listener) {
16
15
  listeners.delete(listener);
17
16
  };
18
17
  }
19
- export function setKeyboardFocusedInputLayout(token, layout, blur) {
18
+ export function setKeyboardFocusedInput(token, blur) {
20
19
  const wasPresent = focusedInput != null;
21
- focusedInput = { token, layout, blur };
22
- // Only notify subscribers on a presence transition; re-measures (scroll,
23
- // layout) update the layout silently so the overlay doesn't churn.
20
+ focusedInput = { token, blur };
21
+ // Only notify subscribers on a presence transition; a focus handoff between
22
+ // two fields keeps presence true and stays silent.
24
23
  if (!wasPresent)
25
24
  emit();
26
25
  }
27
- export function clearKeyboardFocusedInputLayout(token) {
26
+ export function clearKeyboardFocusedInput(token) {
27
+ // Token-guarded: when focus hops A -> B, B's focus can land before A's blur,
28
+ // and A's late clear must not wipe B's registration.
28
29
  if (focusedInput?.token === token) {
29
30
  focusedInput = null;
30
31
  emit();
31
32
  }
32
33
  }
33
- export function getKeyboardFocusedInputLayout() {
34
- return focusedInput?.layout;
35
- }
36
34
  /** Whether any native field currently holds focus. Stable boolean for snapshots. */
37
35
  export function hasKeyboardFocusedInput() {
38
36
  return focusedInput != null;
@@ -42,23 +40,9 @@ export function hasKeyboardFocusedInput() {
42
40
  * `true` if a field was focused and a blur handle was available.
43
41
  */
44
42
  export function dismissKeyboardFocusedInput() {
45
- if (focusedInput?.blur) {
43
+ if (focusedInput) {
46
44
  focusedInput.blur();
47
45
  return true;
48
46
  }
49
47
  return false;
50
48
  }
51
- function isTouchInsideLayout(event, layout) {
52
- if (!layout)
53
- return false;
54
- const { pageX, pageY } = event.nativeEvent;
55
- const left = layout.absoluteX - FOCUSED_INPUT_TOUCH_SLOP;
56
- const right = layout.absoluteX + layout.width + FOCUSED_INPUT_TOUCH_SLOP;
57
- const top = layout.absoluteY - FOCUSED_INPUT_TOUCH_SLOP;
58
- const bottom = layout.absoluteY + layout.height + FOCUSED_INPUT_TOUCH_SLOP;
59
- return pageX >= left && pageX <= right && pageY >= top && pageY <= bottom;
60
- }
61
- export function isTouchInsideKeyboardFocusedInput(event, nativeLayout) {
62
- return (isTouchInsideLayout(event, getKeyboardFocusedInputLayout()) ||
63
- isTouchInsideLayout(event, nativeLayout));
64
- }
@@ -1,15 +1,19 @@
1
1
  /**
2
2
  * Spacing Constants
3
3
  *
4
- * A consistent spacing scale based on an 8px base unit.
5
- * Use these constants throughout the app for consistent spacing.
4
+ * A consistent spacing scale based on an 8px base unit, plus a small set of
5
+ * density tokens that components reach for when composing rows, surfaces, and
6
+ * screens. Tune the density tokens to shift the whole library's feel without
7
+ * touching the base scale.
6
8
  */
7
9
  export declare const spacing: {
8
10
  readonly base: 8;
9
11
  readonly xxs: 2;
10
12
  readonly xs: 4;
11
13
  readonly sm: 8;
14
+ readonly smd: 12;
12
15
  readonly md: 16;
16
+ readonly mdl: 20;
13
17
  readonly lg: 24;
14
18
  readonly xl: 32;
15
19
  readonly xxl: 48;
@@ -17,12 +21,18 @@ export declare const spacing: {
17
21
  readonly gutter: 16;
18
22
  readonly gutterVertical: 24;
19
23
  readonly screenPadding: 16;
24
+ readonly sectionSpacing: 24;
25
+ readonly rowPaddingY: 10;
26
+ readonly rowPaddingX: 16;
27
+ readonly rowGap: 12;
28
+ readonly rowMinHeight: 40;
29
+ readonly formRowMinHeight: 32;
30
+ readonly cardPadding: 16;
31
+ readonly dialogPadding: 20;
32
+ readonly touchTarget: 44;
20
33
  readonly buttonPadding: 10;
21
34
  readonly inputPadding: 10;
22
- readonly cardPadding: 16;
23
- readonly sectionSpacing: 32;
24
35
  readonly listItemSpacing: 8;
25
- readonly touchTarget: 44;
26
36
  readonly radiusNone: 0;
27
37
  readonly radiusXs: 4;
28
38
  readonly radiusSm: 8;
@@ -37,5 +47,5 @@ export declare const spacing: {
37
47
  readonly iconLg: 32;
38
48
  readonly iconXl: 48;
39
49
  };
40
- export declare const base: 8, xxs: 2, xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48, xxxl: 64, gutter: 16, gutterVertical: 24, screenPadding: 16, buttonPadding: 10, inputPadding: 10, cardPadding: 16, sectionSpacing: 32, listItemSpacing: 8, touchTarget: 44, radiusNone: 0, radiusXs: 4, radiusSm: 8, radiusMd: 12, radiusLg: 14, radiusXl: 18, radius2xl: 24, radiusFull: 9999, iconXs: 12, iconSm: 16, iconMd: 24, iconLg: 32, iconXl: 48;
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: 12, radiusLg: 14, radiusXl: 18, radius2xl: 24, radiusFull: 9999, iconXs: 12, iconSm: 16, iconMd: 24, iconLg: 32, iconXl: 48;
41
51
  export declare const space: (multiplier: number) => number;
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Spacing Constants
3
3
  *
4
- * A consistent spacing scale based on an 8px base unit.
5
- * Use these constants throughout the app for consistent spacing.
4
+ * A consistent spacing scale based on an 8px base unit, plus a small set of
5
+ * density tokens that components reach for when composing rows, surfaces, and
6
+ * screens. Tune the density tokens to shift the whole library's feel without
7
+ * touching the base scale.
6
8
  */
7
9
  export const spacing = {
8
10
  // Base unit: 8px
@@ -11,7 +13,9 @@ export const spacing = {
11
13
  xxs: 2, // 2px - Minimal spacing
12
14
  xs: 4, // 4px - Extra small
13
15
  sm: 8, // 8px - Small (1 unit)
16
+ smd: 12, // 12px - Between small and medium (1.5 units); compact gaps and row padding
14
17
  md: 16, // 16px - Medium (2 units)
18
+ mdl: 20, // 20px - Between medium and large (2.5 units); dialog and sheet padding
15
19
  lg: 24, // 24px - Large (3 units)
16
20
  xl: 32, // 32px - Extra large (4 units)
17
21
  xxl: 48, // 48px - 2x extra large (6 units)
@@ -20,13 +24,20 @@ export const spacing = {
20
24
  gutter: 16, // Standard horizontal gutter
21
25
  gutterVertical: 24, // Standard vertical spacing
22
26
  screenPadding: 16, // Default screen edge padding
27
+ sectionSpacing: 24, // Space between major sections / grouped lists
28
+ // Density tokens — rows, surfaces, overlays
29
+ rowPaddingY: 10, // Vertical padding for list / settings rows
30
+ rowPaddingX: 16, // Horizontal padding for list / settings rows
31
+ rowGap: 12, // Gap between media, content, and actions in a row
32
+ rowMinHeight: 40, // Visual min height for rows on pointer surfaces (web)
33
+ formRowMinHeight: 32, // Visual min height for checkbox / radio rows on web
34
+ cardPadding: 16, // Default card / stat card padding
35
+ dialogPadding: 20, // Default dialog / alert dialog padding
36
+ touchTarget: 44, // Minimum comfortable native touch target
23
37
  // Component-specific
24
38
  buttonPadding: 10, // Default button padding
25
39
  inputPadding: 10, // Default input padding
26
- cardPadding: 16, // Default card padding
27
- sectionSpacing: 32, // Space between major sections
28
40
  listItemSpacing: 8, // Space between list items
29
- touchTarget: 44, // Minimum comfortable native touch target
30
41
  // Border radius — shadcn-inspired scale (radiusMd = 12px default)
31
42
  radiusNone: 0,
32
43
  radiusXs: 4,
@@ -44,6 +55,6 @@ export const spacing = {
44
55
  iconXl: 48,
45
56
  };
46
57
  // Export individual constants for convenience
47
- export const { base, xxs, xs, sm, md, lg, xl, xxl, xxxl, gutter, gutterVertical, screenPadding, buttonPadding, inputPadding, cardPadding, sectionSpacing, listItemSpacing, touchTarget, radiusNone, radiusXs, radiusSm, radiusMd, radiusLg, radiusXl, radius2xl, radiusFull, iconXs, iconSm, iconMd, iconLg, iconXl, } = spacing;
58
+ export const { base, xxs, xs, sm, smd, md, mdl, lg, xl, xxl, xxxl, gutter, gutterVertical, screenPadding, sectionSpacing, rowPaddingY, rowPaddingX, rowGap, rowMinHeight, formRowMinHeight, cardPadding, dialogPadding, touchTarget, buttonPadding, inputPadding, listItemSpacing, radiusNone, radiusXs, radiusSm, radiusMd, radiusLg, radiusXl, radius2xl, radiusFull, iconXs, iconSm, iconMd, iconLg, iconXl, } = spacing;
48
59
  // Helper function to multiply base spacing
49
60
  export const space = (multiplier) => spacing.base * multiplier;