@entropy-softworks/ui 2026.8.42 → 2026.9.2

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.
@@ -4,6 +4,26 @@ import React from "react";
4
4
  * the active palette via NativeWind classes). Self-manages the entered
5
5
  * digits and fires `onComplete` when `length` is reached; pass a changing
6
6
  * `resetKey` to clear it (e.g. after a wrong PIN).
7
+ *
8
+ * # Sizing
9
+ *
10
+ * The pad fills the width it is given instead of sitting at a fixed 288px. On a
11
+ * modern phone a fixed pad reads as a small control marooned in the middle of a
12
+ * large screen — and this is the primary way into the app for a device-only
13
+ * vault, so it should feel like the screen's purpose rather than a widget on it.
14
+ * Keys grow with the available width and stop at `MAX_KEY` so a tablet does not
15
+ * get comedy-sized buttons.
16
+ *
17
+ * The width comes from the pad's own `onLayout`, not from the viewport. It is
18
+ * usually inside a card inside a padded screen, and every one of those layers
19
+ * takes width the window knows nothing about — sizing off the window overflowed
20
+ * the card it sat in.
21
+ *
22
+ * # Why the keys have a fill
23
+ *
24
+ * They previously had no background at all until the moment of the press, which
25
+ * left three columns of bare digits that did not read as pressable. A resting
26
+ * fill plus a border is what makes a target look like a target.
7
27
  */
