@myazahq/kyc-sdk-react-native 2.3.0 → 2.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myazahq/kyc-sdk-react-native",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Myaza KYC SDK for React Native (Expo) — ID verification, liveness detection, and document capture",
5
5
  "author": "Flitstack Technologies Inc.",
6
6
  "repository": {
@@ -61,7 +61,6 @@
61
61
  "dependencies": {
62
62
  "@expo-google-fonts/karla": "^0.4.2",
63
63
  "@expo-google-fonts/space-grotesk": "^0.4.1",
64
- "@expo/ui": "~56.0.16",
65
64
  "@expo/vector-icons": "^14.0.4",
66
65
  "country-flag-icons": "^1.6.17",
67
66
  "expo-application": "~56.0.3",
@@ -0,0 +1,137 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Image, View } from 'react-native';
3
+ import { SvgXml } from 'react-native-svg';
4
+
5
+ import { radius, spacing } from '../config/theme';
6
+ import { useTheme } from './runtime';
7
+ import { MyazaText } from './Typography';
8
+
9
+ /** Whether a logo URI points at an SVG (extension check, query-safe). */
10
+ export function isSvgUri(uri: string): boolean {
11
+ const path = uri.split('?')[0] ?? uri;
12
+ return path.toLowerCase().endsWith('.svg');
13
+ }
14
+
15
+ // Deliberately NOT on a Liquid Glass capsule, though it would balance the
16
+ // theme/close capsule opposite it. Glass at capsule scale is the material for
17
+ // controls — something that floats and responds to touch. The brand is a label,
18
+ // so a pill around it promises a tap that never does anything. The header block
19
+ // behind it is glass, which is the correct use: a surface, not a control.
20
+ //
21
+ // Persistent header brand — circular logo avatar + company name. A broken/missing
22
+ // logo image collapses the whole brand bar (mirrors the web SDK's `onError` and
23
+ // Flutter's `errorBuilder`), so the header never shows a broken-image box.
24
+ export function BrandBar({
25
+ logoUri,
26
+ companyName,
27
+ }: {
28
+ logoUri: string;
29
+ companyName: string;
30
+ }): React.ReactElement | null {
31
+ const { colors } = useTheme();
32
+ const [broken, setBroken] = useState(false);
33
+ const svg = isSvgUri(logoUri);
34
+ // SVG logos render through SvgXml with SELF-FETCHED markup — the exact
35
+ // pipeline the country flags use, which is proven on-device. SvgUri's own
36
+ // fetch/sizing was flaky here (the favicon rendered at intrinsic size
37
+ // instead of filling the chip).
38
+ const [xml, setXml] = useState<string | null>(null);
39
+ useEffect(() => {
40
+ if (!svg) return;
41
+ let cancelled = false;
42
+ fetch(logoUri)
43
+ .then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
44
+ .then((text) => {
45
+ if (!cancelled) setXml(text);
46
+ })
47
+ .catch(() => {
48
+ if (!cancelled) setBroken(true);
49
+ });
50
+ return () => {
51
+ cancelled = true;
52
+ };
53
+ }, [logoUri, svg]);
54
+ if (broken) return null;
55
+ return (
56
+ <>
57
+ {/* TWO layers, deliberately.
58
+
59
+ A shadow and `overflow: 'hidden'` CANNOT live on the same view on iOS:
60
+ overflow-hidden compiles to `masksToBounds`, which clips the shadow
61
+ away with everything else outside the bounds. Android draws `elevation`
62
+ outside the view, so it survived there — which is why the chip lost its
63
+ ring on iPhone only.
64
+
65
+ So the outer view owns the border and shadow and does NOT clip, and the
66
+ inner one clips the logo. Same split Flutter gets for free by putting
67
+ the border on the Container's decoration and the crop in a ClipOval.
68
+
69
+ The white plate is load-bearing, not decoration: brand logos are
70
+ routinely dark artwork on a transparent background (the import picks up
71
+ favicons), which would vanish against a dark header. */}
72
+ <View
73
+ style={{
74
+ width: 28,
75
+ height: 28,
76
+ borderRadius: radius.full,
77
+ backgroundColor: '#FFFFFF',
78
+ // Light ring + soft shadow — the same treatment as the Flutter SDK's
79
+ // brand chip (black 5% ring, black 6% blur-4 y-1 shadow) and the web
80
+ // SDK's ring-1 ring-black/5.
81
+ borderWidth: 1,
82
+ borderColor: 'rgba(0,0,0,0.05)',
83
+ shadowColor: '#000000',
84
+ shadowOpacity: 0.06,
85
+ shadowRadius: 4,
86
+ shadowOffset: { width: 0, height: 1 },
87
+ elevation: 1,
88
+ marginRight: spacing.sm,
89
+ alignItems: 'center',
90
+ justifyContent: 'center',
91
+ }}
92
+ >
93
+ <View
94
+ style={{
95
+ // Fills the parent's CONTENT box (inside its 1px border) and is the
96
+ // only thing that clips, so the logo reaches the ring on every side.
97
+ alignSelf: 'stretch',
98
+ flex: 1,
99
+ borderRadius: radius.full,
100
+ overflow: 'hidden',
101
+ }}
102
+ >
103
+ {/* Fill + cover-crop, matching Flutter's SizedBox.expand + BoxFit.cover.
104
+
105
+ Sized in PERCENTAGES, not the literal 26 this used to hardcode: that
106
+ number was "28 minus the 1px border on each side", so it silently
107
+ went wrong the moment either value changed, and it left the logo a
108
+ pixel short of the ring. The clipping parent now owns the geometry.
109
+
110
+ SVG logos are common here because the brand import picks up site
111
+ favicons, and <Image> cannot decode SVG at all — hence the fetched
112
+ markup through SvgXml. `slice` is the SVG spelling of cover-crop. */}
113
+ {svg ? (
114
+ xml ? (
115
+ <SvgXml xml={xml} width="100%" height="100%" preserveAspectRatio="xMidYMid slice" />
116
+ ) : null
117
+ ) : (
118
+ <Image
119
+ source={{ uri: logoUri }}
120
+ style={{ width: '100%', height: '100%' }}
121
+ resizeMode="cover"
122
+ onError={() => setBroken(true)}
123
+ />
124
+ )}
125
+ </View>
126
+ </View>
127
+ <MyazaText
128
+ variant="heading3"
129
+ numberOfLines={1}
130
+ color={colors.textDark}
131
+ style={{ flexShrink: 1, fontSize: 14 }}
132
+ >
133
+ {companyName}
134
+ </MyazaText>
135
+ </>
136
+ );
137
+ }
@@ -2,19 +2,19 @@ import React, { useEffect, useMemo, useState } from 'react';
2
2
  import {
3
3
  FlatList,
4
4
  Keyboard,
5
- Modal,
6
5
  Platform,
7
6
  Pressable,
8
7
  useWindowDimensions,
9
8
  View,
10
9
  } from 'react-native';
11
10
 
12
- import { radius, spacing } from '../config/theme';
11
+ import { spacing } from '../config/theme';
13
12
  import { useTheme } from './runtime';
14
13
  import { MyazaText } from './Typography';
15
14
  import { MyazaInput } from './MyazaInput';
16
15
  import { Icon } from './Icon';
17
16
  import { CountryFlag } from './CountryFlag';
17
+ import { FloatingSheet } from './glass/FloatingSheet';
18
18
 
19
19
  // ---------------------------------------------------------------------------
20
20
  // THE country sheet — the phone field's dial-code picker, generalised.
@@ -98,19 +98,14 @@ export function DialCodePicker({
98
98
  };
99
99
 
100
100
  return (
101
- <Modal visible={visible} animationType="slide" transparent onRequestClose={close}>
102
- <Pressable style={{ flex: 1, backgroundColor: '#00000066' }} onPress={close} />
103
- <View
104
- style={{
105
- maxHeight,
106
- marginBottom: keyboard,
107
- backgroundColor: colors.background,
108
- borderTopLeftRadius: radius.lg,
109
- borderTopRightRadius: radius.lg,
110
- paddingTop: spacing.md,
111
- paddingBottom: spacing.sm,
112
- }}
113
- >
101
+ <FloatingSheet
102
+ visible={visible}
103
+ onClose={close}
104
+ maxHeight={maxHeight}
105
+ // Ride above a raised keyboard — this sheet's whole job is a search box.
106
+ bottomOffset={keyboard}
107
+ closeLabel="Close country picker"
108
+ >
114
109
  <View style={{ paddingHorizontal: spacing.md, paddingBottom: spacing.sm }}>
115
110
  <MyazaInput
116
111
  value={query}
@@ -141,8 +136,7 @@ export function DialCodePicker({
141
136
  />
142
137
  )}
143
138
  />
144
- </View>
145
- </Modal>
139
+ </FloatingSheet>
146
140
  );
147
141
  }
148
142
 
@@ -1,10 +1,14 @@
1
1
  import React from 'react';
2
- import { Image, Pressable, View } from 'react-native';
2
+ import { Image, Pressable, View, type ViewStyle } from 'react-native';
3
3
 
4
4
  import { useTheme } from './runtime';
5
5
  import { Icon } from './Icon';
6
6
  import { MyazaText } from './Typography';
7
7
  import { radius, spacing } from '../config/theme';
8
+ import { CHROME_SCRIM, ChromeGlass } from './glass/ChromeGlass';
9
+
10
+ /** The chip's flat backing, and still the fallback off iOS 26. */
11
+ const CHIP_SCRIM = CHROME_SCRIM;
8
12
 
9
13
  // ─── One captured side, and its enlarged view ─────────────────────────────────
10
14
  //
@@ -67,7 +71,7 @@ export function DocumentReviewThumb({
67
71
  {/* Enlarging is a tap on the image, which nothing announces — so the
68
72
  image says so itself. */}
69
73
  <View style={{ position: 'absolute', right: spacing.md, bottom: spacing.md }} pointerEvents="none">
70
- <Chip icon>
74
+ <Chip icon glass>
71
75
  <Icon name="maximize" size={18} color="#FFFFFF" />
72
76
  </Chip>
73
77
  </View>
@@ -116,20 +120,36 @@ export function Chip({
116
120
  children,
117
121
  /** Icon chips are square and need room around the glyph, not text padding. */
118
122
  icon = false,
123
+ /**
124
+ * Render on Liquid Glass instead of the flat scrim.
125
+ *
126
+ * Only for a chip that ANNOUNCES AN ACTION. It looks like an exception to the
127
+ * "glass is for controls" rule, since the chip itself is `pointerEvents:
128
+ * none` — but the photo behind it is the button, and the chip sits inside
129
+ * that target, so a tap on it really does fire. The promise is kept.
130
+ *
131
+ * A chip that only labels something (FRONT / BACK) stays flat: glass would
132
+ * offer a tap it cannot honour.
133
+ */
134
+ glass = false,
119
135
  }: {
120
136
  children: React.ReactNode;
121
137
  icon?: boolean;
138
+ glass?: boolean;
122
139
  }): React.ReactElement {
123
- return (
124
- <View
125
- style={{
126
- paddingHorizontal: icon ? 8 : 10,
127
- paddingVertical: icon ? 8 : 5,
128
- borderRadius: radius.full,
129
- backgroundColor: 'rgba(0,0,0,0.55)',
130
- }}
131
- >
132
- {children}
133
- </View>
134
- );
140
+ const shape: ViewStyle = {
141
+ paddingHorizontal: icon ? 8 : 10,
142
+ paddingVertical: icon ? 8 : 5,
143
+ borderRadius: radius.full,
144
+ };
145
+ // Not `interactive`: the chip never receives the touch (the image below it
146
+ // does), so asking the glass to react to one would be asking for nothing.
147
+ if (glass) {
148
+ return (
149
+ <ChromeGlass scrim={CHIP_SCRIM} style={shape}>
150
+ {children}
151
+ </ChromeGlass>
152
+ );
153
+ }
154
+ return <View style={[shape, { backgroundColor: CHIP_SCRIM }]}>{children}</View>;
135
155
  }
@@ -17,6 +17,7 @@ import {
17
17
  CreditCard,
18
18
  Fingerprint,
19
19
  FileText,
20
+ FlaskConical,
20
21
  IdCard,
21
22
  Image as ImageIcon,
22
23
  Landmark,
@@ -68,6 +69,7 @@ export type IconName =
68
69
  | 'check'
69
70
  | 'lock'
70
71
  | 'alert'
72
+ | 'flask'
71
73
  | 'refresh'
72
74
  | 'maximize'
73
75
  | 'calendar'
@@ -128,6 +130,9 @@ const ICONS: Record<IconName, LucideIcon> = {
128
130
  check: Check,
129
131
  lock: Lock,
130
132
  alert: CircleAlert,
133
+ // The environment banner's mark, shared with the web SDK (same lucide glyph)
134
+ // and mirrored by Flutter's Icons.science_outlined.
135
+ flask: FlaskConical,
131
136
  refresh: RefreshCw,
132
137
  maximize: Maximize2,
133
138
  calendar: Calendar,
@@ -1,21 +1,21 @@
1
- import React, { useEffect, useState } from "react";
2
- import { Image, Platform, ScrollView, StatusBar, View } from "react-native";
3
- import { SvgXml } from "react-native-svg";
1
+ import React from "react";
2
+ import { Platform, ScrollView, StatusBar, View } from "react-native";
4
3
  import { StatusBarController } from "./StatusBarController";
5
4
  import { initialWindowMetrics } from "react-native-safe-area-context";
6
5
 
7
- import { headerSurface, radius, spacing } from "../config/theme";
6
+ import { headerSurface, spacing } from "../config/theme";
8
7
  import { useKeyboardInset } from "../lib/use-keyboard-inset";
9
8
  import type { SupportedCountry } from "../types/config";
10
9
  import { useKycConfig, useTheme } from "./runtime";
11
10
  import { useBranding } from "./useBranding";
12
- import { MyazaText } from "./Typography";
11
+ import { BrandBar } from "./BrandBar";
13
12
  import { PoweredBy } from "./PoweredBy";
14
13
  import { StepHeader } from "./StepHeader";
15
14
  import { StepIndicator } from "./StepIndicator";
16
15
  import { ProgressBar } from "./ProgressBar";
17
16
  import { GlassIconButton } from "./GlassIconButton";
18
17
  import { GlassSurface } from "./glass/GlassSurface";
18
+ import { SandboxBanner, useSandboxBannerVisible } from "./SandboxBanner";
19
19
  import { GlassGroup } from "./glass/GlassGroup";
20
20
  import { FlashOverlay } from "./FlashOverlay";
21
21
 
@@ -84,13 +84,31 @@ export function KycSheet({
84
84
  const keyboardInset = useKeyboardInset();
85
85
 
86
86
  const tint = headerSurface(colors, mode);
87
+ // Decides who absorbs the status-bar inset: the banner sits above the header,
88
+ // so when it renders it is the topmost element and takes it instead.
89
+ const bannerVisible = useSandboxBannerVisible();
87
90
  const hasProgress = progress != null && stepCount != null;
88
- // The bar REPLACES the step row rather than joining it: two progress
89
- // indicators on one header would be noise, and the point of the bar is that
90
- // it costs no height.
91
- const asBar = config.progressStyle === "bar";
92
- const showIndicator = hasProgress && !asBar;
93
- const showBar = hasProgress && asBar;
91
+ // The three styles are mutually exclusive: the bar REPLACES the step row
92
+ // rather than joining it (two progress indicators on one header would be
93
+ // noise, and the point of the bar is that it costs no height), and 'none'
94
+ // drops both. Switching on the resolved style rather than negating flags
95
+ // keeps a future fourth style from silently falling into the 'steps' branch.
96
+ const progressStyle = config.progressStyle ?? "steps";
97
+ const showIndicator = hasProgress && progressStyle === "steps";
98
+ const showBar = hasProgress && progressStyle === "bar";
99
+
100
+ // The header's bottom padding is the GAP to whatever sits under the brand
101
+ // row — the title block or the step row. With neither (a step that owns its
102
+ // own title, and progress off or drawn as the edge bar), that padding is
103
+ // space to a row that never renders, and the brand + controls read as
104
+ // sitting high in the band rather than centred on one line. Fall back to the
105
+ // row's own top padding so the two edges match.
106
+ //
107
+ // The bar is exempt: it is absolutely positioned ON the bottom edge, so the
108
+ // wider padding is what keeps it clear of the controls.
109
+ const hasRowBelowBrand = !!(title || onBack) || showIndicator;
110
+ const headerPaddingBottom =
111
+ hasRowBelowBrand || showBar ? spacing.md : spacing.sm;
94
112
  const showBrand = !hideBrand && !!logoUri;
95
113
 
96
114
  // On iOS the modal is a pageSheet by default (sits below the status bar), but
@@ -143,6 +161,11 @@ export function KycSheet({
143
161
  />
144
162
  ) : null}
145
163
  <View style={{ flex: 1 }}>
164
+ {/* Above the header block (and outside the immersive branch) so a
165
+ capture step that hides the chrome still says it is not live. It is
166
+ then the topmost element, so it — not the header — absorbs the
167
+ status-bar inset. */}
168
+ <SandboxBanner topInset={topInset} />
146
169
  {/* Header block (glass on iOS 26, tinted surface otherwise) — dropped
147
170
  entirely when the step owns the display. */}
148
171
  {immersive ? null : (
@@ -154,7 +177,7 @@ export function KycSheet({
154
177
  // border would double it.
155
178
  borderBottomWidth: showBar ? 0 : 1,
156
179
  borderBottomColor: colors.border,
157
- paddingBottom: spacing.md,
180
+ paddingBottom: headerPaddingBottom,
158
181
  }}
159
182
  >
160
183
  {/* Row 1 — brand + controls */}
@@ -163,7 +186,10 @@ export function KycSheet({
163
186
  flexDirection: "row",
164
187
  alignItems: "center",
165
188
  paddingHorizontal: spacing.md,
166
- paddingTop: topInset + spacing.sm,
189
+ // The banner, when shown, already cleared the status bar above
190
+ // us — padding for it again would leave a gap the width of the
191
+ // notch between the strip and the brand row.
192
+ paddingTop: (bannerVisible ? 0 : topInset) + spacing.sm,
167
193
  }}
168
194
  >
169
195
  <View
@@ -307,130 +333,3 @@ export function KycSheet({
307
333
  </View>
308
334
  );
309
335
  }
310
-
311
- /** Whether a logo URI points at an SVG (extension check, query-safe). */
312
- function isSvgUri(uri: string): boolean {
313
- const path = uri.split("?")[0] ?? uri;
314
- return path.toLowerCase().endsWith(".svg");
315
- }
316
-
317
- // Persistent header brand — circular logo avatar + company name. A broken/missing
318
- // logo image collapses the whole brand bar (mirrors the web SDK's `onError` and
319
- // Flutter's `errorBuilder`), so the header never shows a broken-image box.
320
- function BrandBar({
321
- logoUri,
322
- companyName,
323
- }: {
324
- logoUri: string;
325
- companyName: string;
326
- }): React.ReactElement | null {
327
- const { colors } = useTheme();
328
- const [broken, setBroken] = useState(false);
329
- const svg = isSvgUri(logoUri);
330
- // SVG logos render through SvgXml with SELF-FETCHED markup — the exact
331
- // pipeline the country flags use, which is proven on-device. SvgUri's own
332
- // fetch/sizing was flaky here (the favicon rendered at intrinsic size
333
- // instead of filling the chip).
334
- const [xml, setXml] = useState<string | null>(null);
335
- useEffect(() => {
336
- if (!svg) return;
337
- let cancelled = false;
338
- fetch(logoUri)
339
- .then((r) =>
340
- r.ok ? r.text() : Promise.reject(new Error(String(r.status))),
341
- )
342
- .then((text) => {
343
- if (!cancelled) setXml(text);
344
- })
345
- .catch(() => {
346
- if (!cancelled) setBroken(true);
347
- });
348
- return () => {
349
- cancelled = true;
350
- };
351
- }, [logoUri, svg]);
352
- if (broken) return null;
353
- return (
354
- <>
355
- {/* TWO layers, deliberately.
356
-
357
- A shadow and `overflow: 'hidden'` CANNOT live on the same view on iOS:
358
- overflow-hidden compiles to `masksToBounds`, which clips the shadow
359
- away with everything else outside the bounds. Android draws `elevation`
360
- outside the view, so it survived there — which is why the chip lost its
361
- ring on iPhone only.
362
-
363
- So the outer view owns the border and shadow and does NOT clip, and the
364
- inner one clips the logo. Same split Flutter gets for free by putting
365
- the border on the Container's decoration and the crop in a ClipOval. */}
366
- <View
367
- style={{
368
- width: 28,
369
- height: 28,
370
- borderRadius: radius.full,
371
- backgroundColor: "#FFFFFF",
372
- // Light ring + soft shadow — the same treatment as the Flutter SDK's
373
- // brand chip (black 5% ring, black 6% blur-4 y-1 shadow) and the web
374
- // SDK's ring-1 ring-black/5.
375
- borderWidth: 1,
376
- borderColor: "rgba(0,0,0,0.05)",
377
- shadowColor: "#000000",
378
- shadowOpacity: 0.06,
379
- shadowRadius: 4,
380
- shadowOffset: { width: 0, height: 1 },
381
- elevation: 1,
382
- marginRight: spacing.sm,
383
- alignItems: "center",
384
- justifyContent: "center",
385
- }}
386
- >
387
- <View
388
- style={{
389
- // Fills the parent's CONTENT box (inside its 1px border) and is the
390
- // only thing that clips, so the logo reaches the ring on every side.
391
- alignSelf: "stretch",
392
- flex: 1,
393
- borderRadius: radius.full,
394
- overflow: "hidden",
395
- }}
396
- >
397
- {/* Fill + cover-crop, matching Flutter's SizedBox.expand + BoxFit.cover.
398
-
399
- Sized in PERCENTAGES, not the literal 26 this used to hardcode: that
400
- number was "28 minus the 1px border on each side", so it silently
401
- went wrong the moment either value changed, and it left the logo a
402
- pixel short of the ring. The clipping parent now owns the geometry.
403
-
404
- SVG logos are common here because the brand import picks up site
405
- favicons, and <Image> cannot decode SVG at all — hence the fetched
406
- markup through SvgXml. `slice` is the SVG spelling of cover-crop. */}
407
- {svg ? (
408
- xml ? (
409
- <SvgXml
410
- xml={xml}
411
- width="100%"
412
- height="100%"
413
- preserveAspectRatio="xMidYMid slice"
414
- />
415
- ) : null
416
- ) : (
417
- <Image
418
- source={{ uri: logoUri }}
419
- style={{ width: "100%", height: "100%" }}
420
- resizeMode="cover"
421
- onError={() => setBroken(true)}
422
- />
423
- )}
424
- </View>
425
- </View>
426
- <MyazaText
427
- variant="heading3"
428
- numberOfLines={1}
429
- color={colors.textDark}
430
- style={{ flexShrink: 1, fontSize: 14 }}
431
- >
432
- {companyName}
433
- </MyazaText>
434
- </>
435
- );
436
- }
@@ -1,10 +1,11 @@
1
1
  import React, { useRef } from 'react';
2
- import { Modal, Platform, Pressable, View } from 'react-native';
2
+ import { Platform, Pressable, View } from 'react-native';
3
3
 
4
4
  import { radius, spacing } from '../config/theme';
5
5
  import { Icon, type IconName } from './Icon';
6
6
  import { useTheme } from './runtime';
7
7
  import { MyazaText } from './Typography';
8
+ import { FloatingSheet } from './glass/FloatingSheet';
8
9
 
9
10
  /**
10
11
  * The SDK's own "where is the document?" sheet.
@@ -61,46 +62,16 @@ export function MediaSourceSheet({
61
62
  };
62
63
 
63
64
  return (
64
- <Modal
65
- transparent
65
+ <FloatingSheet
66
66
  visible={open}
67
- animationType="fade"
68
- onRequestClose={onClose}
67
+ onClose={onClose}
69
68
  onDismiss={() => {
70
69
  const run = pendingRef.current;
71
70
  pendingRef.current = null;
72
71
  run?.();
73
72
  }}
74
73
  >
75
- <Pressable
76
- onPress={onClose}
77
- style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' }}
78
- >
79
- {/* Swallow taps on the card so only the backdrop dismisses. */}
80
- <Pressable
81
- onPress={() => undefined}
82
- style={{
83
- backgroundColor: colors.background,
84
- borderTopLeftRadius: radius.xl,
85
- borderTopRightRadius: radius.xl,
86
- padding: spacing.md,
87
- paddingBottom: spacing.xl,
88
- }}
89
- >
90
- {/* Grab handle — the affordance that says "sheet", and therefore
91
- "swipe-away-able", without a close button competing with the
92
- options. */}
93
- <View
94
- style={{
95
- alignSelf: 'center',
96
- width: 36,
97
- height: 4,
98
- borderRadius: 2,
99
- backgroundColor: colors.border,
100
- marginBottom: spacing.md,
101
- }}
102
- />
103
-
74
+ <View style={{ padding: spacing.md, paddingTop: spacing.sm }}>
104
75
  <MyazaText variant="heading3" style={{ textAlign: 'center', marginBottom: spacing.md }}>
105
76
  {title}
106
77
  </MyazaText>
@@ -156,8 +127,7 @@ export function MediaSourceSheet({
156
127
  Cancel
157
128
  </MyazaText>
158
129
  </Pressable>
159
- </Pressable>
160
- </Pressable>
161
- </Modal>
130
+ </View>
131
+ </FloatingSheet>
162
132
  );
163
133
  }
@@ -1,5 +1,5 @@
1
1
  import React, { useState } from 'react';
2
- import { Modal, Pressable, ScrollView, View } from 'react-native';
2
+ import { Pressable, ScrollView, View } from 'react-native';
3
3
 
4
4
  import { radius, spacing } from '../config/theme';
5
5
  import {
@@ -13,6 +13,7 @@ import {
13
13
  import { Icon } from './Icon';
14
14
  import { useTheme } from './runtime';
15
15
  import { MyazaText } from './Typography';
16
+ import { FloatingSheet } from './glass/FloatingSheet';
16
17
 
17
18
  /**
18
19
  * A date field that opens a REAL calendar picker, replacing the "type
@@ -117,22 +118,8 @@ function DatePickerSheet({
117
118
  !!initial && initial.year === cursor.year && initial.month0 === cursor.month0 && initial.day === d;
118
119
 
119
120
  return (
120
- <Modal transparent animationType="fade" onRequestClose={onClose}>
121
- <Pressable
122
- onPress={onClose}
123
- style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' }}
124
- >
125
- {/* Stop the backdrop press from closing when the card itself is tapped. */}
126
- <Pressable
127
- onPress={() => undefined}
128
- style={{
129
- backgroundColor: colors.background,
130
- borderTopLeftRadius: radius.xl,
131
- borderTopRightRadius: radius.xl,
132
- padding: spacing.md,
133
- paddingBottom: spacing.xl,
134
- }}
135
- >
121
+ <FloatingSheet visible onClose={onClose} closeLabel="Close date picker">
122
+ <View style={{ padding: spacing.md, paddingTop: spacing.sm }}>
136
123
  {/* Month header: step, or tap the title to jump years. */}
137
124
  <View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: spacing.sm }}>
138
125
  <Pressable
@@ -233,8 +220,7 @@ function DatePickerSheet({
233
220
  ))}
234
221
  </>
235
222
  )}
236
- </Pressable>
237
- </Pressable>
238
- </Modal>
223
+ </View>
224
+ </FloatingSheet>
239
225
  );
240
226
  }