react-native-unified-action-sheet 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/README.md +42 -21
  3. package/android/src/main/java/com/unifiedactionsheet/PromptOptions.kt +132 -0
  4. package/android/src/main/java/com/unifiedactionsheet/SheetContentBuilder.kt +31 -0
  5. package/android/src/main/java/com/unifiedactionsheet/SheetPresenters.kt +54 -0
  6. package/android/src/main/java/com/unifiedactionsheet/UnifiedActionSheetModule.kt +55 -0
  7. package/ios/UnifiedActionSheet.mm +47 -0
  8. package/ios/UnifiedActionSheetImpl.swift +101 -4
  9. package/jest/index.d.ts +15 -1
  10. package/jest/index.js +28 -0
  11. package/lib/commonjs/NativeUnifiedActionSheet.js.map +1 -1
  12. package/lib/commonjs/action-sheet-options.interface.js +4 -0
  13. package/lib/commonjs/common-options.interface.js +2 -0
  14. package/lib/commonjs/common-options.interface.js.map +1 -0
  15. package/lib/commonjs/index.js +39 -4
  16. package/lib/commonjs/index.js.map +1 -1
  17. package/lib/commonjs/prompt-options.interface.js +6 -0
  18. package/lib/commonjs/prompt-options.interface.js.map +1 -0
  19. package/lib/module/NativeUnifiedActionSheet.js.map +1 -1
  20. package/lib/module/action-sheet-options.interface.js +2 -0
  21. package/lib/module/common-options.interface.js +2 -0
  22. package/lib/module/common-options.interface.js.map +1 -0
  23. package/lib/module/index.js +37 -3
  24. package/lib/module/index.js.map +1 -1
  25. package/lib/module/prompt-options.interface.js +4 -0
  26. package/lib/module/prompt-options.interface.js.map +1 -0
  27. package/lib/typescript/commonjs/src/NativeUnifiedActionSheet.d.ts +20 -0
  28. package/lib/typescript/commonjs/src/NativeUnifiedActionSheet.d.ts.map +1 -1
  29. package/lib/typescript/commonjs/src/action-sheet-options.interface.d.ts +4 -13
  30. package/lib/typescript/commonjs/src/action-sheet-options.interface.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/src/common-options.interface.d.ts +17 -0
  32. package/lib/typescript/commonjs/src/common-options.interface.d.ts.map +1 -0
  33. package/lib/typescript/commonjs/src/index.d.ts +4 -0
  34. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/src/prompt-options.interface.d.ts +19 -0
  36. package/lib/typescript/commonjs/src/prompt-options.interface.d.ts.map +1 -0
  37. package/lib/typescript/module/src/NativeUnifiedActionSheet.d.ts +20 -0
  38. package/lib/typescript/module/src/NativeUnifiedActionSheet.d.ts.map +1 -1
  39. package/lib/typescript/module/src/action-sheet-options.interface.d.ts +4 -13
  40. package/lib/typescript/module/src/action-sheet-options.interface.d.ts.map +1 -1
  41. package/lib/typescript/module/src/common-options.interface.d.ts +17 -0
  42. package/lib/typescript/module/src/common-options.interface.d.ts.map +1 -0
  43. package/lib/typescript/module/src/index.d.ts +4 -0
  44. package/lib/typescript/module/src/index.d.ts.map +1 -1
  45. package/lib/typescript/module/src/prompt-options.interface.d.ts +19 -0
  46. package/lib/typescript/module/src/prompt-options.interface.d.ts.map +1 -0
  47. package/package.json +1 -1
  48. package/src/NativeUnifiedActionSheet.ts +17 -0
  49. package/src/action-sheet-options.interface.ts +9 -19
  50. package/src/common-options.interface.ts +18 -0
  51. package/src/index.tsx +65 -3
  52. package/src/prompt-options.interface.ts +27 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0]
