orn-ui 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -2
- package/lib/commonjs/atoms/PressableScale.js +9 -10
- package/lib/commonjs/atoms/PressableScale.js.map +1 -1
- package/lib/commonjs/atoms/Skeleton.js +96 -0
- package/lib/commonjs/atoms/Skeleton.js.map +1 -0
- package/lib/commonjs/atoms/Transition.js +171 -0
- package/lib/commonjs/atoms/Transition.js.map +1 -0
- package/lib/commonjs/atoms/index.js +20 -0
- package/lib/commonjs/atoms/index.js.map +1 -1
- package/lib/commonjs/molecules/OptionCard.js +55 -22
- package/lib/commonjs/molecules/OptionCard.js.map +1 -1
- package/lib/commonjs/molecules/{ThemeToggle.js → SegmentedControl.js} +36 -36
- package/lib/commonjs/molecules/SegmentedControl.js.map +1 -0
- package/lib/commonjs/molecules/Stepper.js +124 -47
- package/lib/commonjs/molecules/Stepper.js.map +1 -1
- package/lib/commonjs/molecules/Steps.js +36 -6
- package/lib/commonjs/molecules/Steps.js.map +1 -1
- package/lib/commonjs/molecules/index.js +9 -3
- package/lib/commonjs/molecules/index.js.map +1 -1
- package/lib/commonjs/organisms/AlertProvider.js +26 -0
- package/lib/commonjs/organisms/AlertProvider.js.map +1 -1
- package/lib/commonjs/organisms/BottomSheet.js +118 -39
- package/lib/commonjs/organisms/BottomSheet.js.map +1 -1
- package/lib/commonjs/organisms/DateField.js +46 -15
- package/lib/commonjs/organisms/DateField.js.map +1 -1
- package/lib/commonjs/organisms/DatePicker.js +129 -50
- package/lib/commonjs/organisms/DatePicker.js.map +1 -1
- package/lib/commonjs/organisms/List.js +66 -44
- package/lib/commonjs/organisms/List.js.map +1 -1
- package/lib/commonjs/organisms/Modal.js +42 -87
- package/lib/commonjs/organisms/Modal.js.map +1 -1
- package/lib/commonjs/organisms/NavigationBar.js +210 -0
- package/lib/commonjs/organisms/NavigationBar.js.map +1 -0
- package/lib/commonjs/organisms/Screen.js +9 -2
- package/lib/commonjs/organisms/Screen.js.map +1 -1
- package/lib/commonjs/organisms/SearchList.js +95 -27
- package/lib/commonjs/organisms/SearchList.js.map +1 -1
- package/lib/commonjs/organisms/Select.js +222 -50
- package/lib/commonjs/organisms/Select.js.map +1 -1
- package/lib/commonjs/organisms/ThemeToggle.js +52 -0
- package/lib/commonjs/organisms/ThemeToggle.js.map +1 -0
- package/lib/commonjs/organisms/Toast.js +8 -22
- package/lib/commonjs/organisms/Toast.js.map +1 -1
- package/lib/commonjs/organisms/ToastProvider.js +24 -0
- package/lib/commonjs/organisms/ToastProvider.js.map +1 -1
- package/lib/commonjs/organisms/Wizard.js +23 -2
- package/lib/commonjs/organisms/Wizard.js.map +1 -1
- package/lib/commonjs/organisms/imperativeBridge.js +28 -0
- package/lib/commonjs/organisms/imperativeBridge.js.map +1 -0
- package/lib/commonjs/organisms/index.js +50 -0
- package/lib/commonjs/organisms/index.js.map +1 -1
- package/lib/commonjs/theme/colors.js +207 -0
- package/lib/commonjs/theme/colors.js.map +1 -0
- package/lib/commonjs/theme/createTheme.js +25 -2
- package/lib/commonjs/theme/createTheme.js.map +1 -1
- package/lib/commonjs/theme/index.js +25 -0
- package/lib/commonjs/theme/index.js.map +1 -1
- package/lib/commonjs/theme/labels.js +5 -1
- package/lib/commonjs/theme/labels.js.map +1 -1
- package/lib/commonjs/theme/palettes.js +5 -2
- package/lib/commonjs/theme/palettes.js.map +1 -1
- package/lib/module/atoms/PressableScale.js +9 -10
- package/lib/module/atoms/PressableScale.js.map +1 -1
- package/lib/module/atoms/Skeleton.js +91 -0
- package/lib/module/atoms/Skeleton.js.map +1 -0
- package/lib/module/atoms/Transition.js +165 -0
- package/lib/module/atoms/Transition.js.map +1 -0
- package/lib/module/atoms/index.js +2 -0
- package/lib/module/atoms/index.js.map +1 -1
- package/lib/module/molecules/OptionCard.js +56 -23
- package/lib/module/molecules/OptionCard.js.map +1 -1
- package/lib/module/molecules/{ThemeToggle.js → SegmentedControl.js} +36 -36
- package/lib/module/molecules/SegmentedControl.js.map +1 -0
- package/lib/module/molecules/Stepper.js +125 -49
- package/lib/module/molecules/Stepper.js.map +1 -1
- package/lib/module/molecules/Steps.js +37 -7
- package/lib/module/molecules/Steps.js.map +1 -1
- package/lib/module/molecules/index.js +2 -2
- package/lib/module/molecules/index.js.map +1 -1
- package/lib/module/organisms/AlertProvider.js +24 -1
- package/lib/module/organisms/AlertProvider.js.map +1 -1
- package/lib/module/organisms/BottomSheet.js +120 -41
- package/lib/module/organisms/BottomSheet.js.map +1 -1
- package/lib/module/organisms/DateField.js +49 -18
- package/lib/module/organisms/DateField.js.map +1 -1
- package/lib/module/organisms/DatePicker.js +131 -52
- package/lib/module/organisms/DatePicker.js.map +1 -1
- package/lib/module/organisms/List.js +68 -46
- package/lib/module/organisms/List.js.map +1 -1
- package/lib/module/organisms/Modal.js +44 -89
- package/lib/module/organisms/Modal.js.map +1 -1
- package/lib/module/organisms/NavigationBar.js +205 -0
- package/lib/module/organisms/NavigationBar.js.map +1 -0
- package/lib/module/organisms/Screen.js +9 -2
- package/lib/module/organisms/Screen.js.map +1 -1
- package/lib/module/organisms/SearchList.js +96 -28
- package/lib/module/organisms/SearchList.js.map +1 -1
- package/lib/module/organisms/Select.js +224 -52
- package/lib/module/organisms/Select.js.map +1 -1
- package/lib/module/organisms/ThemeToggle.js +47 -0
- package/lib/module/organisms/ThemeToggle.js.map +1 -0
- package/lib/module/organisms/Toast.js +10 -24
- package/lib/module/organisms/Toast.js.map +1 -1
- package/lib/module/organisms/ToastProvider.js +21 -0
- package/lib/module/organisms/ToastProvider.js.map +1 -1
- package/lib/module/organisms/Wizard.js +24 -3
- package/lib/module/organisms/Wizard.js.map +1 -1
- package/lib/module/organisms/imperativeBridge.js +24 -0
- package/lib/module/organisms/imperativeBridge.js.map +1 -0
- package/lib/module/organisms/index.js +4 -2
- package/lib/module/organisms/index.js.map +1 -1
- package/lib/module/theme/colors.js +194 -0
- package/lib/module/theme/colors.js.map +1 -0
- package/lib/module/theme/createTheme.js +25 -2
- package/lib/module/theme/createTheme.js.map +1 -1
- package/lib/module/theme/index.js +1 -0
- package/lib/module/theme/index.js.map +1 -1
- package/lib/module/theme/labels.js +5 -1
- package/lib/module/theme/labels.js.map +1 -1
- package/lib/module/theme/palettes.js +5 -2
- package/lib/module/theme/palettes.js.map +1 -1
- package/lib/typescript/atoms/PressableScale.d.ts +3 -2
- package/lib/typescript/atoms/PressableScale.d.ts.map +1 -1
- package/lib/typescript/atoms/Skeleton.d.ts +29 -0
- package/lib/typescript/atoms/Skeleton.d.ts.map +1 -0
- package/lib/typescript/atoms/Transition.d.ts +51 -0
- package/lib/typescript/atoms/Transition.d.ts.map +1 -0
- package/lib/typescript/atoms/index.d.ts +4 -0
- package/lib/typescript/atoms/index.d.ts.map +1 -1
- package/lib/typescript/molecules/OptionCard.d.ts +11 -6
- package/lib/typescript/molecules/OptionCard.d.ts.map +1 -1
- package/lib/typescript/molecules/SegmentedControl.d.ts +24 -0
- package/lib/typescript/molecules/SegmentedControl.d.ts.map +1 -0
- package/lib/typescript/molecules/Stepper.d.ts +35 -3
- package/lib/typescript/molecules/Stepper.d.ts.map +1 -1
- package/lib/typescript/molecules/Steps.d.ts.map +1 -1
- package/lib/typescript/molecules/index.d.ts +4 -4
- package/lib/typescript/molecules/index.d.ts.map +1 -1
- package/lib/typescript/organisms/AlertProvider.d.ts +9 -0
- package/lib/typescript/organisms/AlertProvider.d.ts.map +1 -1
- package/lib/typescript/organisms/BottomSheet.d.ts +26 -4
- package/lib/typescript/organisms/BottomSheet.d.ts.map +1 -1
- package/lib/typescript/organisms/DateField.d.ts +23 -5
- package/lib/typescript/organisms/DateField.d.ts.map +1 -1
- package/lib/typescript/organisms/DatePicker.d.ts +5 -1
- package/lib/typescript/organisms/DatePicker.d.ts.map +1 -1
- package/lib/typescript/organisms/List.d.ts +9 -2
- package/lib/typescript/organisms/List.d.ts.map +1 -1
- package/lib/typescript/organisms/Modal.d.ts +3 -5
- package/lib/typescript/organisms/Modal.d.ts.map +1 -1
- package/lib/typescript/organisms/NavigationBar.d.ts +32 -0
- package/lib/typescript/organisms/NavigationBar.d.ts.map +1 -0
- package/lib/typescript/organisms/Screen.d.ts +2 -1
- package/lib/typescript/organisms/Screen.d.ts.map +1 -1
- package/lib/typescript/organisms/SearchList.d.ts +21 -5
- package/lib/typescript/organisms/SearchList.d.ts.map +1 -1
- package/lib/typescript/organisms/Select.d.ts +19 -4
- package/lib/typescript/organisms/Select.d.ts.map +1 -1
- package/lib/typescript/{molecules → organisms}/ThemeToggle.d.ts +4 -4
- package/lib/typescript/organisms/ThemeToggle.d.ts.map +1 -0
- package/lib/typescript/organisms/Toast.d.ts.map +1 -1
- package/lib/typescript/organisms/ToastProvider.d.ts +8 -0
- package/lib/typescript/organisms/ToastProvider.d.ts.map +1 -1
- package/lib/typescript/organisms/Wizard.d.ts +7 -1
- package/lib/typescript/organisms/Wizard.d.ts.map +1 -1
- package/lib/typescript/organisms/imperativeBridge.d.ts +12 -0
- package/lib/typescript/organisms/imperativeBridge.d.ts.map +1 -0
- package/lib/typescript/organisms/index.d.ts +7 -3
- package/lib/typescript/organisms/index.d.ts.map +1 -1
- package/lib/typescript/theme/colors.d.ts +44 -0
- package/lib/typescript/theme/colors.d.ts.map +1 -0
- package/lib/typescript/theme/createTheme.d.ts +3 -0
- package/lib/typescript/theme/createTheme.d.ts.map +1 -1
- package/lib/typescript/theme/index.d.ts +3 -1
- package/lib/typescript/theme/index.d.ts.map +1 -1
- package/lib/typescript/theme/labels.d.ts +6 -0
- package/lib/typescript/theme/labels.d.ts.map +1 -1
- package/lib/typescript/theme/palettes.d.ts.map +1 -1
- package/lib/typescript/theme/types.d.ts +9 -0
- package/lib/typescript/theme/types.d.ts.map +1 -1
- package/package.json +67 -6
- package/registry/alert-provider.json +9 -2
- package/registry/bottom-sheet.json +6 -2
- package/registry/core.json +9 -5
- package/registry/date-field.json +2 -1
- package/registry/date-picker.json +1 -1
- package/registry/index.json +99 -30
- package/registry/list.json +2 -1
- package/registry/manifest.json +44 -6
- package/registry/modal.json +3 -2
- package/registry/navigation-bar.json +18 -0
- package/registry/option-card.json +3 -2
- package/registry/pressable-scale.json +1 -1
- package/registry/screen.json +1 -1
- package/registry/search-list.json +2 -1
- package/registry/segmented-control.json +18 -0
- package/registry/select.json +1 -1
- package/registry/skeleton.json +18 -0
- package/registry/stepper.json +3 -2
- package/registry/steps.json +1 -1
- package/registry/theme-toggle.json +5 -4
- package/registry/toast-provider.json +11 -3
- package/registry/toast.json +3 -2
- package/registry/transition.json +19 -0
- package/registry/wizard.json +3 -2
- package/src/atoms/PressableScale.tsx +16 -6
- package/src/atoms/Skeleton.tsx +114 -0
- package/src/atoms/Transition.tsx +189 -0
- package/src/atoms/index.ts +4 -0
- package/src/molecules/OptionCard.tsx +62 -25
- package/src/molecules/SegmentedControl.tsx +84 -0
- package/src/molecules/Stepper.tsx +147 -32
- package/src/molecules/Steps.tsx +27 -8
- package/src/molecules/index.ts +4 -4
- package/src/organisms/AlertProvider.tsx +26 -1
- package/src/organisms/BottomSheet.tsx +214 -62
- package/src/organisms/DateField.tsx +79 -19
- package/src/organisms/DatePicker.tsx +198 -80
- package/src/organisms/List.tsx +73 -41
- package/src/organisms/Modal.tsx +49 -61
- package/src/organisms/NavigationBar.tsx +226 -0
- package/src/organisms/Screen.tsx +11 -3
- package/src/organisms/SearchList.tsx +131 -35
- package/src/organisms/Select.tsx +223 -45
- package/src/organisms/ThemeToggle.tsx +30 -0
- package/src/organisms/Toast.tsx +8 -21
- package/src/organisms/ToastProvider.tsx +24 -0
- package/src/organisms/Wizard.tsx +37 -3
- package/src/organisms/imperativeBridge.ts +28 -0
- package/src/organisms/index.ts +7 -3
- package/src/theme/colors.ts +175 -0
- package/src/theme/createTheme.ts +38 -4
- package/src/theme/index.ts +3 -1
- package/src/theme/labels.ts +13 -0
- package/src/theme/palettes.ts +5 -2
- package/src/theme/types.ts +10 -0
- package/lib/commonjs/molecules/ThemeToggle.js.map +0 -1
- package/lib/module/molecules/ThemeToggle.js.map +0 -1
- package/lib/typescript/molecules/ThemeToggle.d.ts.map +0 -1
- package/src/molecules/ThemeToggle.tsx +0 -67
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orn-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Fast, tree-shakeable, atomic-design component library for React Native. Zero runtime dependencies.",
|
|
5
|
+
"author": "David Trujillo <davot098@gmail.com>",
|
|
5
6
|
"main": "lib/commonjs/index.js",
|
|
6
7
|
"module": "lib/module/index.js",
|
|
7
8
|
"types": "lib/typescript/index.d.ts",
|
|
@@ -100,6 +101,21 @@
|
|
|
100
101
|
"react-native": "./src/atoms/Spinner.tsx",
|
|
101
102
|
"default": "./lib/module/atoms/Spinner.js"
|
|
102
103
|
},
|
|
104
|
+
"./skeleton": {
|
|
105
|
+
"types": "./lib/typescript/atoms/Skeleton.d.ts",
|
|
106
|
+
"react-native": "./src/atoms/Skeleton.tsx",
|
|
107
|
+
"default": "./lib/module/atoms/Skeleton.js"
|
|
108
|
+
},
|
|
109
|
+
"./transition": {
|
|
110
|
+
"types": "./lib/typescript/atoms/Transition.d.ts",
|
|
111
|
+
"react-native": "./src/atoms/Transition.tsx",
|
|
112
|
+
"default": "./lib/module/atoms/Transition.js"
|
|
113
|
+
},
|
|
114
|
+
"./use-reduce-motion": {
|
|
115
|
+
"types": "./lib/typescript/atoms/Transition.d.ts",
|
|
116
|
+
"react-native": "./src/atoms/Transition.tsx",
|
|
117
|
+
"default": "./lib/module/atoms/Transition.js"
|
|
118
|
+
},
|
|
103
119
|
"./empty-state": {
|
|
104
120
|
"types": "./lib/typescript/atoms/EmptyState.d.ts",
|
|
105
121
|
"react-native": "./src/atoms/EmptyState.tsx",
|
|
@@ -125,6 +141,11 @@
|
|
|
125
141
|
"react-native": "./src/molecules/Stepper.tsx",
|
|
126
142
|
"default": "./lib/module/molecules/Stepper.js"
|
|
127
143
|
},
|
|
144
|
+
"./sanitize-numeric": {
|
|
145
|
+
"types": "./lib/typescript/molecules/Stepper.d.ts",
|
|
146
|
+
"react-native": "./src/molecules/Stepper.tsx",
|
|
147
|
+
"default": "./lib/module/molecules/Stepper.js"
|
|
148
|
+
},
|
|
128
149
|
"./option-card": {
|
|
129
150
|
"types": "./lib/typescript/molecules/OptionCard.d.ts",
|
|
130
151
|
"react-native": "./src/molecules/OptionCard.tsx",
|
|
@@ -145,10 +166,10 @@
|
|
|
145
166
|
"react-native": "./src/molecules/AvatarHeader.tsx",
|
|
146
167
|
"default": "./lib/module/molecules/AvatarHeader.js"
|
|
147
168
|
},
|
|
148
|
-
"./
|
|
149
|
-
"types": "./lib/typescript/molecules/
|
|
150
|
-
"react-native": "./src/molecules/
|
|
151
|
-
"default": "./lib/module/molecules/
|
|
169
|
+
"./segmented-control": {
|
|
170
|
+
"types": "./lib/typescript/molecules/SegmentedControl.d.ts",
|
|
171
|
+
"react-native": "./src/molecules/SegmentedControl.tsx",
|
|
172
|
+
"default": "./lib/module/molecules/SegmentedControl.js"
|
|
152
173
|
},
|
|
153
174
|
"./steps": {
|
|
154
175
|
"types": "./lib/typescript/molecules/Steps.d.ts",
|
|
@@ -185,6 +206,21 @@
|
|
|
185
206
|
"react-native": "./src/organisms/AlertProvider.tsx",
|
|
186
207
|
"default": "./lib/module/organisms/AlertProvider.js"
|
|
187
208
|
},
|
|
209
|
+
"./show-alert": {
|
|
210
|
+
"types": "./lib/typescript/organisms/AlertProvider.d.ts",
|
|
211
|
+
"react-native": "./src/organisms/AlertProvider.tsx",
|
|
212
|
+
"default": "./lib/module/organisms/AlertProvider.js"
|
|
213
|
+
},
|
|
214
|
+
"./show-confirm": {
|
|
215
|
+
"types": "./lib/typescript/organisms/AlertProvider.d.ts",
|
|
216
|
+
"react-native": "./src/organisms/AlertProvider.tsx",
|
|
217
|
+
"default": "./lib/module/organisms/AlertProvider.js"
|
|
218
|
+
},
|
|
219
|
+
"./hide-alert": {
|
|
220
|
+
"types": "./lib/typescript/organisms/AlertProvider.d.ts",
|
|
221
|
+
"react-native": "./src/organisms/AlertProvider.tsx",
|
|
222
|
+
"default": "./lib/module/organisms/AlertProvider.js"
|
|
223
|
+
},
|
|
188
224
|
"./screen": {
|
|
189
225
|
"types": "./lib/typescript/organisms/Screen.d.ts",
|
|
190
226
|
"react-native": "./src/organisms/Screen.tsx",
|
|
@@ -215,6 +251,21 @@
|
|
|
215
251
|
"react-native": "./src/organisms/ToastProvider.tsx",
|
|
216
252
|
"default": "./lib/module/organisms/ToastProvider.js"
|
|
217
253
|
},
|
|
254
|
+
"./show-toast": {
|
|
255
|
+
"types": "./lib/typescript/organisms/ToastProvider.d.ts",
|
|
256
|
+
"react-native": "./src/organisms/ToastProvider.tsx",
|
|
257
|
+
"default": "./lib/module/organisms/ToastProvider.js"
|
|
258
|
+
},
|
|
259
|
+
"./hide-toast": {
|
|
260
|
+
"types": "./lib/typescript/organisms/ToastProvider.d.ts",
|
|
261
|
+
"react-native": "./src/organisms/ToastProvider.tsx",
|
|
262
|
+
"default": "./lib/module/organisms/ToastProvider.js"
|
|
263
|
+
},
|
|
264
|
+
"./hide-all-toasts": {
|
|
265
|
+
"types": "./lib/typescript/organisms/ToastProvider.d.ts",
|
|
266
|
+
"react-native": "./src/organisms/ToastProvider.tsx",
|
|
267
|
+
"default": "./lib/module/organisms/ToastProvider.js"
|
|
268
|
+
},
|
|
218
269
|
"./date-picker": {
|
|
219
270
|
"types": "./lib/typescript/organisms/DatePicker.d.ts",
|
|
220
271
|
"react-native": "./src/organisms/DatePicker.tsx",
|
|
@@ -245,6 +296,16 @@
|
|
|
245
296
|
"react-native": "./src/organisms/Wizard.tsx",
|
|
246
297
|
"default": "./lib/module/organisms/Wizard.js"
|
|
247
298
|
},
|
|
299
|
+
"./theme-toggle": {
|
|
300
|
+
"types": "./lib/typescript/organisms/ThemeToggle.d.ts",
|
|
301
|
+
"react-native": "./src/organisms/ThemeToggle.tsx",
|
|
302
|
+
"default": "./lib/module/organisms/ThemeToggle.js"
|
|
303
|
+
},
|
|
304
|
+
"./navigation-bar": {
|
|
305
|
+
"types": "./lib/typescript/organisms/NavigationBar.d.ts",
|
|
306
|
+
"react-native": "./src/organisms/NavigationBar.tsx",
|
|
307
|
+
"default": "./lib/module/organisms/NavigationBar.js"
|
|
308
|
+
},
|
|
248
309
|
"./theme": {
|
|
249
310
|
"types": "./lib/typescript/theme/index.d.ts",
|
|
250
311
|
"react-native": "./src/theme/index.ts",
|
|
@@ -309,7 +370,7 @@
|
|
|
309
370
|
"url": "git+https://github.com/DavidTrujillo123/orn-ui.git",
|
|
310
371
|
"directory": "packages/ui"
|
|
311
372
|
},
|
|
312
|
-
"homepage": "https://
|
|
373
|
+
"homepage": "https://orn-ui-docs.vercel.app/",
|
|
313
374
|
"bugs": {
|
|
314
375
|
"url": "https://github.com/DavidTrujillo123/orn-ui/issues"
|
|
315
376
|
},
|
|
@@ -5,12 +5,19 @@
|
|
|
5
5
|
"category": "organisms",
|
|
6
6
|
"exportedNames": [
|
|
7
7
|
"AlertProvider",
|
|
8
|
-
"useAlert"
|
|
8
|
+
"useAlert",
|
|
9
|
+
"showAlert",
|
|
10
|
+
"showConfirm",
|
|
11
|
+
"hideAlert"
|
|
9
12
|
],
|
|
10
13
|
"files": [
|
|
11
14
|
{
|
|
12
15
|
"path": "organisms/AlertProvider.tsx",
|
|
13
|
-
"content": "import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';\nimport { Alert, type AlertButton, type AlertType } from './Alert';\n\nexport interface AlertOptions {\n title: string;\n message?: string;\n type?: AlertType;\n confirmText?: string;\n cancelText?: string;\n}\n\nexport interface ConfirmOptions extends AlertOptions {\n /** Botón de confirmar en rojo/destructivo (para \"eliminar\", etc). */\n destructive?: boolean;\n}\n\ninterface AlertContextValue {\n /** Muestra un alert de un solo botón. Resuelve cuando se cierra. */\n alert: (options: AlertOptions) => Promise<void>;\n /** Muestra confirmar/cancelar. Resuelve true si se confirmó, false si se canceló. */\n confirm: (options: ConfirmOptions) => Promise<boolean>;\n hide: () => void;\n}\n\nconst AlertContext = createContext<AlertContextValue | undefined>(undefined);\n\ninterface InternalState {\n visible: boolean;\n title: string;\n message?: string;\n type: AlertType;\n buttons: AlertButton[];\n}\n\nconst HIDDEN: InternalState = { visible: false, title: '', type: 'info', buttons: [] };\n\n/**\n * AlertProvider\n * Monta un único <Alert> controlado imperativamente. Colocar una vez cerca\n * de la raíz (dentro de UIProvider). Los handlers async pueden hacer\n * `const ok = await confirm({ title: 'Eliminar', destructive: true })`.\n */\nexport function AlertProvider({ children }: { children: React.ReactNode }) {\n const [state, setState] = useState<InternalState>(HIDDEN);\n const resolverRef = useRef<((value: any) => void) | null>(null);\n\n const hide = useCallback(() => {\n setState(HIDDEN);\n resolverRef.current?.(undefined);\n resolverRef.current = null;\n }, []);\n\n const alertFn = useCallback((options: AlertOptions): Promise<void> => {\n return new Promise((resolve) => {\n resolverRef.current = resolve;\n setState({\n visible: true,\n title: options.title,\n message: options.message,\n type: options.type ?? 'info',\n buttons: [\n {\n text: options.confirmText ?? 'OK',\n onPress: () => {\n setState(HIDDEN);\n resolve();\n resolverRef.current = null;\n },\n },\n ],\n });\n });\n }, []);\n\n const confirmFn = useCallback((options: ConfirmOptions): Promise<boolean> => {\n return new Promise((resolve) => {\n resolverRef.current = resolve;\n setState({\n visible: true,\n title: options.title,\n message: options.message,\n type: options.type ?? 'question',\n buttons: [\n {\n text: options.cancelText ?? 'Cancel',\n style: 'cancel',\n variant: 'outline',\n onPress: () => {\n setState(HIDDEN);\n resolve(false);\n resolverRef.current = null;\n },\n },\n {\n text: options.confirmText ?? 'OK',\n variant: options.destructive ? 'destructive' : 'primary',\n style: options.destructive ? 'destructive' : 'default',\n onPress: () => {\n setState(HIDDEN);\n resolve(true);\n resolverRef.current = null;\n },\n },\n ],\n });\n });\n }, []);\n\n const value = useMemo<AlertContextValue>(() => ({ alert: alertFn, confirm: confirmFn, hide }), [alertFn, confirmFn, hide]);\n\n return (\n <AlertContext.Provider value={value}>\n {children}\n <Alert visible={state.visible} title={state.title} message={state.message} type={state.type} buttons={state.buttons} onClose={hide} />\n </AlertContext.Provider>\n );\n}\n\nexport function useAlert(): AlertContextValue {\n const ctx = useContext(AlertContext);\n if (!ctx) {\n throw new Error('orn-ui: useAlert must be used within an <AlertProvider>.');\n }\n return ctx;\n}\n"
|
|
16
|
+
"content": "import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';\nimport { Alert, type AlertButton, type AlertType } from './Alert';\nimport { createImperativeBridge } from './imperativeBridge';\n\nexport interface AlertOptions {\n title: string;\n message?: string;\n type?: AlertType;\n confirmText?: string;\n cancelText?: string;\n}\n\nexport interface ConfirmOptions extends AlertOptions {\n /** Botón de confirmar en rojo/destructivo (para \"eliminar\", etc). */\n destructive?: boolean;\n}\n\ninterface AlertContextValue {\n /** Muestra un alert de un solo botón. Resuelve cuando se cierra. */\n alert: (options: AlertOptions) => Promise<void>;\n /** Muestra confirmar/cancelar. Resuelve true si se confirmó, false si se canceló. */\n confirm: (options: ConfirmOptions) => Promise<boolean>;\n hide: () => void;\n}\n\nconst AlertContext = createContext<AlertContextValue | undefined>(undefined);\nconst bridge = createImperativeBridge<AlertContextValue>('AlertProvider');\n\n/**\n * Abre un alert desde fuera del árbol de React —un servicio, un interceptor—\n * sin pasar por el hook. Sin <AlertProvider> montado resuelve de inmediato, así\n * que un `await` nunca queda colgado.\n */\nexport function showAlert(options: AlertOptions): Promise<void> {\n return bridge.get()?.alert(options) ?? Promise.resolve();\n}\n\n/** Igual que showAlert, pero resuelve true/false. Sin provider, false. */\nexport function showConfirm(options: ConfirmOptions): Promise<boolean> {\n return bridge.get()?.confirm(options) ?? Promise.resolve(false);\n}\n\nexport function hideAlert(): void {\n bridge.get()?.hide();\n}\n\ninterface InternalState {\n visible: boolean;\n title: string;\n message?: string;\n type: AlertType;\n buttons: AlertButton[];\n}\n\nconst HIDDEN: InternalState = { visible: false, title: '', type: 'info', buttons: [] };\n\n/**\n * AlertProvider\n * Monta un único <Alert> controlado imperativamente. Colocar una vez cerca\n * de la raíz (dentro de UIProvider). Los handlers async pueden hacer\n * `const ok = await confirm({ title: 'Eliminar', destructive: true })`.\n */\nexport function AlertProvider({ children }: { children: React.ReactNode }) {\n const [state, setState] = useState<InternalState>(HIDDEN);\n const resolverRef = useRef<((value: any) => void) | null>(null);\n\n const hide = useCallback(() => {\n setState(HIDDEN);\n resolverRef.current?.(undefined);\n resolverRef.current = null;\n }, []);\n\n const alertFn = useCallback((options: AlertOptions): Promise<void> => {\n return new Promise((resolve) => {\n resolverRef.current = resolve;\n setState({\n visible: true,\n title: options.title,\n message: options.message,\n type: options.type ?? 'info',\n buttons: [\n {\n text: options.confirmText ?? 'OK',\n onPress: () => {\n setState(HIDDEN);\n resolve();\n resolverRef.current = null;\n },\n },\n ],\n });\n });\n }, []);\n\n const confirmFn = useCallback((options: ConfirmOptions): Promise<boolean> => {\n return new Promise((resolve) => {\n resolverRef.current = resolve;\n setState({\n visible: true,\n title: options.title,\n message: options.message,\n type: options.type ?? 'question',\n buttons: [\n {\n text: options.cancelText ?? 'Cancel',\n style: 'cancel',\n variant: 'outline',\n onPress: () => {\n setState(HIDDEN);\n resolve(false);\n resolverRef.current = null;\n },\n },\n {\n text: options.confirmText ?? 'OK',\n variant: options.destructive ? 'destructive' : 'primary',\n style: options.destructive ? 'destructive' : 'default',\n onPress: () => {\n setState(HIDDEN);\n resolve(true);\n resolverRef.current = null;\n },\n },\n ],\n });\n });\n }, []);\n\n const value = useMemo<AlertContextValue>(() => ({ alert: alertFn, confirm: confirmFn, hide }), [alertFn, confirmFn, hide]);\n\n useEffect(() => {\n bridge.set(value);\n return () => bridge.set(null);\n }, [value]);\n\n return (\n <AlertContext.Provider value={value}>\n {children}\n <Alert visible={state.visible} title={state.title} message={state.message} type={state.type} buttons={state.buttons} onClose={hide} />\n </AlertContext.Provider>\n );\n}\n\nexport function useAlert(): AlertContextValue {\n const ctx = useContext(AlertContext);\n if (!ctx) {\n throw new Error('orn-ui: useAlert must be used within an <AlertProvider>.');\n }\n return ctx;\n}\n"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "organisms/imperativeBridge.ts",
|
|
20
|
+
"content": "/**\n * Puente entre el proveedor montado y el código que no es un componente\n * (servicios, capa de API, interceptores). El proveedor se registra al montar y\n * se borra al desmontar, así que fuera del árbol nunca queda una referencia\n * viva a un contexto muerto.\n */\nexport interface ImperativeBridge<T> {\n set: (value: T | null) => void;\n get: () => T | null;\n}\n\nexport function createImperativeBridge<T>(providerName: string): ImperativeBridge<T> {\n let current: T | null = null;\n\n return {\n set: (value) => {\n current = value;\n },\n get: () => {\n if (!current && __DEV__) {\n console.warn(\n `orn-ui: se llamó a la API imperativa sin un <${providerName}> montado. La llamada se ignoró.`\n );\n }\n return current;\n },\n };\n}\n"
|
|
14
21
|
}
|
|
15
22
|
],
|
|
16
23
|
"registryDependencies": [
|
|
@@ -9,10 +9,14 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/BottomSheet.tsx",
|
|
12
|
-
"content": "import React, { memo, useEffect, useRef, useState } from 'react';\nimport {
|
|
12
|
+
"content": "import React, { memo, useEffect, useRef, useState } from 'react';\nimport {\n Animated,\n Dimensions,\n Keyboard,\n KeyboardAvoidingView,\n Modal,\n PanResponder,\n Platform,\n Pressable,\n ScrollView,\n View,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useColors, useInsets } from '../theme/UIProvider';\nimport { Title } from '../atoms/Text';\nimport { IconButton } from '../atoms/IconButton';\nimport { Transition } from '../atoms/Transition';\n\nconst { height: SCREEN_HEIGHT } = Dimensions.get('window');\nconst DISMISS_THRESHOLD = 100;\n\nexport type FooterPlacement = 'fixed' | 'scroll' | 'hide-with-keyboard';\n\nexport interface BottomSheetProps {\n visible: boolean;\n onClose: () => void;\n children: React.ReactNode;\n /** Con título aparece el header, con su botón de cerrar. */\n title?: string;\n /** Acciones de la hoja. Dónde vive lo decide `footerPlacement`. */\n footer?: React.ReactNode;\n /**\n * 'fixed' lo ancla al pie de la hoja; 'scroll' lo deja al final del\n * contenido; 'hide-with-keyboard' lo esconde mientras se escribe, que en un\n * formulario largo le devuelve esa franja al campo enfocado. @default 'fixed'\n */\n footerPlacement?: FooterPlacement;\n /** El contenido scrollea cuando no entra. @default true */\n scrollable?: boolean;\n /** Arrastrar la hoja hacia abajo la cierra. @default true */\n draggable?: boolean;\n /** Fracción de la pantalla (0-1) o píxeles. @default 0.9 */\n maxHeight?: number;\n closeAccessibilityLabel?: string;\n containerStyle?: ViewStyle;\n contentStyle?: ViewStyle;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { flex: 1, justifyContent: 'flex-end' },\n backdrop: { backgroundColor: theme.colors.overlay },\n sheet: {\n backgroundColor: theme.colors.surface,\n borderTopLeftRadius: theme.tokens.radius.xxl,\n borderTopRightRadius: theme.tokens.radius.xxl,\n ...theme.tokens.shadow.lg,\n },\n handleArea: { paddingVertical: theme.tokens.spacing.md, alignItems: 'center' },\n handle: { width: 40, height: 5, borderRadius: 3, backgroundColor: theme.colors.border },\n header: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.md,\n paddingHorizontal: theme.tokens.spacing.xl,\n paddingBottom: theme.tokens.spacing.md,\n },\n headerSpaced: { paddingTop: theme.tokens.spacing.xl },\n title: { flex: 1, fontSize: theme.tokens.fontSize.xl, fontWeight: theme.tokens.fontWeight.bold },\n content: { paddingHorizontal: theme.tokens.spacing.xl },\n scroll: { flexShrink: 1 },\n footer: {\n paddingHorizontal: theme.tokens.spacing.xl,\n paddingTop: theme.tokens.spacing.md,\n borderTopWidth: 1,\n borderTopColor: theme.colors.border,\n },\n footerInline: { paddingTop: theme.tokens.spacing.xl },\n}));\n\nconst absoluteFill: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 };\n\n/**\n * BottomSheet\n * Hoja que entra desde abajo, con arrastre para cerrar (PanResponder, no un\n * gesture-handler externo: la librería es cero dependencias). Header, footer y\n * scroll son opcionales, así que cubre tanto la hoja desnuda como el formulario\n * completo.\n */\nexport const BottomSheet = memo(\n ({\n visible,\n onClose,\n children,\n title,\n footer,\n footerPlacement = 'fixed',\n scrollable = true,\n draggable = true,\n maxHeight = 0.9,\n closeAccessibilityLabel = 'Close',\n containerStyle,\n contentStyle,\n testID,\n }: BottomSheetProps) => {\n const styles = useStyles();\n const colors = useColors();\n const insets = useInsets();\n const [mounted, setMounted] = useState(visible);\n const [keyboardOpen, setKeyboardOpen] = useState(false);\n const progress = useRef(new Animated.Value(0)).current;\n\n useEffect(() => {\n if (visible) {\n setMounted(true);\n Animated.timing(progress, { toValue: 1, duration: 250, useNativeDriver: true }).start();\n } else {\n Animated.timing(progress, { toValue: 0, duration: 200, useNativeDriver: true }).start(({ finished }) => {\n if (finished) setMounted(false);\n });\n }\n }, [visible, progress]);\n\n useEffect(() => {\n if (footerPlacement !== 'hide-with-keyboard') return;\n const [show, hide] =\n Platform.OS === 'ios' ? ['keyboardWillShow', 'keyboardWillHide'] : ['keyboardDidShow', 'keyboardDidHide'];\n const subs = [\n Keyboard.addListener(show as 'keyboardDidShow', () => setKeyboardOpen(true)),\n Keyboard.addListener(hide as 'keyboardDidHide', () => setKeyboardOpen(false)),\n ];\n return () => subs.forEach((sub) => sub.remove());\n }, [footerPlacement]);\n\n const panResponder = useRef(\n PanResponder.create({\n onMoveShouldSetPanResponder: (_, gesture) => gesture.dy > 5 && Math.abs(gesture.dy) > Math.abs(gesture.dx),\n onPanResponderMove: (_, gesture) => {\n if (gesture.dy > 0) progress.setValue(1 - Math.min(gesture.dy, SCREEN_HEIGHT) / SCREEN_HEIGHT);\n },\n onPanResponderRelease: (_, gesture) => {\n if (gesture.dy > DISMISS_THRESHOLD) {\n onClose();\n } else {\n Animated.timing(progress, { toValue: 1, duration: 150, useNativeDriver: true }).start();\n }\n },\n })\n ).current;\n\n if (!mounted) return null;\n\n // Porcentaje y no píxeles: se mide contra el contenedor, que con el teclado\n // abierto es más bajo, así que la hoja entra siempre entera.\n const resolvedMaxHeight: number | `${number}%` = maxHeight <= 1 ? `${maxHeight * 100}%` : maxHeight;\n const hasHeader = !!title;\n\n const scrolledFooter = !!footer && footerPlacement === 'scroll' && (\n <View style={styles.footerInline}>{footer}</View>\n );\n const pinnedFooter =\n !!footer && footerPlacement !== 'scroll' && !(footerPlacement === 'hide-with-keyboard' && keyboardOpen) ? (\n <View style={styles.footer}>{footer}</View>\n ) : null;\n\n const body = scrollable ? (\n <ScrollView\n style={styles.scroll}\n contentContainerStyle={[styles.content, contentStyle]}\n keyboardShouldPersistTaps=\"handled\"\n showsVerticalScrollIndicator={false}\n >\n {children}\n {scrolledFooter}\n </ScrollView>\n ) : (\n <View style={[styles.content, contentStyle]}>\n {children}\n {scrolledFooter}\n </View>\n );\n\n return (\n <Modal visible transparent animationType=\"none\" statusBarTranslucent onRequestClose={onClose}>\n {/* La hoja está anclada abajo, así que el teclado la tapa entera: hay\n que empujarla, no ajustar insets adentro del scroll. Una sola\n compensación, la del contenedor. */}\n <KeyboardAvoidingView\n style={styles.container}\n behavior={Platform.OS === 'ios' ? 'padding' : 'height'}\n >\n <Transition value={progress} preset=\"fade\" style={[absoluteFill, styles.backdrop]}>\n {/* Con header, el botón de cerrar ya expone la acción: repetir el\n label acá deja dos elementos con el mismo nombre. */}\n <Pressable\n style={absoluteFill}\n onPress={onClose}\n testID=\"bottom-sheet-backdrop\"\n accessibilityRole={hasHeader ? undefined : 'button'}\n accessibilityLabel={hasHeader ? undefined : closeAccessibilityLabel}\n />\n </Transition>\n\n <Transition\n value={progress}\n preset=\"slide-up\"\n distance={SCREEN_HEIGHT}\n testID={testID}\n style={[\n styles.sheet,\n { maxHeight: resolvedMaxHeight, paddingBottom: Math.max(insets.bottom, 20) },\n containerStyle,\n ]}\n >\n {draggable && (\n <View style={styles.handleArea} testID=\"bottom-sheet-handle\" {...panResponder.panHandlers}>\n <View style={styles.handle} />\n </View>\n )}\n\n {hasHeader && (\n <View style={[styles.header, !draggable && styles.headerSpaced]}>\n <Title style={styles.title} numberOfLines={1}>\n {title}\n </Title>\n <IconButton\n iconName=\"close\"\n size={24}\n color={colors.text}\n onPress={onClose}\n accessibilityLabel={closeAccessibilityLabel}\n />\n </View>\n )}\n\n {body}\n\n {pinnedFooter}\n </Transition>\n </KeyboardAvoidingView>\n </Modal>\n );\n }\n);\nBottomSheet.displayName = 'BottomSheet';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
16
|
-
"core"
|
|
16
|
+
"core",
|
|
17
|
+
"title",
|
|
18
|
+
"icon-button",
|
|
19
|
+
"pressable-scale",
|
|
20
|
+
"transition"
|
|
17
21
|
]
|
|
18
22
|
}
|
package/registry/core.json
CHANGED
|
@@ -23,17 +23,21 @@
|
|
|
23
23
|
"path": "theme/UIProvider.tsx",
|
|
24
24
|
"content": "import React, { createContext, useContext, useMemo, useState, useCallback } from 'react';\nimport { Appearance, useColorScheme } from 'react-native';\nimport { defaultTheme } from './createTheme';\nimport { defaultLabels, type Labels } from './labels';\nimport { zeroInsets, type EdgeInsets } from './insets';\nimport type { Theme, ThemePair } from './types';\nimport { renderDefaultIcon } from '../icons/defaultIcons';\nimport type { IconRenderer } from '../icons/types';\n\nexport type ThemeMode = 'system' | 'light' | 'dark';\n\ninterface UIContextValue {\n theme: Theme;\n mode: ThemeMode;\n setMode: (mode: ThemeMode) => void;\n icons: IconRenderer;\n insets: EdgeInsets;\n labels: Labels;\n allowFontScaling: boolean;\n}\n\nconst UIContext = createContext<UIContextValue | undefined>(undefined);\n\n/** Una sola vez por sesión: en un re-render el aviso no aporta nada nuevo. */\nlet warnedAboutInsets = false;\n\nexport interface UIProviderProps {\n children: React.ReactNode;\n /** Par light/dark. Por defecto el theme de marca de la librería (créalo con createTheme()). */\n theme?: ThemePair;\n /** Modo inicial (no controlado) si no se pasa `mode`. @default 'system' */\n defaultMode?: ThemeMode;\n /** Modo controlado. Si se pasa junto a onModeChange, el provider no gestiona estado propio. */\n mode?: ThemeMode;\n onModeChange?: (mode: ThemeMode) => void;\n /** Renderer de íconos. Por defecto usa los glifos zero-dep incluidos. */\n icons?: IconRenderer;\n /** Insets de safe area (de useSafeAreaInsets()). Por defecto {0,0,0,0}. */\n insets?: EdgeInsets;\n labels?: Partial<Labels>;\n /** @default false, para paridad con el resto de la librería (accesibilidad primero). */\n allowFontScaling?: boolean;\n}\n\n/**\n * UIProvider\n * Único punto de entrada de la librería: resuelve el theme activo (sistema u\n * override manual), e inyecta íconos, insets y textos por defecto a todo el\n * árbol. No persiste el modo — si el consumidor quiere recordar la preferencia\n * entre sesiones, controla `mode`/`onModeChange` y persiste donde prefiera.\n */\nexport function UIProvider({\n children,\n theme = defaultTheme,\n defaultMode = 'system',\n mode: controlledMode,\n onModeChange,\n icons = renderDefaultIcon,\n insets = zeroInsets,\n labels: labelOverrides,\n allowFontScaling = false,\n}: UIProviderProps) {\n const systemScheme = useColorScheme();\n const [uncontrolledMode, setUncontrolledMode] = useState<ThemeMode>(defaultMode);\n const isControlled = controlledMode !== undefined;\n const mode = isControlled ? controlledMode : uncontrolledMode;\n\n const setMode = useCallback(\n (next: ThemeMode) => {\n if (!isControlled) setUncontrolledMode(next);\n onModeChange?.(next);\n },\n [isControlled, onModeChange]\n );\n\n // Olvidar `insets` no rompe nada visible al montar: el default de ceros\n // recién se nota cuando abrís un Modal `full` (se mete abajo del notch) o un\n // BottomSheet (queda pegado a la barra de gestos). El aviso llega antes.\n if (__DEV__ && insets === zeroInsets && !warnedAboutInsets) {\n warnedAboutInsets = true;\n console.warn(\n 'orn-ui: <UIProvider> sin `insets`, se usan {top:0,bottom:0,left:0,right:0}. ' +\n 'Los modales y el Screen quedan sin safe area. Pasá `insets={useSafeAreaInsets()}` ' +\n \"o usá <SafeAreaUIProvider> de 'orn-ui/safe-area', que lo cablea solo.\"\n );\n }\n\n const scheme: 'light' | 'dark' =\n mode === 'system' ? (systemScheme === 'dark' ? 'dark' : 'light') : mode;\n\n const resolvedTheme = theme[scheme];\n\n const labels = useMemo(\n () => (labelOverrides ? { ...defaultLabels, ...labelOverrides } : defaultLabels),\n [labelOverrides]\n );\n\n const value = useMemo<UIContextValue>(\n () => ({ theme: resolvedTheme, mode, setMode, icons, insets, labels, allowFontScaling }),\n [resolvedTheme, mode, setMode, icons, insets, labels, allowFontScaling]\n );\n\n return <UIContext.Provider value={value}>{children}</UIContext.Provider>;\n}\n\nfunction useUIContext(): UIContextValue {\n const ctx = useContext(UIContext);\n if (!ctx) {\n throw new Error('orn-ui: this hook must be used within a <UIProvider>.');\n }\n return ctx;\n}\n\nexport function useTheme(): Theme {\n return useUIContext().theme;\n}\n\nexport function useColors(): Theme['colors'] {\n return useUIContext().theme.colors;\n}\n\nexport function useThemeMode() {\n const { mode, setMode } = useUIContext();\n return { mode, setMode };\n}\n\nexport function useIcons(): IconRenderer {\n return useUIContext().icons;\n}\n\nexport function useInsets(): EdgeInsets {\n return useUIContext().insets;\n}\n\nexport function useLabels(): Labels {\n return useUIContext().labels;\n}\n\nexport function useAllowFontScaling(): boolean {\n return useUIContext().allowFontScaling;\n}\n\n/** Re-exportado para que tests/consumidores puedan simular cambios de sistema sin RN real. */\nexport const __testUtils = { Appearance };\n"
|
|
25
25
|
},
|
|
26
|
+
{
|
|
27
|
+
"path": "theme/colors.ts",
|
|
28
|
+
"content": "/**\n * Utilidades de color sin dependencias, usadas para derivar una familia de\n * roles (relleno / encima / tenue / legible) a partir de un solo hex de marca.\n */\n\n/** Contraste mínimo AA para texto normal. */\nexport const AA_CONTRAST = 4.5;\n\nexport interface Rgb {\n r: number;\n g: number;\n b: number;\n}\n\nexport interface Hsl {\n h: number;\n s: number;\n l: number;\n}\n\n/** Roles que necesita un acento del theme, en un esquema concreto. */\nexport interface AccentRoles {\n /** Relleno pleno: fondo de botón, círculo del paso actual. */\n base: string;\n /** Texto/ícono que va encima del relleno. */\n on: string;\n /** Fondo tenue: badges, pill del indicador activo. */\n soft: string;\n /** El acento legible sobre `surface`: labels, links. */\n text: string;\n}\n\nconst HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;\n\nexport function hexToRgb(hex: string): Rgb {\n const match = HEX.exec(hex.trim());\n if (!match) {\n throw new Error(`orn-ui: se esperaba un color hex como \"#7c3aed\", se recibió \"${hex}\".`);\n }\n const digits = match[1]!;\n const full = digits.length === 3 ? digits.replace(/./g, (c) => c + c) : digits;\n return {\n r: parseInt(full.slice(0, 2), 16),\n g: parseInt(full.slice(2, 4), 16),\n b: parseInt(full.slice(4, 6), 16),\n };\n}\n\nconst toHexPair = (value: number) => Math.round(clamp(value, 0, 255)).toString(16).padStart(2, '0');\n\nexport function rgbToHex({ r, g, b }: Rgb): string {\n return `#${toHexPair(r)}${toHexPair(g)}${toHexPair(b)}`;\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value));\n}\n\nexport function rgbToHsl({ r, g, b }: Rgb): Hsl {\n const rn = r / 255;\n const gn = g / 255;\n const bn = b / 255;\n const max = Math.max(rn, gn, bn);\n const min = Math.min(rn, gn, bn);\n const delta = max - min;\n const l = (max + min) / 2;\n\n if (delta === 0) return { h: 0, s: 0, l };\n\n const s = delta / (1 - Math.abs(2 * l - 1));\n let h: number;\n if (max === rn) h = ((gn - bn) / delta) % 6;\n else if (max === gn) h = (bn - rn) / delta + 2;\n else h = (rn - gn) / delta + 4;\n\n h *= 60;\n return { h: h < 0 ? h + 360 : h, s, l };\n}\n\nexport function hslToRgb({ h, s, l }: Hsl): Rgb {\n const c = (1 - Math.abs(2 * l - 1)) * s;\n const hp = (((h % 360) + 360) % 360) / 60;\n const x = c * (1 - Math.abs((hp % 2) - 1));\n const [r1, g1, b1] =\n hp < 1 ? [c, x, 0] : hp < 2 ? [x, c, 0] : hp < 3 ? [0, c, x] : hp < 4 ? [0, x, c] : hp < 5 ? [x, 0, c] : [c, 0, x];\n const m = l - c / 2;\n return { r: (r1 + m) * 255, g: (g1 + m) * 255, b: (b1 + m) * 255 };\n}\n\nexport function toHsl(hex: string): Hsl {\n return rgbToHsl(hexToRgb(hex));\n}\n\nexport function toHex(hsl: Hsl): string {\n return rgbToHex(hslToRgb({ ...hsl, s: clamp(hsl.s, 0, 1), l: clamp(hsl.l, 0, 1) }));\n}\n\nfunction channelLuminance(value: number) {\n const c = value / 255;\n return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;\n}\n\n/** Luminancia relativa WCAG de un hex. */\nexport function luminance(hex: string): number {\n const { r, g, b } = hexToRgb(hex);\n return 0.2126 * channelLuminance(r) + 0.7152 * channelLuminance(g) + 0.0722 * channelLuminance(b);\n}\n\n/** Ratio de contraste WCAG entre dos hex: 1 (igual) a 21 (blanco sobre negro). */\nexport function contrast(a: string, b: string): number {\n const la = luminance(a);\n const lb = luminance(b);\n return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);\n}\n\n/**\n * Blanco si alcanza el contraste AA —es lo que se espera encima de un relleno\n * de color— y si no, el extremo que más contraste dé.\n */\nfunction bestOnColor(background: string, light: string, dark: string): string {\n if (contrast(background, light) >= AA_CONTRAST) return light;\n return contrast(background, dark) >= contrast(background, light) ? dark : light;\n}\n\n/**\n * Mueve la luminosidad del tono hasta alcanzar el contraste pedido contra\n * `against`. Si ni el extremo llega —un amarillo puro sobre blanco, por\n * ejemplo— devuelve el más contrastado que encontró en el camino.\n */\nfunction reachContrast(hsl: Hsl, against: string, target: number, direction: 'darker' | 'lighter'): string {\n const step = direction === 'darker' ? -0.02 : 0.02;\n let best = toHex(hsl);\n let bestRatio = contrast(best, against);\n\n for (let l = hsl.l; l >= 0 && l <= 1; l += step) {\n const candidate = toHex({ ...hsl, l });\n const ratio = contrast(candidate, against);\n if (ratio > bestRatio) {\n best = candidate;\n bestRatio = ratio;\n }\n if (ratio >= target) return candidate;\n }\n\n return best;\n}\n\n/**\n * Deriva los cuatro roles de un acento a partir de un hex, para un esquema.\n * `surface` es el fondo sobre el que tiene que leerse el rol `text`, así que\n * cada esquema pasa el suyo.\n */\nexport function deriveAccent(hex: string, scheme: 'light' | 'dark', surface: string): AccentRoles {\n const hsl = toHsl(hex);\n\n if (scheme === 'light') {\n const base = toHex(hsl);\n return {\n base,\n on: bestOnColor(base, '#ffffff', '#1a1a1a'),\n soft: toHex({ h: hsl.h, s: Math.min(hsl.s, 0.85), l: 0.94 }),\n text: reachContrast({ ...hsl, l: Math.min(hsl.l, 0.38) }, surface, AA_CONTRAST, 'darker'),\n };\n }\n\n // En oscuro el acento sube de luminosidad y lo que va encima baja: un\n // #004cef sobre #1e1e1e no se distingue del fondo.\n const base = toHex({ ...hsl, l: 0.72 });\n return {\n base,\n on: bestOnColor(base, '#ffffff', toHex({ ...hsl, s: Math.min(hsl.s, 0.9), l: 0.16 })),\n soft: toHex({ h: hsl.h, s: Math.min(hsl.s, 0.35), l: 0.18 }),\n text: reachContrast({ ...hsl, l: Math.max(hsl.l, 0.74) }, surface, AA_CONTRAST, 'lighter'),\n };\n}\n"
|
|
29
|
+
},
|
|
26
30
|
{
|
|
27
31
|
"path": "theme/createStyles.ts",
|
|
28
32
|
"content": "import { StyleSheet } from 'react-native';\nimport { useTheme } from './UIProvider';\nimport type { Theme } from './types';\n\n/**\n * createStyles(factory)\n * Devuelve un hook useStyles() que memoiza el StyleSheet resultante por\n * theme (WeakMap<Theme, Styles>), no por instancia de componente. Todas las\n * filas de una lista comparten el mismo objeto de estilos: sin esto, N filas\n * ⇒ N StyleSheet.create() por render, que es lo que hacía cada componente\n * de la app original con `useMemo(() => createStyles(colors), [colors])`.\n *\n * La cache vive en el closure del factory (una WeakMap por llamada a\n * createStyles, es decir, una por componente), así que temas de distintos\n * <UIProvider> en el mismo árbol no colisionan entre sí.\n *\n * El tipo de `factory` sigue el patrón oficial de RN para StyleSheet\n * tipados (`T extends StyleSheet.NamedStyles<T> | StyleSheet.NamedStyles<any>`):\n * es lo que le da contexto a TS para inferir literales ('row', 'center', ...)\n * en vez de ensancharlos a `string` dentro del objeto devuelto.\n */\nexport function createStyles<T extends StyleSheet.NamedStyles<T> | StyleSheet.NamedStyles<any>>(\n factory: (theme: Theme) => T | StyleSheet.NamedStyles<T>\n): () => T {\n const cache = new WeakMap<Theme, T>();\n\n return function useStyles(): T {\n const theme = useTheme();\n let styles = cache.get(theme);\n if (!styles) {\n // El cast es seguro: StyleSheet.create<T>(...) con el mismo T del\n // factory siempre devuelve T; TS no puede probarlo por sí solo dentro\n // de un cuerpo genérico (T podría \"instanciarse\" con un subtipo no\n // relacionado desde su punto de vista, aunque en runtime es el mismo).\n styles = StyleSheet.create(factory(theme)) as T;\n cache.set(theme, styles);\n }\n return styles;\n };\n}\n"
|
|
29
33
|
},
|
|
30
34
|
{
|
|
31
35
|
"path": "theme/createTheme.ts",
|
|
32
|
-
"content": "import { lightPalette, darkPalette } from './palettes';\nimport { tokens as defaultTokens } from './tokens';\nimport type { Theme, ThemeOverrides, ThemePair } from './types';\n\nfunction mergeDeep<T extends Record<string, any>>(base: T, patch?: Partial<T>): T {\n if (!patch) return base;\n const out: Record<string, any> = { ...base };\n for (const key of Object.keys(patch)) {\n const patchVal = (patch as Record<string, any>)[key];\n const baseVal = (base as Record<string, any>)[key];\n if (\n patchVal &&\n baseVal &&\n typeof patchVal === 'object' &&\n typeof baseVal === 'object' &&\n !Array.isArray(patchVal)\n ) {\n out[key] = mergeDeep(baseVal, patchVal);\n } else if (patchVal !== undefined) {\n out[key] = patchVal;\n }\n }\n return out as T;\n}\n\n/**\n * Construye el par de themes light/dark que consume <UIProvider theme={...}>.\n * Nunca muta los defaults: cada llamada produce objetos nuevos, seguros de\n * compartir entre providers distintos (tests, multi-brand en el mismo árbol).\n */\nexport function createTheme(overrides?: ThemeOverrides): ThemePair {\n const tokens = mergeDeep(defaultTokens, overrides?.tokens as any);\n\n const light: Theme = {\n colors: mergeDeep(lightPalette, overrides?.colors?.light),\n tokens,\n scheme: 'light',\n };\n\n const dark: Theme = {\n colors: mergeDeep(darkPalette, overrides?.colors?.dark),\n tokens,\n scheme: 'dark',\n };\n\n return { light, dark };\n}\n\nexport const defaultTheme: ThemePair = createTheme();\n"
|
|
36
|
+
"content": "import { lightPalette, darkPalette, type ThemeColors } from './palettes';\nimport { tokens as defaultTokens } from './tokens';\nimport { deriveAccent } from './colors';\nimport type { BrandAccent, Theme, ThemeOverrides, ThemePair } from './types';\n\nfunction mergeDeep<T extends Record<string, any>>(base: T, patch?: Partial<T>): T {\n if (!patch) return base;\n const out: Record<string, any> = { ...base };\n for (const key of Object.keys(patch)) {\n const patchVal = (patch as Record<string, any>)[key];\n const baseVal = (base as Record<string, any>)[key];\n if (\n patchVal &&\n baseVal &&\n typeof patchVal === 'object' &&\n typeof baseVal === 'object' &&\n !Array.isArray(patchVal)\n ) {\n out[key] = mergeDeep(baseVal, patchVal);\n } else if (patchVal !== undefined) {\n out[key] = patchVal;\n }\n }\n return out as T;\n}\n\nconst capitalize = <T extends string>(name: T) => (name.charAt(0).toUpperCase() + name.slice(1)) as Capitalize<T>;\n\n/** Expande cada hex de `brand` a los cuatro roles del acento en un esquema. */\nfunction brandColors(\n brand: ThemeOverrides['brand'],\n scheme: 'light' | 'dark',\n palette: ThemeColors\n): Partial<ThemeColors> {\n if (!brand) return {};\n const accents = typeof brand === 'string' ? { primary: brand } : brand;\n const out: Partial<ThemeColors> = {};\n\n for (const [name, hex] of Object.entries(accents) as [BrandAccent, string | undefined][]) {\n if (!hex) continue;\n const roles = deriveAccent(hex, scheme, palette.surface);\n out[name] = roles.base;\n out[`on${capitalize(name)}`] = roles.on;\n out[`${name}Soft`] = roles.soft;\n out[`${name}Text`] = roles.text;\n }\n\n return out;\n}\n\n/**\n * Construye el par de themes light/dark que consume <UIProvider theme={...}>.\n * Nunca muta los defaults: cada llamada produce objetos nuevos, seguros de\n * compartir entre providers distintos (tests, multi-brand en el mismo árbol).\n *\n * `brand` se aplica antes que `colors`, así que un rol escrito a mano siempre\n * gana sobre el derivado.\n */\nexport function createTheme(overrides?: ThemeOverrides): ThemePair {\n const tokens = mergeDeep(defaultTokens, overrides?.tokens as any);\n\n const light: Theme = {\n colors: mergeDeep(\n mergeDeep(lightPalette, brandColors(overrides?.brand, 'light', lightPalette)),\n overrides?.colors?.light\n ),\n tokens,\n scheme: 'light',\n };\n\n const dark: Theme = {\n colors: mergeDeep(\n mergeDeep(darkPalette, brandColors(overrides?.brand, 'dark', darkPalette)),\n overrides?.colors?.dark\n ),\n tokens,\n scheme: 'dark',\n };\n\n return { light, dark };\n}\n\nexport const defaultTheme: ThemePair = createTheme();\n"
|
|
33
37
|
},
|
|
34
38
|
{
|
|
35
39
|
"path": "theme/index.ts",
|
|
36
|
-
"content": "export { spacing, radius, fontSize, fontWeight, lineHeight, duration, shadow, tokens } from './tokens';\nexport type { Tokens } from './tokens';\nexport { lightPalette, darkPalette } from './palettes';\nexport type { ThemeColors } from './palettes';\nexport { createTheme, defaultTheme } from './createTheme';\nexport type { Theme, ThemePair, ThemeOverrides, DeepPartial } from './types';\nexport { defaultLabels } from './labels';\nexport type { Labels } from './labels';\nexport { zeroInsets } from './insets';\nexport type { EdgeInsets } from './insets';\nexport { createStyles } from './createStyles';\nexport {\n UIProvider,\n useTheme,\n useColors,\n useThemeMode,\n useIcons,\n useInsets,\n useLabels,\n useAllowFontScaling,\n} from './UIProvider';\nexport type { UIProviderProps, ThemeMode } from './UIProvider';\n"
|
|
40
|
+
"content": "export { spacing, radius, fontSize, fontWeight, lineHeight, duration, shadow, tokens } from './tokens';\nexport type { Tokens } from './tokens';\nexport { lightPalette, darkPalette } from './palettes';\nexport type { ThemeColors } from './palettes';\nexport { createTheme, defaultTheme } from './createTheme';\nexport type { Theme, ThemePair, ThemeOverrides, BrandAccent, DeepPartial } from './types';\nexport { deriveAccent, contrast, luminance, AA_CONTRAST } from './colors';\nexport type { AccentRoles } from './colors';\nexport { defaultLabels } from './labels';\nexport type { Labels } from './labels';\nexport { zeroInsets } from './insets';\nexport type { EdgeInsets } from './insets';\nexport { createStyles } from './createStyles';\nexport {\n UIProvider,\n useTheme,\n useColors,\n useThemeMode,\n useIcons,\n useInsets,\n useLabels,\n useAllowFontScaling,\n} from './UIProvider';\nexport type { UIProviderProps, ThemeMode } from './UIProvider';\n"
|
|
37
41
|
},
|
|
38
42
|
{
|
|
39
43
|
"path": "theme/insets.ts",
|
|
@@ -41,11 +45,11 @@
|
|
|
41
45
|
},
|
|
42
46
|
{
|
|
43
47
|
"path": "theme/labels.ts",
|
|
44
|
-
"content": "export interface Labels {\n close: string;\n cancel: string;\n confirm: string;\n loading: string;\n loadingMore: string;\n noResultsTitle: string;\n noResultsDescription: string;\n search: string;\n selectPlaceholder: string;\n}\n\nexport const defaultLabels: Labels = {\n close: 'Close',\n cancel: 'Cancel',\n confirm: 'OK',\n loading: 'Loading...',\n loadingMore: 'Loading more...',\n noResultsTitle: 'No results found',\n noResultsDescription: 'Try a different search term',\n search: 'Search...',\n selectPlaceholder: 'Select...',\n};\n"
|
|
48
|
+
"content": "export interface Labels {\n close: string;\n cancel: string;\n confirm: string;\n loading: string;\n loadingMore: string;\n noResultsTitle: string;\n noResultsDescription: string;\n search: string;\n selectPlaceholder: string;\n /** Nombres de los meses, de enero a diciembre. */\n months: string[];\n /** Iniciales de los días, índice 0 = domingo (alinea con `Date.getDay()`). */\n weekdaysShort: string[];\n selectDate: string;\n clear: string;\n}\n\nexport const defaultLabels: Labels = {\n close: 'Close',\n cancel: 'Cancel',\n confirm: 'OK',\n loading: 'Loading...',\n loadingMore: 'Loading more...',\n noResultsTitle: 'No results found',\n noResultsDescription: 'Try a different search term',\n search: 'Search...',\n selectPlaceholder: 'Select...',\n months: [\n 'January', 'February', 'March', 'April', 'May', 'June',\n 'July', 'August', 'September', 'October', 'November', 'December',\n ],\n weekdaysShort: ['S', 'M', 'T', 'W', 'T', 'F', 'S'],\n selectDate: 'Select date',\n clear: 'Clear',\n};\n"
|
|
45
49
|
},
|
|
46
50
|
{
|
|
47
51
|
"path": "theme/palettes.ts",
|
|
48
|
-
"content": "/**\n * Cada color es `string`, no un literal (`as const`): light y dark tienen\n * valores distintos para la misma clave, y createTheme() debe poder\n * aceptar cualquier hex/rgba en los overrides — literal typing lo impediría.\n *\n * Cada acento viene en cuatro roles, y no son intercambiables:\n *\n * `primary` relleno sólido (fondo de botón, día seleccionado, FAB).\n * `onPrimary` texto/ícono *encima* de ese relleno.\n * `primarySoft` fondo teñido para estados seleccionados y pills.\n * `primaryText` texto/ícono del acento sobre `surface`, `background`\n * o sobre su propio `primarySoft`.\n *\n * El motivo es el contraste: un acento que se lee bien como relleno no se\n * lee como texto. Antes los componentes usaban `colors.primary` para las dos\n * cosas y lo teñían concatenando alpha al hex (`colors.primary + '15'`), lo\n * que en dark daba 2.6:1 sobre la superficie — muy por debajo del 4.5:1 de\n * WCAG AA. Todos los pares de esta paleta pasan AA en ambos esquemas.\n */\nexport interface ThemeColors {\n primary: string;\n onPrimary: string;\n primarySoft: string;\n primaryText: string;\n\n secondary: string;\n onSecondary: string;\n secondarySoft: string;\n secondaryText: string;\n\n success: string;\n onSuccess: string;\n successSoft: string;\n successText: string;\n\n error: string;\n onError: string;\n errorSoft: string;\n errorText: string;\n\n warning: string;\n onWarning: string;\n warningSoft: string;\n warningText: string;\n\n white: string;\n black: string;\n background: string;\n surface: string;\n text: string;\n textLight: string;\n border: string;\n inputBackground: string;\n overlay: string;\n /** Velo semitransparente sobre contenido propio (footer de carga de List). */\n scrim: string;\n /** Fondo de placeholder mientras carga una imagen. */\n skeleton: string;\n}\n\nconst neutral = {\n white: '#ffffff',\n black: '#000000',\n};\n\nexport const lightPalette: ThemeColors = {\n ...neutral,\n\n primary: '#004cef',\n onPrimary: '#ffffff',\n primarySoft: '#e6edfe',\n primaryText: '#00369f',\n\n secondary: '#00cae1',\n onSecondary: '#00363d',\n secondarySoft: '#dcf6f9',\n secondaryText: '#00707d',\n\n success: '#34c759',\n onSuccess: '#00320f',\n successSoft: '#e3f6e9',\n successText: '#116a30',\n\n error: '#
|
|
52
|
+
"content": "/**\n * Cada color es `string`, no un literal (`as const`): light y dark tienen\n * valores distintos para la misma clave, y createTheme() debe poder\n * aceptar cualquier hex/rgba en los overrides — literal typing lo impediría.\n *\n * Cada acento viene en cuatro roles, y no son intercambiables:\n *\n * `primary` relleno sólido (fondo de botón, día seleccionado, FAB).\n * `onPrimary` texto/ícono *encima* de ese relleno.\n * `primarySoft` fondo teñido para estados seleccionados y pills.\n * `primaryText` texto/ícono del acento sobre `surface`, `background`\n * o sobre su propio `primarySoft`.\n *\n * El motivo es el contraste: un acento que se lee bien como relleno no se\n * lee como texto. Antes los componentes usaban `colors.primary` para las dos\n * cosas y lo teñían concatenando alpha al hex (`colors.primary + '15'`), lo\n * que en dark daba 2.6:1 sobre la superficie — muy por debajo del 4.5:1 de\n * WCAG AA. Todos los pares de esta paleta pasan AA en ambos esquemas.\n */\nexport interface ThemeColors {\n primary: string;\n onPrimary: string;\n primarySoft: string;\n primaryText: string;\n\n secondary: string;\n onSecondary: string;\n secondarySoft: string;\n secondaryText: string;\n\n success: string;\n onSuccess: string;\n successSoft: string;\n successText: string;\n\n error: string;\n onError: string;\n errorSoft: string;\n errorText: string;\n\n warning: string;\n onWarning: string;\n warningSoft: string;\n warningText: string;\n\n white: string;\n black: string;\n background: string;\n surface: string;\n text: string;\n textLight: string;\n border: string;\n inputBackground: string;\n overlay: string;\n /** Velo semitransparente sobre contenido propio (footer de carga de List). */\n scrim: string;\n /** Fondo de placeholder mientras carga una imagen. */\n skeleton: string;\n}\n\nconst neutral = {\n white: '#ffffff',\n black: '#000000',\n};\n\nexport const lightPalette: ThemeColors = {\n ...neutral,\n\n primary: '#004cef',\n onPrimary: '#ffffff',\n primarySoft: '#e6edfe',\n primaryText: '#00369f',\n\n secondary: '#00cae1',\n onSecondary: '#00363d',\n secondarySoft: '#dcf6f9',\n secondaryText: '#00707d',\n\n success: '#34c759',\n onSuccess: '#00320f',\n successSoft: '#e3f6e9',\n successText: '#116a30',\n\n // El rojo va con texto blanco, que es lo que la gente espera de un botón\n // destructivo. #ff3b30 no llega a 4.5:1 con blanco y obligaba a texto casi\n // negro sobre rojo; este tono sí, y sigue leyéndose como rojo de alerta.\n error: '#dc2626',\n onError: '#ffffff',\n errorSoft: '#fdeaea',\n errorText: '#c62222',\n\n warning: '#f59e0b',\n onWarning: '#3d2600',\n warningSoft: '#fdf1de',\n warningText: '#8a4b00',\n\n background: '#f8f9fa',\n surface: '#ffffff',\n text: '#333333',\n textLight: '#666666',\n border: '#e5e7eb',\n inputBackground: '#f5f7fa',\n overlay: 'rgba(0,0,0,0.5)',\n scrim: 'rgba(248,249,250,0.8)',\n skeleton: '#eceef1',\n};\n\n/**\n * Los acentos de dark NO son los de light: sobre `#1e1e1e` hay que subir la\n * luminosidad del acento y bajar la del texto que va encima (por eso\n * `onPrimary` es azul oscuro acá y blanco en light).\n */\nexport const darkPalette: ThemeColors = {\n ...neutral,\n\n primary: '#7ba4ff',\n onPrimary: '#00205c',\n primarySoft: '#1b2740',\n primaryText: '#a9c3ff',\n\n secondary: '#4dd8e8',\n onSecondary: '#00363d',\n secondarySoft: '#12333a',\n secondaryText: '#7fe6f2',\n\n success: '#5ddb84',\n onSuccess: '#003514',\n successSoft: '#13301e',\n successText: '#7fe6a1',\n\n error: '#ff7b72',\n onError: '#4d0500',\n errorSoft: '#3a1c1a',\n errorText: '#ffa9a3',\n\n warning: '#f0b458',\n onWarning: '#3d2600',\n warningSoft: '#33260f',\n warningText: '#f5c97e',\n\n background: '#121212',\n surface: '#1e1e1e',\n text: '#ecedee',\n textLight: '#9ba1a6',\n border: '#2a2a2e',\n inputBackground: '#232326',\n overlay: 'rgba(0,0,0,0.6)',\n scrim: 'rgba(18,18,18,0.8)',\n skeleton: '#26262a',\n};\n"
|
|
49
53
|
},
|
|
50
54
|
{
|
|
51
55
|
"path": "theme/tokens.ts",
|
|
@@ -53,7 +57,7 @@
|
|
|
53
57
|
},
|
|
54
58
|
{
|
|
55
59
|
"path": "theme/types.ts",
|
|
56
|
-
"content": "import type { Tokens } from './tokens';\nimport type { ThemeColors } from './palettes';\n\n/** Theme resuelto: paleta de un scheme concreto + tokens compartidos. */\nexport interface Theme {\n colors: ThemeColors;\n tokens: Tokens;\n scheme: 'light' | 'dark';\n}\n\n/** Par de themes light/dark que produce createTheme() y consume UIProvider. */\nexport interface ThemePair {\n light: Theme;\n dark: Theme;\n}\n\nexport type DeepPartial<T> = {\n [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];\n};\n\nexport interface ThemeOverrides {\n colors?: {\n light?: Partial<ThemeColors>;\n dark?: Partial<ThemeColors>;\n };\n tokens?: DeepPartial<Tokens>;\n}\n"
|
|
60
|
+
"content": "import type { Tokens } from './tokens';\nimport type { ThemeColors } from './palettes';\n\n/** Theme resuelto: paleta de un scheme concreto + tokens compartidos. */\nexport interface Theme {\n colors: ThemeColors;\n tokens: Tokens;\n scheme: 'light' | 'dark';\n}\n\n/** Par de themes light/dark que produce createTheme() y consume UIProvider. */\nexport interface ThemePair {\n light: Theme;\n dark: Theme;\n}\n\nexport type DeepPartial<T> = {\n [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];\n};\n\n/** Acentos que se pueden definir con un solo hex vía `brand`. */\nexport type BrandAccent = 'primary' | 'secondary' | 'success' | 'error' | 'warning';\n\nexport interface ThemeOverrides {\n /**\n * Un hex por acento; de cada uno se derivan sus cuatro roles (relleno, lo\n * que va encima, el fondo tenue y la variante legible sobre `surface`) para\n * light y para dark, con el contraste ya resuelto. Un string suelto define\n * `primary`. Lo que se pase en `colors` gana sobre lo derivado.\n */\n brand?: string | Partial<Record<BrandAccent, string>>;\n colors?: {\n light?: Partial<ThemeColors>;\n dark?: Partial<ThemeColors>;\n };\n tokens?: DeepPartial<Tokens>;\n}\n"
|
|
57
61
|
}
|
|
58
62
|
],
|
|
59
63
|
"registryDependencies": [],
|
package/registry/date-field.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/DateField.tsx",
|
|
12
|
-
"content": "import React, { memo, useState } from 'react';\nimport { Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Modal } from './Modal';\nimport { DatePicker,
|
|
12
|
+
"content": "import React, { memo, useCallback, useState } from 'react';\nimport { Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useLabels } from '../theme/UIProvider';\nimport { Modal } from './Modal';\nimport { DatePicker, type DatePickerProps, type DateRange } from './DatePicker';\nimport { Button } from '../atoms/Button';\n\nexport interface DateFieldProps\n extends Pick<DatePickerProps, 'minDate' | 'maxDate' | 'monthNames' | 'weekdayNames' | 'firstDayOfWeek'> {\n label?: string;\n required?: boolean;\n /**\n * 'single' elige una fecha; 'range' elige un intervalo con dos toques.\n * @default 'single'\n */\n mode?: 'single' | 'range';\n /** Fecha elegida en mode=\"single\". */\n value?: Date;\n /** Se llama en mode=\"single\". */\n onChange?: (date: Date) => void;\n /** Rango elegido en mode=\"range\". */\n range?: DateRange;\n /** Se llama en mode=\"range\", tanto al fijar el inicio como al cerrar el rango. */\n onRangeChange?: (range: DateRange) => void;\n placeholder?: string;\n error?: string;\n disabled?: boolean;\n /** Formatea la fecha en el campo. @default \"Month D, YYYY\" */\n format?: (date: Date) => string;\n /** Título del modal del calendario. @default el `selectDate` de `useLabels()` */\n modalTitle?: string;\n /**\n * Si se pasa, el modal muestra un botón que borra la selección y cierra.\n * Sin esto no hay botón: el \"Clear\" anterior sólo cerraba, sin limpiar nada.\n */\n onClear?: () => void;\n /** Texto de ese botón. @default el `clear` de `useLabels()` */\n clearLabel?: string;\n containerStyle?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nfunction defaultFormat(date: Date, monthNames: string[]): string {\n return `${monthNames[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { marginBottom: theme.tokens.spacing.lg, width: '100%', gap: theme.tokens.spacing.sm },\n label: { fontSize: theme.tokens.fontSize.md, fontWeight: theme.tokens.fontWeight.medium, color: theme.colors.text },\n required: { color: theme.colors.errorText },\n field: {\n flexDirection: 'row',\n alignItems: 'center',\n justifyContent: 'space-between',\n backgroundColor: theme.colors.inputBackground,\n borderRadius: theme.tokens.radius.lg,\n borderWidth: 2,\n borderColor: theme.colors.border,\n minHeight: 50,\n paddingHorizontal: theme.tokens.spacing.lg,\n },\n fieldError: { borderColor: theme.colors.error },\n fieldText: { fontSize: theme.tokens.fontSize.lg, color: theme.colors.text },\n placeholder: { color: theme.colors.textLight },\n errorText: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.errorText },\n}));\n\n/**\n * DateField\n * Campo de fecha: se ve como un Input, abre un `DatePicker` en un Modal al\n * tocarlo. Sin picker nativo del sistema, así que se ve y se comporta igual\n * en iOS y Android.\n */\nexport const DateField = memo(\n ({\n label,\n required = false,\n mode = 'single',\n value,\n onChange,\n range,\n onRangeChange,\n placeholder = 'Select a date',\n error,\n disabled = false,\n format,\n modalTitle,\n onClear,\n clearLabel,\n containerStyle,\n monthNames,\n testID,\n ...pickerProps\n }: DateFieldProps) => {\n const colors = useColors();\n const styles = useStyles();\n const labels = useLabels();\n const allowFontScaling = useAllowFontScaling();\n const [open, setOpen] = useState(false);\n\n const months = monthNames ?? labels.months;\n const isRange = mode === 'range';\n\n const formatDate = (date: Date) => (format ? format(date) : defaultFormat(date, months));\n\n // El calendario se cierra solo cuando ya no falta nada por elegir: en\n // rango, recién cuando el segundo toque cierra el intervalo.\n const handleChange = useCallback(\n (date: Date) => {\n onChange?.(date);\n setOpen(false);\n },\n [onChange]\n );\n\n const handleRangeChange = useCallback(\n (next: DateRange) => {\n onRangeChange?.(next);\n if (next.end) setOpen(false);\n },\n [onRangeChange]\n );\n\n const handleClear = useCallback(() => {\n onClear?.();\n setOpen(false);\n }, [onClear]);\n\n const hasValue = isRange ? !!range?.start : !!value;\n let display = placeholder;\n if (isRange && range?.start) {\n display = range.end ? `${formatDate(range.start)} — ${formatDate(range.end)}` : formatDate(range.start);\n } else if (!isRange && value) {\n display = formatDate(value);\n }\n\n return (\n <View style={[styles.container, containerStyle]} testID={testID}>\n {!!label && (\n <Text allowFontScaling={allowFontScaling} style={styles.label}>\n {label}\n {required && <Text style={styles.required}> *</Text>}\n </Text>\n )}\n\n <TouchableOpacity\n style={[styles.field, !!error && styles.fieldError, disabled && { opacity: 0.5 }]}\n onPress={() => setOpen(true)}\n disabled={disabled}\n activeOpacity={0.7}\n accessibilityRole=\"button\"\n accessibilityLabel={label ?? placeholder}\n accessibilityState={{ disabled, expanded: open }}\n >\n <Text allowFontScaling={allowFontScaling} style={[styles.fieldText, !hasValue && styles.placeholder]}>\n {display}\n </Text>\n <Text allowFontScaling={allowFontScaling} style={{ color: colors.textLight }}>\n {'▾'}\n </Text>\n </TouchableOpacity>\n\n {!!error && (\n <Text allowFontScaling={allowFontScaling} style={styles.errorText}>\n {error}\n </Text>\n )}\n\n <Modal\n visible={open}\n onClose={() => setOpen(false)}\n title={modalTitle ?? labels.selectDate}\n variant=\"overlay\"\n scrollable={false}\n footer={\n onClear ? <Button title={clearLabel ?? labels.clear} variant=\"ghost\" onPress={handleClear} /> : undefined\n }\n >\n <DatePicker\n mode={mode}\n value={value}\n range={range}\n monthNames={months}\n {...pickerProps}\n onChange={handleChange}\n onRangeChange={handleRangeChange}\n />\n </Modal>\n </View>\n );\n }\n);\nDateField.displayName = 'DateField';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"title",
|
|
19
19
|
"icon-button",
|
|
20
20
|
"pressable-scale",
|
|
21
|
+
"transition",
|
|
21
22
|
"date-picker",
|
|
22
23
|
"button"
|
|
23
24
|
]
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
{
|
|
14
14
|
"path": "organisms/DatePicker.tsx",
|
|
15
|
-
"content": "import React, { memo, useMemo, useState } from 'react';\nimport { Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { IconButton } from '../atoms/IconButton';\n\nexport const DEFAULT_MONTH_NAMES = [\n 'January', 'February', 'March', 'April', 'May', 'June',\n 'July', 'August', 'September', 'October', 'November', 'December',\n];\n\n/** Índice 0 = domingo, para alinear con Date.getDay(). */\nexport const DEFAULT_WEEKDAY_NAMES = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];\n\nexport interface DateRange {\n start: Date;\n /** Falta mientras el rango está a medio elegir (sólo se tocó el inicio). */\n end?: Date;\n}\n\nexport interface DatePickerProps {\n /**\n * 'single' elige una fecha; 'range' elige un intervalo con dos toques.\n * @default 'single'\n */\n mode?: 'single' | 'range';\n /** Fecha elegida en mode=\"single\". */\n value?: Date;\n /** Se llama en mode=\"single\". */\n onChange?: (date: Date) => void;\n /** Rango elegido en mode=\"range\". */\n range?: DateRange;\n /** Se llama en mode=\"range\", tanto al fijar el inicio como al cerrar el rango. */\n onRangeChange?: (range: DateRange) => void;\n minDate?: Date;\n maxDate?: Date;\n /** Mes mostrado al abrir si no hay `value`. @default hoy */\n defaultMonth?: Date;\n monthNames?: string[];\n weekdayNames?: string[];\n /** 0 = domingo, 1 = lunes. @default 0 */\n firstDayOfWeek?: 0 | 1;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\n/** Compara sólo año/mes/día, ignorando hora — dos Date del mismo día son \"iguales\". */\nfunction isSameDay(a: Date, b: Date): boolean {\n return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();\n}\n\nfunction startOfDay(d: Date): Date {\n return new Date(d.getFullYear(), d.getMonth(), d.getDate());\n}\n\n/** Estrictamente entre start y end, sin incluir los extremos. */\nfunction isBetween(day: Date, start: Date, end: Date): boolean {\n const t = startOfDay(day).getTime();\n return t > startOfDay(start).getTime() && t < startOfDay(end).getTime();\n}\n\nfunction isOutOfRange(day: Date, minDate?: Date, maxDate?: Date): boolean {\n const t = startOfDay(day).getTime();\n if (minDate && t < startOfDay(minDate).getTime()) return true;\n if (maxDate && t > startOfDay(maxDate).getTime()) return true;\n return false;\n}\n\n/**\n * Devuelve la grilla del mes: 6 filas de 7 celdas. Las celdas fuera del mes\n * son null, así el layout no salta de alto entre meses (un mes puede\n * necesitar 4, 5 o 6 filas según en qué día caiga el 1).\n */\nexport function buildMonthGrid(year: number, month: number, firstDayOfWeek: 0 | 1): (Date | null)[][] {\n const first = new Date(year, month, 1);\n const daysInMonth = new Date(year, month + 1, 0).getDate();\n const leading = (first.getDay() - firstDayOfWeek + 7) % 7;\n\n const cells: (Date | null)[] = [];\n for (let i = 0; i < leading; i++) cells.push(null);\n for (let d = 1; d <= daysInMonth; d++) cells.push(new Date(year, month, d));\n while (cells.length < 42) cells.push(null);\n\n const rows: (Date | null)[][] = [];\n for (let r = 0; r < 6; r++) rows.push(cells.slice(r * 7, r * 7 + 7));\n return rows;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: {\n backgroundColor: theme.colors.surface,\n borderRadius: theme.tokens.radius.lg,\n borderWidth: 1,\n borderColor: theme.colors.border,\n padding: theme.tokens.spacing.md,\n },\n header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: theme.tokens.spacing.sm },\n monthLabel: { fontSize: theme.tokens.fontSize.lg, fontWeight: theme.tokens.fontWeight.semibold, color: theme.colors.text },\n weekRow: { flexDirection: 'row' },\n weekday: {\n flex: 1,\n textAlign: 'center',\n fontSize: theme.tokens.fontSize.xs,\n fontWeight: theme.tokens.fontWeight.semibold,\n color: theme.colors.textLight,\n paddingVertical: theme.tokens.spacing.sm,\n },\n row: { flexDirection: 'row' },\n cell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center' },\n dayButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },\n dayText: { fontSize: theme.tokens.fontSize.md, color: theme.colors.text },\n daySelected: { backgroundColor: theme.colors.primary },\n dayTextSelected: { color: theme.colors.onPrimary, fontWeight: theme.tokens.fontWeight.bold },\n // El resaltado del tramo intermedio va en la celda (cuadrada, sin gaps) y\n // no en el botón redondo, así la barra del rango se ve continua.\n cellInRange: { backgroundColor: theme.colors.primarySoft },\n cellRangeStart: { borderTopLeftRadius: 18, borderBottomLeftRadius: 18 },\n cellRangeEnd: { borderTopRightRadius: 18, borderBottomRightRadius: 18 },\n dayTextInRange: { color: theme.colors.primaryText, fontWeight: theme.tokens.fontWeight.semibold },\n dayToday: { borderWidth: 1, borderColor: theme.colors.primary },\n dayTextDisabled: { color: theme.colors.textLight, opacity: 0.4 },\n}));\n\n/**\n * DatePicker\n * Calendario mensual dibujado con View/Text puros — sin\n * @react-native-community/datetimepicker ni ningún picker nativo, para\n * mantener la librería en cero dependencias y verse igual en iOS y Android.\n */\nexport const DatePicker = memo(\n ({\n mode = 'single',\n value,\n onChange,\n range,\n onRangeChange,\n minDate,\n maxDate,\n defaultMonth,\n monthNames = DEFAULT_MONTH_NAMES,\n weekdayNames = DEFAULT_WEEKDAY_NAMES,\n firstDayOfWeek = 0,\n style,\n testID,\n }: DatePickerProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const isRange = mode === 'range';\n\n const handlePress = (day: Date) => {\n if (!isRange) {\n onChange?.(day);\n return;\n }\n // Primer toque (o rango ya cerrado, o toque antes del inicio): arranca\n // un rango nuevo. Segundo toque en adelante: lo cierra.\n if (!range?.start || range.end || startOfDay(day) < startOfDay(range.start)) {\n onRangeChange?.({ start: day });\n } else {\n onRangeChange?.({ start: range.start, end: day });\n }\n };\n\n const initial = value ?? range?.start ?? defaultMonth ?? new Date();\n const [visibleMonth, setVisibleMonth] = useState(() => new Date(initial.getFullYear(), initial.getMonth(), 1));\n\n const today = useMemo(() => new Date(), []);\n const grid = useMemo(\n () => buildMonthGrid(visibleMonth.getFullYear(), visibleMonth.getMonth(), firstDayOfWeek),\n [visibleMonth, firstDayOfWeek]\n );\n\n const orderedWeekdays = useMemo(\n () => (firstDayOfWeek === 0 ? weekdayNames : [...weekdayNames.slice(1), weekdayNames[0]!]),\n [weekdayNames, firstDayOfWeek]\n );\n\n const shiftMonth = (delta: number) =>\n setVisibleMonth((m) => new Date(m.getFullYear(), m.getMonth() + delta, 1));\n\n const monthLabel = `${monthNames[visibleMonth.getMonth()]} ${visibleMonth.getFullYear()}`;\n\n return (\n <View style={[styles.container, style]} testID={testID}>\n <View style={styles.header}>\n <IconButton iconName=\"chevron-left\" accessibilityLabel=\"Previous month\" onPress={() => shiftMonth(-1)} />\n <Text allowFontScaling={allowFontScaling} style={styles.monthLabel} accessibilityRole=\"header\">\n {monthLabel}\n </Text>\n <IconButton iconName=\"chevron-right\" accessibilityLabel=\"Next month\" onPress={() => shiftMonth(1)} />\n </View>\n\n <View style={styles.weekRow}>\n {orderedWeekdays.map((w, i) => (\n <Text allowFontScaling={allowFontScaling} key={`${w}-${i}`} style={styles.weekday}>\n {w}\n </Text>\n ))}\n </View>\n\n {grid.map((row, rowIndex) => (\n <View style={styles.row} key={rowIndex}>\n {row.map((day, cellIndex) => {\n if (!day) return <View style={styles.cell} key={cellIndex} />;\n\n const isStart = isRange && !!range?.start && isSameDay(day, range.start);\n const isEnd = isRange && !!range?.end && isSameDay(day, range.end);\n const inRange =\n isRange && !!range?.start && !!range.end && isBetween(day, range.start, range.end);\n const selected = isRange ? isStart || isEnd : !!value && isSameDay(day, value);\n\n const disabled = isOutOfRange(day, minDate, maxDate);\n const isToday = isSameDay(day, today);\n return (\n <View\n style={[\n styles.cell,\n (inRange || (isStart && !!range?.end) || isEnd) && styles.cellInRange,\n isStart && !!range?.end && styles.cellRangeStart,\n isEnd && styles.cellRangeEnd,\n ]}\n key={cellIndex}\n >\n <TouchableOpacity\n style={[styles.dayButton, selected && styles.daySelected, !selected && isToday && styles.dayToday]}\n onPress={() => handlePress(day)}\n disabled={disabled}\n activeOpacity={0.7}\n accessibilityRole=\"button\"\n accessibilityLabel={`${monthNames[day.getMonth()]} ${day.getDate()}, ${day.getFullYear()}`}\n accessibilityState={{ selected, disabled }}\n >\n <Text\n allowFontScaling={allowFontScaling}\n style={[\n styles.dayText,\n inRange && styles.dayTextInRange,\n selected && styles.dayTextSelected,\n disabled && styles.dayTextDisabled,\n ]}\n >\n {day.getDate()}\n </Text>\n </TouchableOpacity>\n </View>\n );\n })}\n </View>\n ))}\n </View>\n );\n }\n);\nDatePicker.displayName = 'DatePicker';\n"
|
|
15
|
+
"content": "import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useLabels } from '../theme/UIProvider';\nimport { IconButton } from '../atoms/IconButton';\n\nexport const DEFAULT_MONTH_NAMES = [\n 'January', 'February', 'March', 'April', 'May', 'June',\n 'July', 'August', 'September', 'October', 'November', 'December',\n];\n\n/** Índice 0 = domingo, para alinear con Date.getDay(). */\nexport const DEFAULT_WEEKDAY_NAMES = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];\n\nexport interface DateRange {\n start: Date;\n /** Falta mientras el rango está a medio elegir (sólo se tocó el inicio). */\n end?: Date;\n}\n\nexport interface DatePickerProps {\n /**\n * 'single' elige una fecha; 'range' elige un intervalo con dos toques.\n * @default 'single'\n */\n mode?: 'single' | 'range';\n /** Fecha elegida en mode=\"single\". */\n value?: Date;\n /** Se llama en mode=\"single\". */\n onChange?: (date: Date) => void;\n /** Rango elegido en mode=\"range\". */\n range?: DateRange;\n /** Se llama en mode=\"range\", tanto al fijar el inicio como al cerrar el rango. */\n onRangeChange?: (range: DateRange) => void;\n minDate?: Date;\n maxDate?: Date;\n /** Mes mostrado al abrir si no hay `value`. @default hoy */\n defaultMonth?: Date;\n /** Nombres de los meses. @default los de `useLabels()` */\n monthNames?: string[];\n /** Iniciales de los días, índice 0 = domingo. @default las de `useLabels()` */\n weekdayNames?: string[];\n /** 0 = domingo, 1 = lunes. @default 0 */\n firstDayOfWeek?: 0 | 1;\n /** Avisa qué mes quedó a la vista al navegar con las flechas. */\n onVisibleMonthChange?: (month: Date) => void;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nfunction startOfDay(d: Date): Date {\n return new Date(d.getFullYear(), d.getMonth(), d.getDate());\n}\n\n/** Timestamp del día, para comparar sin volver a crear Date en cada celda. */\nfunction dayTime(d: Date): number {\n return startOfDay(d).getTime();\n}\n\nfunction dayTimeOf(d: Date | undefined): number | undefined {\n return d ? dayTime(d) : undefined;\n}\n\n/** Milisegundos que faltan para la próxima medianoche local. */\nfunction msUntilNextMidnight(from: Date): number {\n const next = new Date(from.getFullYear(), from.getMonth(), from.getDate() + 1);\n return next.getTime() - from.getTime();\n}\n\n/** Número de mes absoluto (año * 12 + mes), para comparar meses con un `<`. */\nfunction monthIndex(d: Date): number {\n return d.getFullYear() * 12 + d.getMonth();\n}\n\n/**\n * Devuelve la grilla del mes: 6 filas de 7 celdas. Las celdas fuera del mes\n * son null, así el layout no salta de alto entre meses (un mes puede\n * necesitar 4, 5 o 6 filas según en qué día caiga el 1).\n */\nexport function buildMonthGrid(year: number, month: number, firstDayOfWeek: 0 | 1): (Date | null)[][] {\n const first = new Date(year, month, 1);\n const daysInMonth = new Date(year, month + 1, 0).getDate();\n const leading = (first.getDay() - firstDayOfWeek + 7) % 7;\n\n const cells: (Date | null)[] = [];\n for (let i = 0; i < leading; i++) cells.push(null);\n for (let d = 1; d <= daysInMonth; d++) cells.push(new Date(year, month, d));\n while (cells.length < 42) cells.push(null);\n\n const rows: (Date | null)[][] = [];\n for (let r = 0; r < 6; r++) rows.push(cells.slice(r * 7, r * 7 + 7));\n return rows;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: {\n backgroundColor: theme.colors.surface,\n borderRadius: theme.tokens.radius.lg,\n borderWidth: 1,\n borderColor: theme.colors.border,\n padding: theme.tokens.spacing.md,\n },\n header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: theme.tokens.spacing.sm },\n monthLabel: { fontSize: theme.tokens.fontSize.lg, fontWeight: theme.tokens.fontWeight.semibold, color: theme.colors.text },\n weekRow: { flexDirection: 'row' },\n weekday: {\n flex: 1,\n textAlign: 'center',\n fontSize: theme.tokens.fontSize.xs,\n fontWeight: theme.tokens.fontWeight.semibold,\n color: theme.colors.textLight,\n paddingVertical: theme.tokens.spacing.sm,\n },\n row: { flexDirection: 'row' },\n cell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center' },\n dayButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },\n dayText: { fontSize: theme.tokens.fontSize.md, color: theme.colors.text },\n daySelected: { backgroundColor: theme.colors.primary },\n dayTextSelected: { color: theme.colors.onPrimary, fontWeight: theme.tokens.fontWeight.bold },\n // El resaltado del tramo intermedio va en la celda (cuadrada, sin gaps) y\n // no en el botón redondo, así la barra del rango se ve continua.\n cellInRange: { backgroundColor: theme.colors.primarySoft },\n cellRangeStart: { borderTopLeftRadius: 18, borderBottomLeftRadius: 18 },\n cellRangeEnd: { borderTopRightRadius: 18, borderBottomRightRadius: 18 },\n dayTextInRange: { color: theme.colors.primaryText, fontWeight: theme.tokens.fontWeight.semibold },\n dayToday: { borderWidth: 1, borderColor: theme.colors.primary },\n dayTextDisabled: { color: theme.colors.textLight, opacity: 0.4 },\n}));\n\ntype Styles = ReturnType<typeof useStyles>;\n\ninterface DayCellProps {\n day: Date;\n label: string;\n selected: boolean;\n inRange: boolean;\n isRangeStart: boolean;\n isRangeEnd: boolean;\n isToday: boolean;\n disabled: boolean;\n allowFontScaling: boolean;\n styles: Styles;\n onPress: (day: Date) => void;\n}\n\n/**\n * Una celda del calendario. Va memoizada aparte porque el mes entero son 42\n * celdas: sin esto, cualquier render del padre reconcilia las 42.\n */\nconst DayCell = memo(\n ({\n day,\n label,\n selected,\n inRange,\n isRangeStart,\n isRangeEnd,\n isToday,\n disabled,\n allowFontScaling,\n styles,\n onPress,\n }: DayCellProps) => (\n <View\n style={[\n styles.cell,\n (inRange || isRangeStart || isRangeEnd) && styles.cellInRange,\n isRangeStart && styles.cellRangeStart,\n isRangeEnd && styles.cellRangeEnd,\n ]}\n >\n <TouchableOpacity\n style={[styles.dayButton, selected && styles.daySelected, !selected && isToday && styles.dayToday]}\n onPress={() => onPress(day)}\n disabled={disabled}\n activeOpacity={0.7}\n accessibilityRole=\"button\"\n accessibilityLabel={label}\n accessibilityState={{ selected, disabled }}\n >\n <Text\n allowFontScaling={allowFontScaling}\n style={[\n styles.dayText,\n inRange && styles.dayTextInRange,\n selected && styles.dayTextSelected,\n disabled && styles.dayTextDisabled,\n ]}\n >\n {day.getDate()}\n </Text>\n </TouchableOpacity>\n </View>\n )\n);\nDayCell.displayName = 'DayCell';\n\n/**\n * DatePicker\n * Calendario mensual dibujado con View/Text puros — sin\n * @react-native-community/datetimepicker ni ningún picker nativo, para\n * mantener la librería en cero dependencias y verse igual en iOS y Android.\n */\nexport const DatePicker = memo(\n ({\n mode = 'single',\n value,\n onChange,\n range,\n onRangeChange,\n minDate,\n maxDate,\n defaultMonth,\n monthNames,\n weekdayNames,\n firstDayOfWeek = 0,\n onVisibleMonthChange,\n style,\n testID,\n }: DatePickerProps) => {\n const styles = useStyles();\n const labels = useLabels();\n const allowFontScaling = useAllowFontScaling();\n const isRange = mode === 'range';\n\n const months = monthNames ?? labels.months;\n const weekdays = weekdayNames ?? labels.weekdaysShort;\n\n const handlePress = useCallback(\n (day: Date) => {\n if (!isRange) {\n onChange?.(day);\n return;\n }\n // Primer toque (o rango ya cerrado, o toque antes del inicio): arranca\n // un rango nuevo. Segundo toque en adelante: lo cierra.\n if (!range?.start || range.end || dayTime(day) < dayTime(range.start)) {\n onRangeChange?.({ start: day });\n } else {\n onRangeChange?.({ start: range.start, end: day });\n }\n },\n [isRange, onChange, onRangeChange, range?.start, range?.end]\n );\n\n const anchor = value ?? range?.start ?? defaultMonth;\n const [visibleMonth, setVisibleMonth] = useState(\n () => new Date((anchor ?? new Date()).getFullYear(), (anchor ?? new Date()).getMonth(), 1)\n );\n\n // La fecha elegida puede cambiar desde afuera (uso controlado): sin esto,\n // el calendario se quedaba clavado en el mes con el que montó.\n const anchorMonth = anchor ? monthIndex(anchor) : undefined;\n const lastAnchorMonth = useRef(anchorMonth);\n useEffect(() => {\n if (anchorMonth === undefined || anchorMonth === lastAnchorMonth.current) return;\n lastAnchorMonth.current = anchorMonth;\n setVisibleMonth(new Date(Math.floor(anchorMonth / 12), anchorMonth % 12, 1));\n }, [anchorMonth]);\n\n // \"Hoy\" se recalcula en la próxima medianoche: una app que queda abierta\n // toda la noche marcaba el día anterior.\n const [todayTs, setTodayTs] = useState(() => dayTime(new Date()));\n useEffect(() => {\n const timer = setTimeout(() => setTodayTs(dayTime(new Date())), msUntilNextMidnight(new Date()));\n return () => clearTimeout(timer);\n }, [todayTs]);\n\n const grid = useMemo(\n () => buildMonthGrid(visibleMonth.getFullYear(), visibleMonth.getMonth(), firstDayOfWeek),\n [visibleMonth, firstDayOfWeek]\n );\n\n const orderedWeekdays = useMemo(\n () => (firstDayOfWeek === 0 ? weekdays : [...weekdays.slice(1), weekdays[0]!]),\n [weekdays, firstDayOfWeek]\n );\n\n // Los límites se resuelven una vez y no 42 veces por render: comparar\n // timestamps sale gratis, crear un Date por celda no.\n const bounds = useMemo(\n () => ({\n min: dayTimeOf(minDate),\n max: dayTimeOf(maxDate),\n start: dayTimeOf(range?.start),\n end: dayTimeOf(range?.end),\n selected: dayTimeOf(value),\n }),\n [minDate, maxDate, range?.start, range?.end, value]\n );\n\n const shiftMonth = (delta: number) =>\n setVisibleMonth((m) => {\n const next = new Date(m.getFullYear(), m.getMonth() + delta, 1);\n onVisibleMonthChange?.(next);\n return next;\n });\n\n // Un mes entero fuera de los límites no tiene nada que elegir: la flecha\n // que lleva ahí se apaga en vez de dejar navegar al vacío.\n const visibleIndex = monthIndex(visibleMonth);\n const canGoBack = !minDate || visibleIndex > monthIndex(minDate);\n const canGoForward = !maxDate || visibleIndex < monthIndex(maxDate);\n\n const monthLabel = `${months[visibleMonth.getMonth()]} ${visibleMonth.getFullYear()}`;\n\n return (\n <View style={[styles.container, style]} testID={testID}>\n <View style={styles.header}>\n <IconButton\n iconName=\"chevron-left\"\n accessibilityLabel=\"Previous month\"\n disabled={!canGoBack}\n onPress={() => shiftMonth(-1)}\n />\n <Text allowFontScaling={allowFontScaling} style={styles.monthLabel} accessibilityRole=\"header\">\n {monthLabel}\n </Text>\n <IconButton\n iconName=\"chevron-right\"\n accessibilityLabel=\"Next month\"\n disabled={!canGoForward}\n onPress={() => shiftMonth(1)}\n />\n </View>\n\n <View style={styles.weekRow}>\n {orderedWeekdays.map((w, i) => (\n <Text allowFontScaling={allowFontScaling} key={`${w}-${i}`} style={styles.weekday}>\n {w}\n </Text>\n ))}\n </View>\n\n {grid.map((row, rowIndex) => (\n <View style={styles.row} key={rowIndex}>\n {row.map((day, cellIndex) => {\n if (!day) return <View style={styles.cell} key={cellIndex} />;\n\n const t = dayTime(day);\n const disabled = (bounds.min !== undefined && t < bounds.min) || (bounds.max !== undefined && t > bounds.max);\n const isStart = isRange && bounds.start === t;\n const isEnd = isRange && bounds.end === t;\n const closed = isRange && bounds.start !== undefined && bounds.end !== undefined;\n const between = closed && t > bounds.start! && t < bounds.end!;\n const selected = isRange ? isStart || isEnd : bounds.selected === t;\n\n return (\n <DayCell\n key={cellIndex}\n day={day}\n label={`${months[day.getMonth()]} ${day.getDate()}, ${day.getFullYear()}`}\n selected={selected}\n // Un día fuera de los límites no se pinta como parte del\n // rango: se ve deshabilitado, y eso es lo que es.\n inRange={between && !disabled}\n isRangeStart={!!closed && isStart && !disabled}\n isRangeEnd={isEnd && !disabled}\n isToday={t === todayTs}\n disabled={disabled}\n allowFontScaling={allowFontScaling}\n styles={styles}\n onPress={handlePress}\n />\n );\n })}\n </View>\n ))}\n </View>\n );\n }\n);\nDatePicker.displayName = 'DatePicker';\n"
|
|
16
16
|
}
|
|
17
17
|
],
|
|
18
18
|
"registryDependencies": [
|