8
28
  export interface PinPadProps {
9
29
  length?: number;
@@ -14,7 +34,13 @@ export interface PinPadProps {
14
34
  /** Bump to clear the entered digits. */
15
35
  resetKey?: string | number;
16
36
  error?: boolean;
37
+ /**
38
+ * Haptic feedback on each key. On by default, and a no-op on web and on any
39
+ * device without a haptic engine. Pass `false` where a press already triggers
40
+ * feedback of its own.
41
+ */
42
+ haptics?: boolean;
17
43
  }
18
- export declare function PinPad({ length, onComplete, onChange, onBiometric, resetKey, error, }: PinPadProps): React.JSX.Element;
44
+ export declare function PinPad({ length, onComplete, onChange, onBiometric, resetKey, error, haptics, }: PinPadProps): React.JSX.Element;
19
45
  export default PinPad;
20
46
  //# sourceMappingURL=pin-pad.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pin-pad.d.ts","sourceRoot":"","sources":["../../src/components/pin-pad.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAmD,MAAM,OAAO,CAAC;AAKxE;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,yEAAyE;IACzE,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IACzB,wCAAwC;IACxC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC3B,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAID,wBAAgB,MAAM,CAAC,EACrB,MAAU,EACV,UAAU,EACV,QAAQ,EACR,WAAW,EACX,QAAQ,EACR,KAAK,GACN,EAAE,WAAW,qBAqGb;AAyBD,eAAe,MAAM,CAAC"}
1
+ {"version":3,"file":"pin-pad.d.ts","sourceRoot":"","sources":["../../src/components/pin-pad.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAmD,MAAM,OAAO,CAAC;AAOxE;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,WAAW,WAAW;IAC1B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,yEAAyE;IACzE,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IACzB,wCAAwC;IACxC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC3B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAWD,wBAAgB,MAAM,CAAC,EACrB,MAAU,EACV,UAAU,EACV,QAAQ,EACR,WAAW,EACX,QAAQ,EACR,KAAK,EACL,OAAc,GACf,EAAE,WAAW,qBAwJb;AAmDD,eAAe,MAAM,CAAC"}
@@ -1,11 +1,35 @@
1
1
  import React, { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { Platform, Pressable, Text, View } from "react-native";
3
+ import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
4
+ import * as Haptics from "expo-haptics";
3
5
  import { useColorScheme } from "nativewind";
4
6
  import { Delete, Fingerprint } from "lucide-react-native";
5
7
  const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
6
- export function PinPad({ length = 6, onComplete, onChange, onBiometric, resetKey, error, }) {
8
+ /** Key diameter bounds, and the gap between them. */
9
+ const MIN_KEY = 76;
10
+ const MAX_KEY = 104;
11
+ const GAP = 16;
12
+ /** Assumed width for the single frame before the first measurement lands. */
13
+ const ASSUMED_WIDTH = 320;
14
+ export function PinPad({ length = 6, onComplete, onChange, onBiometric, resetKey, error, haptics = true, }) {
7
15
  const { colorScheme } = useColorScheme();
8
- const iconColor = colorScheme === "dark" ? "#FFFFFF" : "#000000";
16
+ const isDark = colorScheme === "dark";
17
+ const iconColor = isDark ? "#FFFFFF" : "#000000";
18
+ const [available, setAvailable] = useState(0);
19
+ const onMeasure = (event) => {
20
+ const measured = event.nativeEvent.layout.width;
21
+ if (measured > 0 && Math.abs(measured - available) > 1) {
22
+ setAvailable(measured);
23
+ }
24
+ };
25
+ // Three keys and two gaps have to fit in the width this pad was actually
26
+ // given. Clamped at both ends: MIN_KEY keeps the pad usable in a narrow
27
+ // column even if that means it overflows a little, MAX_KEY stops a tablet
28
+ // from rendering three dinner plates.
29
+ const width = available || ASSUMED_WIDTH;
30
+ const keySize = Math.max(MIN_KEY, Math.min(MAX_KEY, Math.floor((width - 2 * GAP) / 3)));
31
+ const padWidth = keySize * 3 + GAP * 2;
32
+ const dotSize = Math.round(keySize * 0.17);
9
33
  const [pin, setPin] = useState("");
10
34
  // Source of truth for the latest digits — lets callbacks fire OUTSIDE the
11
35
  // setPin updater (calling a parent setState inside an updater triggers
@@ -21,11 +45,28 @@ export function PinPad({ length = 6, onComplete, onChange, onBiometric, resetKey
21
45
  setPin("");
22
46
  }
23
47
  }
48
+ /**
49
+ * A tap you can feel.
50
+ *
51
+ * Deliberately fire-and-forget: the feedback is worth nothing if the digit
52
+ * waits for it, and a device with no haptic engine rejects the promise rather
53
+ * than throwing, which must not surface as an unhandled rejection over a
54
+ * keypress.
55
+ */
56
+ const tap = useCallback(() => {
57
+ if (!haptics || Platform.OS === "web") {
58
+ return;
59
+ }
60
+ void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {
61
+ // No haptic engine, or the OS declined. Nothing to say about it.
62
+ });
63
+ }, [haptics]);
24
64
  const press = useCallback((d) => {
25
65
  const cur = pinRef.current;
26
66
  if (cur.length >= length) {
27
67
  return;
28
68
  }
69
+ tap();
29
70
  const next = cur + d;
30
71
  pinRef.current = next;
31
72
  setPin(next);
@@ -33,13 +74,17 @@ export function PinPad({ length = 6, onComplete, onChange, onBiometric, resetKey
33
74
  if (next.length === length) {
34
75
  onComplete(next);
35
76
  }
36
- }, [length, onChange, onComplete]);
77
+ }, [length, onChange, onComplete, tap]);
37
78
  const back = useCallback(() => {
79
+ if (pinRef.current.length === 0) {
80
+ return;
81
+ }
82
+ tap();
38
83
  const next = pinRef.current.slice(0, -1);
39
84
  pinRef.current = next;
40
85
  setPin(next);
41
86
  onChange?.(next);
42
- }, [onChange]);
87
+ }, [onChange, tap]);
43
88
  // Physical keyboard / numpad support (web): digits type, Backspace/Delete
44
89
  // removes, Enter submits a full PIN.
45
90
  useEffect(() => {
@@ -65,29 +110,44 @@ export function PinPad({ length = 6, onComplete, onChange, onBiometric, resetKey
65
110
  window.addEventListener("keydown", onKey);
66
111
  return () => window.removeEventListener("keydown", onKey);
67
112
  }, [press, back, onComplete, length]);
68
- return (<View className="items-center gap-7">
113
+ return (<View className="w-full items-center gap-8" onLayout={onMeasure}>
69
114
  {/* dots */}
70
- <View className="flex-row gap-3">
71
- {Array.from({ length }).map((_, i) => (<View key={i} className={i < pin.length
115
+ <View className="flex-row" style={{ gap: Math.round(dotSize * 0.9) }}>
116
+ {Array.from({ length }).map((_, i) => (<View key={i} style={{ width: dotSize, height: dotSize, borderRadius: dotSize / 2 }} className={i < pin.length
72
117
  ? error
73
- ? "h-3.5 w-3.5 rounded-full bg-destructive dark:bg-red-900"
74
- : "h-3.5 w-3.5 rounded-full bg-primary dark:bg-white"
75
- : "h-3.5 w-3.5 rounded-full border-2 border-border dark:border-neutral-800"}/>))}
118
+ ? "bg-destructive dark:bg-red-900"
119
+ : "bg-primary dark:bg-white"
120
+ : "border-2 border-border dark:border-neutral-700"}/>))}
76
121
  </View>
77
122
 
78
123
  {/* keypad */}
79
- <View className="w-72 flex-row flex-wrap justify-center gap-3">
80
- {KEYS.map((k) => (<Key key={k} label={k} onPress={() => press(k)}/>))}
81
- {onBiometric ? (<Key onPress={onBiometric} icon={<Fingerprint size={26} color={iconColor}/>}/>) : (<View className="h-20 w-20"/>)}
82
- <Key label="0" onPress={() => press("0")}/>
83
- <Key onPress={back} icon={<Delete size={24} color={iconColor}/>}/>
124
+ <View className="flex-row flex-wrap justify-center" style={{ width: padWidth, gap: GAP }}>
125
+ {KEYS.map((k) => (<Key key={k} label={k} size={keySize} onPress={() => press(k)}/>))}
126
+ {onBiometric ? (<Key size={keySize} onPress={onBiometric} icon={<Fingerprint size={Math.round(keySize * 0.34)} color={iconColor}/>} accessibilityLabel="Unlock with biometrics"/>) : (<View style={{ width: keySize, height: keySize }}/>)}
127
+ <Key label="0" size={keySize} onPress={() => press("0")}/>
128
+ <Key size={keySize} onPress={back} icon={<Delete size={Math.round(keySize * 0.3)} color={iconColor}/>} accessibilityLabel="Delete"/>
84
129
  </View>
85
130
  </View>);
86
131
  }
87
- function Key({ label, icon, onPress, }) {
88
- return (<Pressable onPress={onPress} className="h-20 w-20 items-center justify-center rounded-full active:bg-secondary dark:active:bg-neutral-800" accessibilityRole="button" accessibilityLabel={label ?? "key"}>
89
- {icon ?? (<Text className="font-sans-medium text-2xl text-foreground dark:text-white">{label}</Text>)}
90
- </Pressable>);
132
+ function Key({ label, icon, size, onPress, accessibilityLabel, }) {
133
+ // Same two-part response as Button: a dip in scale and a lift in fill. The
134
+ // fill change alone was too easy to miss on a dark screen, and on a keypad
135
+ // "did that register?" is the entire question the user is asking.
136
+ const pressed = useSharedValue(0);
137
+ const pressStyle = useAnimatedStyle(() => ({
138
+ transform: [{ scale: 1 - pressed.value * 0.08 }],
139
+ }));
140
+ return (<Animated.View style={pressStyle}>
141
+ <Pressable onPress={onPress} onPressIn={() => {
142
+ pressed.value = withTiming(1, { duration: 60 });
143
+ }} onPressOut={() => {
144
+ pressed.value = withTiming(0, { duration: 160 });
145
+ }} style={{ width: size, height: size, borderRadius: size / 2 }} className="items-center justify-center border border-border bg-secondary active:bg-border dark:border-neutral-700 dark:bg-neutral-900 dark:active:bg-neutral-700" accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label ?? "key"}>
146
+ {icon ?? (<Text className="font-sans-medium text-foreground dark:text-white" style={{ fontSize: Math.round(size * 0.38) }}>
147
+ {label}
148
+ </Text>)}
149
+ </Pressable>
150
+ </Animated.View>);
91
151
  }
92
152
  export default PinPad;
93
153
  //# sourceMappingURL=pin-pad.jsx.map
@@ -1 +1 @@
1
- {"version":3,"file":"pin-pad.jsx","sourceRoot":"","sources":["../../src/components/pin-pad.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"pin-pad.jsx","sourceRoot":"","sources":["../../src/components/pin-pad.tsx"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entropy-softworks/ui",
3
- "version": "2026.8.42",
3
+ "version": "2026.9.2",
4
4
  "private": false,
5
5
  "description": "Shared React Native UI primitives, auth screens, hooks, and services for Entropy Softworks Expo apps",
6
6
  "license": "MIT",
@@ -1,5 +1,7 @@
1
1
  import React, { useCallback, useEffect, useRef, useState } from "react";
2
- import { Platform, Pressable, Text, View } from "react-native";
2
+ import { type LayoutChangeEvent, Platform, Pressable, Text, View } from "react-native";
3
+ import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
4
+ import * as Haptics from "expo-haptics";
3
5
  import { useColorScheme } from "nativewind";
4
6
  import { Delete, Fingerprint } from "lucide-react-native";
5
7
 
@@ -8,6 +10,26 @@ import { Delete, Fingerprint } from "lucide-react-native";
8
10
  * the active palette via NativeWind classes). Self-manages the entered
9
11
  * digits and fires `onComplete` when `length` is reached; pass a changing
10
12
  * `resetKey` to clear it (e.g. after a wrong PIN).
13
+ *
14
+ * # Sizing
15
+ *
16
+ * The pad fills the width it is given instead of sitting at a fixed 288px. On a
17
+ * modern phone a fixed pad reads as a small control marooned in the middle of a
18
+ * large screen — and this is the primary way into the app for a device-only
19
+ * vault, so it should feel like the screen's purpose rather than a widget on it.
20
+ * Keys grow with the available width and stop at `MAX_KEY` so a tablet does not
21
+ * get comedy-sized buttons.
22
+ *
23
+ * The width comes from the pad's own `onLayout`, not from the viewport. It is
24
+ * usually inside a card inside a padded screen, and every one of those layers
25
+ * takes width the window knows nothing about — sizing off the window overflowed
26
+ * the card it sat in.
27
+ *
28
+ * # Why the keys have a fill
29
+ *
30
+ * They previously had no background at all until the moment of the press, which
31
+ * left three columns of bare digits that did not read as pressable. A resting
32
+ * fill plus a border is what makes a target look like a target.
11
33
  */
12
34
  export interface PinPadProps {
13
35
  length?: number;
@@ -18,10 +40,23 @@ export interface PinPadProps {
18
40
  /** Bump to clear the entered digits. */
19
41
  resetKey?: string | number;
20
42
  error?: boolean;
43
+ /**
44
+ * Haptic feedback on each key. On by default, and a no-op on web and on any
45
+ * device without a haptic engine. Pass `false` where a press already triggers
46
+ * feedback of its own.
47
+ */
48
+ haptics?: boolean;
21
49
  }
22
50
 
23
51
  const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"] as const;
24
52
 
53
+ /** Key diameter bounds, and the gap between them. */
54
+ const MIN_KEY = 76;
55
+ const MAX_KEY = 104;
56
+ const GAP = 16;
57
+ /** Assumed width for the single frame before the first measurement lands. */
58
+ const ASSUMED_WIDTH = 320;
59
+
25
60
  export function PinPad({
26
61
  length = 6,
27
62
  onComplete,
@@ -29,9 +64,28 @@ export function PinPad({
29
64
  onBiometric,
30
65
  resetKey,
31
66
  error,
67
+ haptics = true,
32
68
  }: PinPadProps) {
33
69
  const { colorScheme } = useColorScheme();
34
- const iconColor = colorScheme === "dark" ? "#FFFFFF" : "#000000";
70
+ const isDark = colorScheme === "dark";
71
+ const iconColor = isDark ? "#FFFFFF" : "#000000";
72
+ const [available, setAvailable] = useState(0);
73
+ const onMeasure = (event: LayoutChangeEvent) => {
74
+ const measured = event.nativeEvent.layout.width;
75
+ if (measured > 0 && Math.abs(measured - available) > 1) {
76
+ setAvailable(measured);
77
+ }
78
+ };
79
+
80
+ // Three keys and two gaps have to fit in the width this pad was actually
81
+ // given. Clamped at both ends: MIN_KEY keeps the pad usable in a narrow
82
+ // column even if that means it overflows a little, MAX_KEY stops a tablet
83
+ // from rendering three dinner plates.
84
+ const width = available || ASSUMED_WIDTH;
85
+ const keySize = Math.max(MIN_KEY, Math.min(MAX_KEY, Math.floor((width - 2 * GAP) / 3)));
86
+ const padWidth = keySize * 3 + GAP * 2;
87
+ const dotSize = Math.round(keySize * 0.17);
88
+
35
89
  const [pin, setPin] = useState("");
36
90
  // Source of truth for the latest digits — lets callbacks fire OUTSIDE the
37
91
  // setPin updater (calling a parent setState inside an updater triggers
@@ -49,12 +103,30 @@ export function PinPad({
49
103
  }
50
104
  }
51
105
 
106
+ /**
107
+ * A tap you can feel.
108
+ *
109
+ * Deliberately fire-and-forget: the feedback is worth nothing if the digit
110
+ * waits for it, and a device with no haptic engine rejects the promise rather
111
+ * than throwing, which must not surface as an unhandled rejection over a
112
+ * keypress.
113
+ */
114
+ const tap = useCallback(() => {
115
+ if (!haptics || Platform.OS === "web") {
116
+ return;
117
+ }
118
+ void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {
119
+ // No haptic engine, or the OS declined. Nothing to say about it.
120
+ });
121
+ }, [haptics]);
122
+
52
123
  const press = useCallback(
53
124
  (d: string) => {
54
125
  const cur = pinRef.current;
55
126
  if (cur.length >= length) {
56
127
  return;
57
128
  }
129
+ tap();
58
130
  const next = cur + d;
59
131
  pinRef.current = next;
60
132
  setPin(next);
@@ -63,15 +135,19 @@ export function PinPad({
63
135
  onComplete(next);
64
136
  }
65
137
  },
66
- [length, onChange, onComplete]
138
+ [length, onChange, onComplete, tap]
67
139
  );
68
140
 
69
141
  const back = useCallback(() => {
142
+ if (pinRef.current.length === 0) {
143
+ return;
144
+ }
145
+ tap();
70
146
  const next = pinRef.current.slice(0, -1);
71
147
  pinRef.current = next;
72
148
  setPin(next);
73
149
  onChange?.(next);
74
- }, [onChange]);
150
+ }, [onChange, tap]);
75
151
 
76
152
  // Physical keyboard / numpad support (web): digits type, Backspace/Delete
77
153
  // removes, Enter submits a full PIN.
@@ -98,35 +174,46 @@ export function PinPad({
98
174
  }, [press, back, onComplete, length]);
99
175
 
100
176
  return (
101
- <View className="items-center gap-7">
177
+ <View className="w-full items-center gap-8" onLayout={onMeasure}>
102
178
  {/* dots */}
103
- <View className="flex-row gap-3">
179
+ <View className="flex-row" style={{ gap: Math.round(dotSize * 0.9) }}>
104
180
  {Array.from({ length }).map((_, i) => (
105
181
  <View
106
182
  key={i}
183
+ style={{ width: dotSize, height: dotSize, borderRadius: dotSize / 2 }}
107
184
  className={
108
185
  i < pin.length
109
186
  ? error
110
- ? "h-3.5 w-3.5 rounded-full bg-destructive dark:bg-red-900"
111
- : "h-3.5 w-3.5 rounded-full bg-primary dark:bg-white"
112
- : "h-3.5 w-3.5 rounded-full border-2 border-border dark:border-neutral-800"
187
+ ? "bg-destructive dark:bg-red-900"
188
+ : "bg-primary dark:bg-white"
189
+ : "border-2 border-border dark:border-neutral-700"
113
190
  }
114
191
  />
115
192
  ))}
116
193
  </View>
117
194
 
118
195
  {/* keypad */}
119
- <View className="w-72 flex-row flex-wrap justify-center gap-3">
196
+ <View className="flex-row flex-wrap justify-center" style={{ width: padWidth, gap: GAP }}>
120
197
  {KEYS.map((k) => (
121
- <Key key={k} label={k} onPress={() => press(k)} />
198
+ <Key key={k} label={k} size={keySize} onPress={() => press(k)} />
122
199
  ))}
123
200
  {onBiometric ? (
124
- <Key onPress={onBiometric} icon={<Fingerprint size={26} color={iconColor} />} />
201
+ <Key
202
+ size={keySize}
203
+ onPress={onBiometric}
204
+ icon={<Fingerprint size={Math.round(keySize * 0.34)} color={iconColor} />}
205
+ accessibilityLabel="Unlock with biometrics"
206
+ />
125
207
  ) : (
126
- <View className="h-20 w-20" />
208
+ <View style={{ width: keySize, height: keySize }} />
127
209
  )}
128
- <Key label="0" onPress={() => press("0")} />
129
- <Key onPress={back} icon={<Delete size={24} color={iconColor} />} />
210
+ <Key label="0" size={keySize} onPress={() => press("0")} />
211
+ <Key
212
+ size={keySize}
213
+ onPress={back}
214
+ icon={<Delete size={Math.round(keySize * 0.3)} color={iconColor} />}
215
+ accessibilityLabel="Delete"
216
+ />
130
217
  </View>
131
218
  </View>
132
219
  );
@@ -135,23 +222,49 @@ export function PinPad({
135
222
  function Key({
136
223
  label,
137
224
  icon,
225
+ size,
138
226
  onPress,
227
+ accessibilityLabel,
139
228
  }: {
140
229
  label?: string;
141
230
  icon?: React.ReactNode;
231
+ size: number;
142
232
  onPress: () => void;
233
+ accessibilityLabel?: string;
143
234
  }) {
235
+ // Same two-part response as Button: a dip in scale and a lift in fill. The
236
+ // fill change alone was too easy to miss on a dark screen, and on a keypad
237
+ // "did that register?" is the entire question the user is asking.
238
+ const pressed = useSharedValue(0);
239
+ const pressStyle = useAnimatedStyle(() => ({
240
+ transform: [{ scale: 1 - pressed.value * 0.08 }],
241
+ }));
242
+
144
243
  return (
145
- <Pressable
146
- onPress={onPress}
147
- className="h-20 w-20 items-center justify-center rounded-full active:bg-secondary dark:active:bg-neutral-800"
148
- accessibilityRole="button"
149
- accessibilityLabel={label ?? "key"}
150
- >
151
- {icon ?? (
152
- <Text className="font-sans-medium text-2xl text-foreground dark:text-white">{label}</Text>
153
- )}
154
- </Pressable>
244
+ <Animated.View style={pressStyle}>
245
+ <Pressable
246
+ onPress={onPress}
247
+ onPressIn={() => {
248
+ pressed.value = withTiming(1, { duration: 60 });
249
+ }}
250
+ onPressOut={() => {
251
+ pressed.value = withTiming(0, { duration: 160 });
252
+ }}
253
+ style={{ width: size, height: size, borderRadius: size / 2 }}
254
+ className="items-center justify-center border border-border bg-secondary active:bg-border dark:border-neutral-700 dark:bg-neutral-900 dark:active:bg-neutral-700"
255
+ accessibilityRole="button"
256
+ accessibilityLabel={accessibilityLabel ?? label ?? "key"}
257
+ >
258
+ {icon ?? (
259
+ <Text
260
+ className="font-sans-medium text-foreground dark:text-white"
261
+ style={{ fontSize: Math.round(size * 0.38) }}
262
+ >
263
+ {label}
264
+ </Text>
265
+ )}
266
+ </Pressable>
267
+ </Animated.View>
155
268
  );
156
269
  }
157
270