11
+
12
+ ### Added
13
+
14
+ - `showPromptWithOptions(options)` — the same native dialog as the action sheet,
15
+ with a text field. React Native's own `Alert.prompt` is iOS-only and silently
16
+ does nothing on Android, so this is the one API here with no core equivalent.
17
+ Resolves `{ buttonIndex, text }`, or `undefined` after `dismissActionSheet()`.
18
+ - Prompt button handlers receive the field's text. It is read when the prompt
19
+ closes, including on a dismissal, so a draft is recoverable rather than lost.
20
+ - Prompt options: `placeholder`, `defaultValue`, `secureTextEntry` and
21
+ `keyboardType` (`'default' | 'email-address' | 'numeric' | 'phone-pad' |
22
+ 'url'`). Buttons keep the sheet's `style`, `disabled` and `onPress`.
23
+ - `setNextPromptResult({ buttonIndex, text })` in the shipped Jest mock, the
24
+ prompt counterpart to `setNextButtonIndex()`.
25
+ - Exported types: `PromptButtonInterface`, `PromptCommonOptionsInterface`,
26
+ `PromptAndroidOptionsInterface`, `PromptOptionsInterface`,
27
+ `PromptResultInterface`, plus the shared `BaseButtonInterface`,
28
+ `BaseOptionsInterface` and `BaseAndroidOptionsInterface`.
29
+
30
+ ### Changed
31
+
32
+ - The options and button fields the sheet and the prompt share are now declared
33
+ once, in `BaseOptionsInterface` / `BaseButtonInterface`, which both APIs
34
+ extend. No public type changed shape.
35
+
36
+ ### Notes
37
+
38
+ - A prompt is always presented centered. UIKit has no text field in an action
39
+ sheet, so `presentationStyle` and `anchor` do not apply to one.
40
+ - `dismissActionSheet()` and `dismissAllActionSheets()` close prompts too.
41
+
42
+ ## [0.1.1]
43
+
44
+ No user-facing changes — the library is identical to 0.1.0. Republished from CI
45
+ so the release carries npm provenance; 0.1.0 was published by hand, because npm
46
+ only lets a trusted publisher be configured on a package that already exists.
47
+
10
48
  ## [0.1.0]
11
49
 
12
50
  Initial release.
@@ -37,5 +75,7 @@ Initial release.
37
75
  - No `com.google.android.material` dependency, and no runtime dependencies —
38
76
  `react` and `react-native` are peers.
39
77
 
40
- [unreleased]: https://github.com/galikvalkin/react-native-unified-action-sheet/compare/v0.1.0...HEAD
78
+ [unreleased]: https://github.com/galikvalkin/react-native-unified-action-sheet/compare/v0.2.0...HEAD
79
+ [0.2.0]: https://github.com/galikvalkin/react-native-unified-action-sheet/compare/v0.1.1...v0.2.0
80
+ [0.1.1]: https://github.com/galikvalkin/react-native-unified-action-sheet/compare/v0.1.0...v0.1.1
41
81
  [0.1.0]: https://github.com/galikvalkin/react-native-unified-action-sheet/releases/tag/v0.1.0
package/README.md CHANGED
@@ -12,8 +12,9 @@ Unified action sheet API for React Native: a native `UIAlertController` on iOS,
12
12
  <em>iPad: the same sheet presented as a popover, anchored to the view that opened it.</em>
13
13
  </p>
14
14
 
15
- - **Fully native, always on top.** The sheet gets its own platform window, so it renders above your entire React Native view tree — including above an already-open [`Modal`](https://reactnative.dev/docs/modal). JS-rendered action sheets live *inside* the component tree, where `overflow`, `zIndex`, a transform or an open modal can clip or bury them.
15
+ - **Fully native, always on top.** The sheet gets its own platform window, so it renders above your whole view tree — including an already-open [`Modal`](https://reactnative.dev/docs/modal). JS-rendered sheets live *inside* the component tree, where `overflow`, `zIndex`, a transform or an open modal can clip or bury them.
16
16
  - **One API, native on both platforms**, so the same options behave the same way.
17
+ - **Prompts included.** React Native's `Alert.prompt` is iOS-only and silently does nothing on Android; `showPromptWithOptions()` works on both.
17
18
  - Works on React Native **0.81 (old and new architecture)** through the **latest** release.
18
19
  - **No extra native dependencies** on either platform, and zero runtime dependencies (`react` / `react-native` peers only).
19
20
 
@@ -44,7 +45,7 @@ await showActionSheetWithOptions({
44
45
  });
45
46
  ```
46
47
 
47
- Each button is an object. `style` marks the two native roles, which are mutually exclusive; `disabled` is independent of it; and `onPress` runs when that button resolves the sheet, so reordering buttons cannot send one down another's branch. The call also resolves with the tapped index, and the two never disagree:
48
+ `style` marks the two native roles, which are mutually exclusive; `disabled` is independent of it. `onPress` runs when that button resolves the sheet, and the call also resolves with the tapped index the two never disagree:
48
49
 
49
50
  | | promise resolves with | `onPress` runs |
50
51
  | --- | --- | --- |
@@ -75,6 +76,34 @@ const anchorRef = useRef<View>(null);
75
76
 
76
77
  Anything with a `measureInWindow` method works, which is every React Native host component ref. On iOS the anchor only applies on iPad, where the sheet becomes a popover; iPhone always presents from the bottom.
77
78
 
79
+ ### Prompts
80
+
81
+ `showPromptWithOptions()` is the same dialog with a text field:
82
+
83
+ ```ts
84
+ import { showPromptWithOptions } from 'react-native-unified-action-sheet';
85
+
86
+ const result = await showPromptWithOptions({
87
+ title: 'Rename item',
88
+ placeholder: 'New name',
89
+ defaultValue: 'Untitled',
90
+ options: [
91
+ { label: 'Save', onPress: (text) => rename(text) },
92
+ { label: 'Cancel', style: 'cancel' },
93
+ ],
94
+ });
95
+ // { buttonIndex, text }, or undefined after dismissActionSheet().
96
+ ```
97
+
98
+ Buttons behave as they do for the sheet, except `onPress` receives the field's
99
+ text. That text is whatever was in the field when the prompt closed, including
100
+ on a dismissal, so a draft is recoverable. Extra options: `placeholder`,
101
+ `defaultValue`, `secureTextEntry` and `keyboardType` (`'default' |
102
+ 'email-address' | 'numeric' | 'phone-pad' | 'url'`).
103
+
104
+ A prompt is always centered — UIKit has no text field in an action sheet, so
105
+ `presentationStyle` and `anchor` do not apply.
106
+
78
107
  ### Dismissing from code
79
108
 
80
109
  `dismissActionSheet()` closes the most recently opened sheet, `dismissAllActionSheets()` closes every open one. Both are no-ops when nothing is open, and the dismissed sheets resolve with `undefined`.
@@ -91,13 +120,13 @@ import {
91
120
  | Option | Type | iOS | Android | Description |
92
121
  | --- | --- | :---: | :---: | --- |
93
122
  | `options` | `ActionSheetButtonInterface[]` | ✅ | ✅ | The buttons, in order: `{ label, style?, disabled?, onPress? }`. |
94
- | `style` (per button) | `'cancel' \| 'destructive'?` | ✅ | ✅ | `'cancel'` renders a separated cancel row on Android and resolves on backdrop tap / back; `'destructive'` renders in the destructive color. Only the first `'cancel'` counts. |
123
+ | `style` (per button) | `'cancel' \| 'destructive'?` | ✅ | ✅ | `'cancel'` renders a separated row and resolves on backdrop tap / back; `'destructive'` uses the destructive color. Only the first `'cancel'` counts. |
95
124
  | `disabled` (per button) | `boolean?` | ✅ | ✅ | Renders the row dimmed and unresponsive to taps. |
96
125
  | `onPress` (per button) | `() => void?` | ✅ | ✅ | Runs when this button resolves the sheet. |
97
126
  | `title` | `string?` | ✅ | ✅ | Sheet title. |
98
127
  | `message` | `string?` | ✅ | ✅ | Secondary text under the title. |
99
- | `presentationStyle` | `'centered' \| 'anchored'` | ✅ | ✅ | `'centered'` is a centered dialog; `'anchored'` attaches to `anchor` (an iPad popover on iOS). **Defaults differ**: Android defaults to `'centered'`, iOS to the standard action sheet. |
100
- | `anchor` | `ActionSheetAnchorInterface?` | ✅ | ✅ | The view to attach to — a ref, or anything with `measureInWindow`. Without a measurable anchor an `'anchored'` sheet falls back to a centered dialog. |
128
+ | `presentationStyle` | `'centered' \| 'anchored'` | ✅ | ✅ | `'centered'` is a centered dialog; `'anchored'` attaches to `anchor` (an iPad popover on iOS). **Defaults differ**: Android `'centered'`, iOS the standard action sheet. |
129
+ | `anchor` | `ActionSheetAnchorInterface?` | ✅ | ✅ | A ref, or anything with `measureInWindow`. Without a measurable anchor an `'anchored'` sheet falls back to a centered dialog. |
101
130
  | `anchorAlignment` | `'start' \| 'center'` | — | ✅ | Alignment of an `'anchored'` popup relative to its anchor. `'start'` (default) aligns leading edges, flipping in RTL. |
102
131
  | `userInterfaceStyle` | `'light' \| 'dark'` | ✅ | ✅ | Forces the appearance; defaults to following the system setting. |
103
132
  | `tintColor` | `string?` | ✅ | ✅ | Text color of non-destructive buttons. |
@@ -105,26 +134,16 @@ import {
105
134
  | `destructiveColor` | `string?` | ✅ | ✅ | Overrides the destructive row color (Android's palette error color, iOS system red). |
106
135
  | `buttonTextAlignment` | `'start' \| 'center'` | — | ✅ | Alignment of button labels. Defaults to `'start'`, which follows layout direction. |
107
136
 
108
- The options types are exported for typing your own wrappers:
109
-
110
- ```ts
111
- import type {
112
- ActionSheetOptionsInterface, // what showActionSheetWithOptions accepts
113
- ActionSheetCommonOptionsInterface,
114
- ActionSheetAndroidOptionsInterface,
115
- ActionSheetButtonInterface, // a single button
116
- ActionSheetAnchorInterface, // anything with measureInWindow
117
- } from 'react-native-unified-action-sheet';
118
- ```
137
+ Every options and button type is exported for typing your own wrappers
138
+ `ActionSheetOptionsInterface`, `PromptOptionsInterface`, `PromptResultInterface`
139
+ and the interfaces they are composed from.
119
140
 
120
141
  ### Platform notes
121
142
 
122
143
  - **Which gestures dismiss differs.** On iOS a sheet can only be tapped away if it has a `'cancel'` button, and a `'centered'` one never can — UIKit treats it as strictly modal. Android's centered dialog always cancels on a backdrop tap. Give a sheet a cancel button if you want that gesture everywhere.
123
144
  - **On iPad, a popover hides the cancel row**, since tapping outside already cancels. The index you receive is unaffected.
124
145
  - **Sheets stack.** Opening one over another puts it on top, and each resolves its own promise. Opening a sheet over a `Modal` does not dismiss the modal.
125
- - **Light or dark follows the system setting** unless `userInterfaceStyle` forces one, chosen when the sheet opens. The sheet uses its own palette, so it looks the same in any host app.
126
- - **Long lists scroll**, with the title and message pinned above them.
127
- - On Android, a sheet with no available activity resolves the cancel index rather than hanging, so a cancel handler can run for a sheet that never appeared.
146
+ - **Light or dark follows the system setting** unless `userInterfaceStyle` forces one, chosen when the sheet opens. It uses its own palette, so it looks the same in any host app.
128
147
 
129
148
  ## Testing
130
149
 
@@ -136,7 +155,7 @@ jest.mock('react-native-unified-action-sheet', () =>
136
155
  );
137
156
  ```
138
157
 
139
- Every sheet then resolves with no selection. To simulate a tap, queue the index the next sheet should resolve with — the pressed button's `onPress` runs, just as it would for real:
158
+ Every sheet then resolves with no selection. To simulate a tap, queue the index it should resolve with — the pressed button's `onPress` runs as it would for real:
140
159
 
141
160
  ```ts
142
161
  import { setNextButtonIndex } from 'react-native-unified-action-sheet/jest';
@@ -145,7 +164,9 @@ setNextButtonIndex(0);
145
164
  await openTheSheet();
146
165
  ```
147
166
 
148
- Prefer that over `mockResolvedValueOnce`, which replaces the implementation and so skips `onPress`. `dismissActionSheet` and `dismissAllActionSheets` are plain spies.
167
+ `setNextPromptResult({ buttonIndex, text })` does the same for a prompt. Prefer
168
+ these over `mockResolvedValueOnce`, which replaces the implementation and so
169
+ skips `onPress`. `dismissActionSheet` and `dismissAllActionSheets` are spies.
149
170
 
150
171
  ## Compatibility
151
172
 
@@ -0,0 +1,132 @@
1
+ package com.unifiedactionsheet
2
+
3
+ import android.text.InputType
4
+ import com.facebook.react.bridge.ReadableMap
5
+
6
+ /// The subset of React Native's keyboardType values that map onto both
7
+ /// platforms. Anything unrecognised falls back to the plain text keyboard.
8
+ internal enum class PromptKeyboardType {
9
+ DEFAULT,
10
+ EMAIL_ADDRESS,
11
+ NUMERIC,
12
+ PHONE_PAD,
13
+ URL,
14
+ ;
15
+
16
+ fun toInputType(secure: Boolean): Int {
17
+ if (secure) {
18
+ // Keep the keyboard class: masking a passcode should still show digits.
19
+ return when (this) {
20
+ NUMERIC, PHONE_PAD ->
21
+ InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
22
+ else -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
23
+ }
24
+ }
25
+
26
+ return when (this) {
27
+ DEFAULT -> InputType.TYPE_CLASS_TEXT
28
+ EMAIL_ADDRESS -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
29
+ NUMERIC -> InputType.TYPE_CLASS_NUMBER
30
+ PHONE_PAD -> InputType.TYPE_CLASS_PHONE
31
+ URL -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
32
+ }
33
+ }
34
+
35
+ companion object {
36
+ fun fromWire(value: String?): PromptKeyboardType = when (value) {
37
+ "email-address" -> EMAIL_ADDRESS
38
+ "numeric" -> NUMERIC
39
+ "phone-pad" -> PHONE_PAD
40
+ "url" -> URL
41
+ else -> DEFAULT
42
+ }
43
+ }
44
+ }
45
+
46
+ internal data class PromptOptions(
47
+ val options: List<String>,
48
+ val cancelButtonIndex: Int?,
49
+ val destructiveButtonIndices: Set<Int>,
50
+ val disabledButtonIndices: Set<Int>,
51
+ val title: String?,
52
+ val message: String?,
53
+ val placeholder: String?,
54
+ val defaultValue: String?,
55
+ val keyboardType: PromptKeyboardType,
56
+ val secureTextEntry: Boolean,
57
+ val tintColor: String?,
58
+ val cancelButtonTintColor: String?,
59
+ val destructiveColor: String?,
60
+ val buttonTextAlignment: ButtonTextAlignment,
61
+ val userInterfaceStyle: ForcedAppearance,
62
+ ) {
63
+ /// The prompt reuses the sheet's content and dialog code, which are written
64
+ /// against ActionSheetOptions. Presentation is fixed to centered: UIKit has
65
+ /// no text field in an action sheet, so an anchored prompt has no iOS
66
+ /// counterpart and would break the "same options behave the same way" rule.
67
+ fun toSheetOptions(): ActionSheetOptions = ActionSheetOptions(
68
+ options = options,
69
+ cancelButtonIndex = cancelButtonIndex,
70
+ destructiveButtonIndices = destructiveButtonIndices,
71
+ title = title,
72
+ message = message,
73
+ tintColor = tintColor,
74
+ cancelButtonTintColor = cancelButtonTintColor,
75
+ destructiveColor = destructiveColor,
76
+ buttonTextAlignment = buttonTextAlignment,
77
+ disabledButtonIndices = disabledButtonIndices,
78
+ userInterfaceStyle = userInterfaceStyle,
79
+ presentationStyle = PresentationStyle.CENTERED,
80
+ anchorRect = null,
81
+ anchorAlignment = AnchorAlignment.START,
82
+ )
83
+
84
+ companion object {
85
+ fun fromReadableMap(map: ReadableMap): PromptOptions {
86
+ val labels = mutableListOf<String>()
87
+ map.getArray("options")?.let { array ->
88
+ for (index in 0 until array.size()) {
89
+ array.getString(index)?.let(labels::add)
90
+ }
91
+ }
92
+
93
+ return PromptOptions(
94
+ options = labels,
95
+ cancelButtonIndex = optInt(map, "cancelButtonIndex"),
96
+ destructiveButtonIndices = optIndices(map, "destructiveButtonIndices"),
97
+ disabledButtonIndices = optIndices(map, "disabledButtonIndices"),
98
+ title = optString(map, "title"),
99
+ message = optString(map, "message"),
100
+ placeholder = optString(map, "placeholder"),
101
+ defaultValue = optString(map, "defaultValue"),
102
+ keyboardType = PromptKeyboardType.fromWire(optString(map, "keyboardType")),
103
+ secureTextEntry = optBoolean(map, "secureTextEntry"),
104
+ tintColor = optString(map, "tintColor"),
105
+ cancelButtonTintColor = optString(map, "cancelButtonTintColor"),
106
+ destructiveColor = optString(map, "destructiveColor"),
107
+ buttonTextAlignment = ButtonTextAlignment.fromWire(optString(map, "buttonTextAlignment")),
108
+ userInterfaceStyle = ForcedAppearance.fromWire(optString(map, "userInterfaceStyle")),
109
+ )
110
+ }
111
+
112
+ private fun optIndices(map: ReadableMap, key: String): Set<Int> {
113
+ val indices = mutableSetOf<Int>()
114
+ map.getArray(key)?.let { array ->
115
+ for (index in 0 until array.size()) {
116
+ indices.add(array.getInt(index))
117
+ }
118
+ }
119
+
120
+ return indices
121
+ }
122
+
123
+ private fun optInt(map: ReadableMap, key: String): Int? =
124
+ if (map.hasKey(key) && !map.isNull(key)) map.getInt(key) else null
125
+
126
+ private fun optBoolean(map: ReadableMap, key: String): Boolean =
127
+ map.hasKey(key) && !map.isNull(key) && map.getBoolean(key)
128
+
129
+ private fun optString(map: ReadableMap, key: String): String? =
130
+ if (map.hasKey(key) && !map.isNull(key)) map.getString(key) else null
131
+ }
132
+ }
@@ -6,9 +6,11 @@ import android.util.TypedValue
6
6
  import android.view.Gravity
7
7
  import android.view.View
8
8
  import android.widget.Button
9
+ import android.widget.EditText
9
10
  import android.widget.LinearLayout
10
11
  import android.widget.TextView
11
12
  import androidx.appcompat.content.res.AppCompatResources
13
+ import androidx.appcompat.widget.AppCompatEditText
12
14
  import androidx.core.graphics.ColorUtils
13
15
  import androidx.core.view.AccessibilityDelegateCompat
14
16
  import androidx.core.view.ViewCompat
@@ -22,6 +24,8 @@ internal fun buildContent(
22
24
  options: ActionSheetOptions,
23
25
  palette: SheetPalette,
24
26
  includeCancelRow: Boolean = true,
27
+ /// Sits between the message and the rows. Only the prompt supplies one.
28
+ inputView: View? = null,
25
29
  onSelect: (Int) -> Unit,
26
30
  ): LinearLayout {
27
31
  val container = LinearLayout(context).apply {
@@ -36,6 +40,8 @@ internal fun buildContent(
36
40
  container.addView(buildHeader(context, it, palette.secondaryText, isTitle = false))
37
41
  }
38
42
 
43
+ inputView?.let(container::addView)
44
+
39
45
  val centerLabels = options.buttonTextAlignment == ButtonTextAlignment.CENTER
40
46
 
41
47
  val optionColor = parseColor(options.tintColor) ?: palette.primaryText
@@ -96,6 +102,31 @@ internal fun buildContent(
96
102
  return container
97
103
  }
98
104
 
105
+ internal fun buildPromptInput(
106
+ context: Context,
107
+ options: PromptOptions,
108
+ palette: SheetPalette,
109
+ ): EditText = AppCompatEditText(context).apply {
110
+ setText(options.defaultValue.orEmpty())
111
+ hint = options.placeholder
112
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
113
+ setTextColor(palette.primaryText)
114
+ setHintTextColor(ColorUtils.setAlphaComponent(palette.secondaryText, DISABLED_TEXT_ALPHA))
115
+ setPadding(dp(context, 16), dp(context, 8), dp(context, 16), dp(context, 8))
116
+ isSingleLine = true
117
+ // After isSingleLine, never before: setSingleLine() installs
118
+ // SingleLineTransformationMethod, which replaces the password masking that
119
+ // setInputType() sets up. Ordering them the other way renders a secure field
120
+ // in plain text while still reporting a password input type.
121
+ inputType = options.keyboardType.toInputType(options.secureTextEntry)
122
+ // Land the caret after any default value rather than before it.
123
+ setSelection(text?.length ?: 0)
124
+ layoutParams = LinearLayout.LayoutParams(
125
+ LinearLayout.LayoutParams.MATCH_PARENT,
126
+ LinearLayout.LayoutParams.WRAP_CONTENT,
127
+ )
128
+ }
129
+
99
130
  private fun buildHeader(
100
131
  context: Context,
101
132
  text: String,
@@ -83,6 +83,60 @@ internal object CenteredDialogPresenter : SheetPresenter {
83
83
  }
84
84
  }
85
85
 
86
+ /// The centered dialog with a text field above the buttons. Deliberately not a
87
+ /// SheetPresenter: it takes PromptOptions and hands the field's text back with
88
+ /// the index, which the sheet interface has no place for.
89
+ internal fun buildPromptDialog(
90
+ activity: Activity,
91
+ options: PromptOptions,
92
+ onSelect: (Dialog, Int, String) -> Unit,
93
+ ): Pair<Dialog, () -> String> {
94
+ val isDark = isDarkAppearance(activity, options.userInterfaceStyle)
95
+ val palette = paletteFor(isDark)
96
+ val dialog = AppCompatDialog(activity, dialogTheme(isDark))
97
+ dialog.supportRequestWindowFeature(Window.FEATURE_NO_TITLE)
98
+
99
+ val context: Context = dialog.context
100
+ val input = buildPromptInput(context, options, palette)
101
+ val currentText = { input.text?.toString().orEmpty() }
102
+
103
+ val container = buildContent(
104
+ context = context,
105
+ options = options.toSheetOptions(),
106
+ palette = palette,
107
+ inputView = input,
108
+ ) { index -> onSelect(dialog, index, currentText()) }
109
+
110
+ dialog.setContentView(container)
111
+ dialog.setCanceledOnTouchOutside(true)
112
+
113
+ val background = GradientDrawable().apply {
114
+ cornerRadius = dp(context, PROMPT_CORNER_RADIUS_DP).toFloat()
115
+ setColor(palette.surface)
116
+ }
117
+ dialog.window?.setBackgroundDrawable(background)
118
+ container.background = background.constantState?.newDrawable() ?: background
119
+ container.clipToOutline = true
120
+
121
+ val metrics = context.resources.displayMetrics
122
+ val width = (metrics.widthPixels - 2 * dp(context, PROMPT_MARGIN_DP))
123
+ .coerceAtMost(dp(context, PROMPT_MAX_WIDTH_DP))
124
+ .coerceAtLeast(minOf(dp(context, PROMPT_MIN_WIDTH_DP), metrics.widthPixels))
125
+ dialog.window?.setLayout(width, WindowManager.LayoutParams.WRAP_CONTENT)
126
+
127
+ // A prompt exists to be typed into, so raise the keyboard with it rather than
128
+ // making the user tap the field first.
129
+ dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
130
+ input.requestFocus()
131
+
132
+ return dialog to currentText
133
+ }
134
+
135
+ private const val PROMPT_CORNER_RADIUS_DP = 28
136
+ private const val PROMPT_MARGIN_DP = 24
137
+ private const val PROMPT_MIN_WIDTH_DP = 280
138
+ private const val PROMPT_MAX_WIDTH_DP = 560
139
+
86
140
  internal class AnchoredDialogPresenter(private val anchorRect: Rect) : SheetPresenter {
87
141
  override fun build(
88
142
  activity: Activity,
@@ -2,6 +2,7 @@ package com.unifiedactionsheet
2
2
 
3
3
  import android.app.Activity
4
4
  import android.app.Dialog
5
+ import com.facebook.react.bridge.Arguments
5
6
  import com.facebook.react.bridge.LifecycleEventListener
6
7
  import com.facebook.react.bridge.Promise
7
8
  import com.facebook.react.bridge.ReactApplicationContext
@@ -37,6 +38,19 @@ class UnifiedActionSheetModule(reactContext: ReactApplicationContext) :
37
38
  }
38
39
  }
39
40
 
41
+ override fun showPromptWithOptions(options: ReadableMap, promise: Promise) {
42
+ val parsed = PromptOptions.fromReadableMap(options)
43
+ val activity = reactApplicationContext.currentActivity
44
+ ?: return promise.reject(
45
+ "E_NO_ACTIVITY",
46
+ "No current activity to attach the prompt to.",
47
+ )
48
+
49
+ UiThreadUtil.runOnUiThread {
50
+ presentPrompt(activity, parsed, promise)
51
+ }
52
+ }
53
+
40
54
  override fun dismissActionSheet() {
41
55
  UiThreadUtil.runOnUiThread {
42
56
  openDialogs.lastOrNull()?.let { dialog ->
@@ -118,6 +132,47 @@ class UnifiedActionSheetModule(reactContext: ReactApplicationContext) :
118
132
  dialog.show()
119
133
  }
120
134
 
135
+ /// Mirrors presentSheet exactly, except the resolved value carries the text
136
+ /// as well as the index. The dialog joins the same openDialogs registry, so
137
+ /// dismissActionSheet()/dismissAllActionSheets() close prompts too.
138
+ private fun presentPrompt(
139
+ activity: Activity,
140
+ options: PromptOptions,
141
+ promise: Promise,
142
+ ) {
143
+ val resolved = AtomicBoolean(false)
144
+ val resolveOnce: (Int?, String) -> Unit = { index, text ->
145
+ if (resolved.compareAndSet(false, true)) {
146
+ promise.resolve(
147
+ Arguments.createMap().apply {
148
+ putInt("buttonIndex", index ?: -1)
149
+ putString("text", text)
150
+ },
151
+ )
152
+ }
153
+ }
154
+
155
+ val (dialog, currentText) = buildPromptDialog(activity, options) { presented, index, text ->
156
+ presented.dismiss()
157
+ resolveOnce(index, text)
158
+ }
159
+ openDialogs.add(dialog)
160
+
161
+ // Read the field at dismissal time: what the user typed survives a backdrop
162
+ // tap, so a caller can still recover a draft.
163
+ dialog.setOnCancelListener { resolveOnce(options.cancelButtonIndex, currentText()) }
164
+ dialog.setOnDismissListener {
165
+ openDialogs.remove(dialog)
166
+ if (dismissedByApi.remove(dialog)) {
167
+ resolveOnce(DISMISSED_BY_API, currentText())
168
+ } else {
169
+ resolveOnce(options.cancelButtonIndex, currentText())
170
+ }
171
+ }
172
+
173
+ dialog.show()
174
+ }
175
+
121
176
  companion object {
122
177
  const val NAME = NativeUnifiedActionSheetSpec.NAME
123
178
 
@@ -73,6 +73,53 @@ RCT_EXPORT_METHOD(showActionSheetWithOptions
73
73
  });
74
74
  }
75
75
 
76
+ RCT_EXPORT_METHOD(showPromptWithOptions
77
+ : (JS::NativeUnifiedActionSheet::SpecShowPromptWithOptionsOptions &)options resolve
78
+ : (RCTPromiseResolveBlock)resolve reject
79
+ : (RCTPromiseRejectBlock)reject)
80
+ {
81
+ NSMutableDictionary *payload = [NSMutableDictionary new];
82
+
83
+ payload[@"options"] = RCTConvertVecToArray(options.options(), ^id(NSString *element) {
84
+ return element;
85
+ });
86
+
87
+ if (options.cancelButtonIndex()) {
88
+ payload[@"cancelButtonIndex"] = @(*options.cancelButtonIndex());
89
+ }
90
+ if (options.destructiveButtonIndices()) {
91
+ payload[@"destructiveButtonIndices"] =
92
+ RCTConvertVecToArray(*options.destructiveButtonIndices(), ^id(double element) {
93
+ return @(element);
94
+ });
95
+ }
96
+ if (options.disabledButtonIndices()) {
97
+ payload[@"disabledButtonIndices"] = RCTConvertVecToArray(*options.disabledButtonIndices(), ^id(double element) {
98
+ return @(element);
99
+ });
100
+ }
101
+ if (options.secureTextEntry()) {
102
+ payload[@"secureTextEntry"] = @(*options.secureTextEntry());
103
+ }
104
+
105
+ payload[@"title"] = options.title();
106
+ payload[@"message"] = options.message();
107
+ payload[@"placeholder"] = options.placeholder();
108
+ payload[@"defaultValue"] = options.defaultValue();
109
+ payload[@"keyboardType"] = options.keyboardType();
110
+ payload[@"tintColor"] = options.tintColor();
111
+ payload[@"cancelButtonTintColor"] = options.cancelButtonTintColor();
112
+ payload[@"destructiveColor"] = options.destructiveColor();
113
+ payload[@"userInterfaceStyle"] = options.userInterfaceStyle();
114
+
115
+ dispatch_async(dispatch_get_main_queue(), ^{
116
+ [UnifiedActionSheetImpl.shared showPromptWithOptions:payload
117
+ completion:^(NSInteger buttonIndex, NSString *text) {
118
+ resolve(@{@"buttonIndex" : @(buttonIndex), @"text" : text});
119
+ }];
120
+ });
121
+ }
122
+
76
123
  RCT_EXPORT_METHOD(dismissActionSheet)
77
124
  {
78
125
  dispatch_async(dispatch_get_main_queue(), ^{