orn-ui 0.2.6 → 0.2.7
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/AGENTS.md +354 -0
- package/README.md +43 -13
- package/lib/typescript/atoms/Badge.d.ts +1 -1
- package/lib/typescript/atoms/Badge.d.ts.map +1 -1
- package/lib/typescript/atoms/Image.d.ts +6 -6
- package/lib/typescript/atoms/Image.d.ts.map +1 -1
- package/lib/typescript/atoms/Input.d.ts +1 -1
- package/lib/typescript/atoms/Input.d.ts.map +1 -1
- package/lib/typescript/atoms/PressableScale.d.ts +1 -1
- package/lib/typescript/atoms/PressableScale.d.ts.map +1 -1
- package/lib/typescript/atoms/Skeleton.d.ts +5 -5
- package/lib/typescript/atoms/Skeleton.d.ts.map +1 -1
- package/lib/typescript/atoms/Spinner.d.ts +4 -4
- package/lib/typescript/atoms/Spinner.d.ts.map +1 -1
- package/lib/typescript/atoms/Transition.d.ts +10 -10
- package/lib/typescript/atoms/Transition.d.ts.map +1 -1
- package/lib/typescript/molecules/AvatarHeader.d.ts +2 -2
- package/lib/typescript/molecules/AvatarHeader.d.ts.map +1 -1
- package/lib/typescript/molecules/FormActions.d.ts +1 -1
- package/lib/typescript/molecules/FormActions.d.ts.map +1 -1
- package/lib/typescript/molecules/OptionCard.d.ts +2 -2
- package/lib/typescript/molecules/OptionCard.d.ts.map +1 -1
- package/lib/typescript/molecules/Stepper.d.ts +8 -8
- package/lib/typescript/molecules/Stepper.d.ts.map +1 -1
- package/lib/typescript/molecules/Steps.d.ts +3 -3
- package/lib/typescript/molecules/Steps.d.ts.map +1 -1
- package/lib/typescript/molecules/SymmetricGrid.d.ts +4 -4
- package/lib/typescript/molecules/SymmetricGrid.d.ts.map +1 -1
- package/lib/typescript/organisms/BottomSheet.d.ts +8 -8
- package/lib/typescript/organisms/BottomSheet.d.ts.map +1 -1
- package/lib/typescript/organisms/DateField.d.ts +10 -10
- package/lib/typescript/organisms/DateField.d.ts.map +1 -1
- package/lib/typescript/organisms/DatePicker.d.ts +10 -10
- package/lib/typescript/organisms/DatePicker.d.ts.map +1 -1
- package/lib/typescript/organisms/List.d.ts +6 -6
- package/lib/typescript/organisms/List.d.ts.map +1 -1
- package/lib/typescript/organisms/NavigationBar.d.ts +2 -2
- package/lib/typescript/organisms/NavigationBar.d.ts.map +1 -1
- package/lib/typescript/organisms/ReorderableList.d.ts +7 -7
- package/lib/typescript/organisms/ReorderableList.d.ts.map +1 -1
- package/lib/typescript/organisms/Screen.d.ts +6 -6
- package/lib/typescript/organisms/SearchList.d.ts +8 -8
- package/lib/typescript/organisms/SearchList.d.ts.map +1 -1
- package/lib/typescript/organisms/Select.d.ts +2 -2
- package/lib/typescript/organisms/Select.d.ts.map +1 -1
- package/lib/typescript/organisms/Toast.d.ts +2 -2
- package/lib/typescript/organisms/Toast.d.ts.map +1 -1
- package/lib/typescript/organisms/ToastProvider.d.ts +2 -2
- package/lib/typescript/organisms/ToastProvider.d.ts.map +1 -1
- package/lib/typescript/organisms/Wizard.d.ts +7 -7
- package/lib/typescript/organisms/Wizard.d.ts.map +1 -1
- package/lib/typescript/theme/UIProvider.d.ts +9 -9
- package/lib/typescript/theme/UIProvider.d.ts.map +1 -1
- package/llms.txt +33 -0
- package/package.json +24 -6
- package/registry/avatar-header.json +1 -1
- package/registry/badge.json +1 -1
- package/registry/bottom-sheet.json +1 -1
- package/registry/core.json +1 -1
- package/registry/date-field.json +1 -1
- package/registry/date-picker.json +1 -1
- package/registry/form-actions.json +1 -1
- package/registry/image.json +1 -1
- package/registry/index.json +26 -26
- package/registry/input.json +1 -1
- package/registry/list.json +1 -1
- package/registry/navigation-bar.json +1 -1
- package/registry/option-card.json +1 -1
- package/registry/pressable-scale.json +1 -1
- package/registry/reorderable-list.json +1 -1
- package/registry/screen.json +1 -1
- package/registry/search-list.json +1 -1
- package/registry/select.json +1 -1
- package/registry/skeleton.json +1 -1
- package/registry/spinner.json +1 -1
- package/registry/stepper.json +1 -1
- package/registry/steps.json +1 -1
- package/registry/symmetric-grid.json +1 -1
- package/registry/toast-provider.json +1 -1
- package/registry/toast.json +1 -1
- package/registry/transition.json +1 -1
- package/registry/wizard.json +1 -1
- package/src/atoms/Badge.tsx +1 -1
- package/src/atoms/Image.tsx +6 -6
- package/src/atoms/Input.tsx +1 -1
- package/src/atoms/PressableScale.tsx +1 -1
- package/src/atoms/Skeleton.tsx +5 -5
- package/src/atoms/Spinner.tsx +4 -4
- package/src/atoms/Transition.tsx +10 -10
- package/src/molecules/AvatarHeader.tsx +2 -2
- package/src/molecules/FormActions.tsx +1 -1
- package/src/molecules/OptionCard.tsx +2 -2
- package/src/molecules/Stepper.tsx +8 -8
- package/src/molecules/Steps.tsx +3 -3
- package/src/molecules/SymmetricGrid.tsx +4 -4
- package/src/organisms/BottomSheet.tsx +8 -8
- package/src/organisms/DateField.tsx +10 -10
- package/src/organisms/DatePicker.tsx +10 -10
- package/src/organisms/List.tsx +6 -6
- package/src/organisms/NavigationBar.tsx +2 -2
- package/src/organisms/ReorderableList.tsx +7 -7
- package/src/organisms/Screen.tsx +6 -6
- package/src/organisms/SearchList.tsx +8 -8
- package/src/organisms/Select.tsx +2 -2
- package/src/organisms/Toast.tsx +2 -2
- package/src/organisms/ToastProvider.tsx +2 -2
- package/src/organisms/Wizard.tsx +7 -7
- package/src/theme/UIProvider.tsx +9 -9
|
@@ -8,28 +8,28 @@ export interface WizardStep extends StepItem {
|
|
|
8
8
|
}
|
|
9
9
|
export interface WizardProps {
|
|
10
10
|
steps: WizardStep[];
|
|
11
|
-
/**
|
|
11
|
+
/** Current step (controlled). If omitted, the Wizard manages its own state. */
|
|
12
12
|
current?: number;
|
|
13
13
|
onStepChange?: (index: number) => void;
|
|
14
14
|
onFinish?: () => void;
|
|
15
15
|
/** @default 'horizontal' */
|
|
16
16
|
orientation?: 'horizontal' | 'vertical';
|
|
17
17
|
completedIndicator?: 'check' | 'number';
|
|
18
|
-
/**
|
|
18
|
+
/** Allows returning to an already-completed step by tapping it in the indicator. @default true */
|
|
19
19
|
allowStepNavigation?: boolean;
|
|
20
20
|
backLabel?: string;
|
|
21
21
|
nextLabel?: string;
|
|
22
22
|
finishLabel?: string;
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* VirtualizedList
|
|
24
|
+
* The step content scrolls when it doesn't fit. Set to false only if
|
|
25
|
+
* a step renders its own virtualized list (nesting a
|
|
26
|
+
* VirtualizedList inside a ScrollView breaks virtualization).
|
|
27
27
|
* @default true
|
|
28
28
|
*/
|
|
29
29
|
scrollableContent?: boolean;
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
31
|
+
* The step content slides in following the navigation direction.
|
|
32
|
+
* Only opacity and transform, so the animation runs on the native thread.
|
|
33
33
|
* @default true
|
|
34
34
|
*/
|
|
35
35
|
animated?: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Wizard.d.ts","sourceRoot":"","sources":["../../../src/organisms/Wizard.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4D,MAAM,OAAO,CAAC;AACjF,OAAO,EAAoB,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAEhF,OAAO,EAAS,KAAK,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAI1D,MAAM,WAAW,UAAW,SAAQ,QAAQ;IAC1C,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC;IACzB,0EAA0E;IAC1E,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB
|
|
1
|
+
{"version":3,"file":"Wizard.d.ts","sourceRoot":"","sources":["../../../src/organisms/Wizard.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4D,MAAM,OAAO,CAAC;AACjF,OAAO,EAAoB,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAEhF,OAAO,EAAS,KAAK,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAI1D,MAAM,WAAW,UAAW,SAAQ,QAAQ;IAC1C,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC;IACzB,0EAA0E;IAC1E,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,+EAA+E;IAC/E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,4BAA4B;IAC5B,WAAW,CAAC,EAAE,YAAY,GAAG,UAAU,CAAC;IACxC,kBAAkB,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;IACxC,kGAAkG;IAClG,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAUD;;;;;;GAMG;AACH,wBAAgB,MAAM,CAAC,EACrB,KAAK,EACL,OAAO,EAAE,iBAAiB,EAC1B,YAAY,EACZ,QAAQ,EACR,WAA0B,EAC1B,kBAA4B,EAC5B,mBAA0B,EAC1B,SAAkB,EAClB,SAAkB,EAClB,WAAsB,EACtB,iBAAwB,EACxB,QAAe,EACf,KAAK,GACN,EAAE,WAAW,qBAkGb"}
|
|
@@ -19,24 +19,24 @@ export interface ModalSafeAreaBoundaryProps {
|
|
|
19
19
|
export type ModalSafeAreaBoundaryComponent = React.ComponentType<ModalSafeAreaBoundaryProps>;
|
|
20
20
|
export interface UIProviderProps {
|
|
21
21
|
children: React.ReactNode;
|
|
22
|
-
/**
|
|
22
|
+
/** Light/dark pair. Defaults to the library's brand theme (create one with createTheme()). */
|
|
23
23
|
theme?: ThemePair;
|
|
24
|
-
/**
|
|
24
|
+
/** Initial mode (uncontrolled) if `mode` isn't passed. @default 'system' */
|
|
25
25
|
defaultMode?: ThemeMode;
|
|
26
|
-
/**
|
|
26
|
+
/** Controlled mode. If passed together with onModeChange, the provider doesn't manage its own state. */
|
|
27
27
|
mode?: ThemeMode;
|
|
28
28
|
onModeChange?: (mode: ThemeMode) => void;
|
|
29
|
-
/**
|
|
29
|
+
/** Icon renderer. Defaults to the included zero-dep glyphs. */
|
|
30
30
|
icons?: IconRenderer;
|
|
31
|
-
/**
|
|
31
|
+
/** Safe area insets (from useSafeAreaInsets()). Defaults to {0,0,0,0}. */
|
|
32
32
|
insets?: EdgeInsets;
|
|
33
33
|
labels?: Partial<Labels>;
|
|
34
|
-
/** @default false,
|
|
34
|
+
/** @default false, for parity with the rest of the library (accessibility first). */
|
|
35
35
|
allowFontScaling?: boolean;
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* @default
|
|
37
|
+
* Source of insets used by `Modal`/`BottomSheet` for their own native
|
|
38
|
+
* window. Don't pass this by hand — `SafeAreaUIProvider` wires it up itself.
|
|
39
|
+
* @default repeats the ambient insets (behavior without safe-area-context)
|
|
40
40
|
*/
|
|
41
41
|
ModalSafeAreaBoundary?: ModalSafeAreaBoundaryComponent;
|
|
42
42
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"UIProvider.d.ts","sourceRoot":"","sources":["../../../src/theme/UIProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4E,MAAM,OAAO,CAAC;AACjG,OAAO,EAAE,UAAU,EAAkB,MAAM,cAAc,CAAC;AAE1D,OAAO,EAAiB,KAAK,MAAM,EAAE,MAAM,UAAU,CAAC;AACtD,OAAO,EAAc,KAAK,UAAU,EAAE,MAAM,UAAU,CAAC;AACvD,OAAO,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEhD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEnD,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,CAAC;AAEpD;;;;;;;GAOG;AACH,MAAM,WAAW,0BAA0B;IACzC,QAAQ,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,KAAK,CAAC,SAAS,CAAC;CACnD;AACD,MAAM,MAAM,8BAA8B,GAAG,KAAK,CAAC,aAAa,CAAC,0BAA0B,CAAC,CAAC;AAoB7F,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B
|
|
1
|
+
{"version":3,"file":"UIProvider.d.ts","sourceRoot":"","sources":["../../../src/theme/UIProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4E,MAAM,OAAO,CAAC;AACjG,OAAO,EAAE,UAAU,EAAkB,MAAM,cAAc,CAAC;AAE1D,OAAO,EAAiB,KAAK,MAAM,EAAE,MAAM,UAAU,CAAC;AACtD,OAAO,EAAc,KAAK,UAAU,EAAE,MAAM,UAAU,CAAC;AACvD,OAAO,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEhD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEnD,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,CAAC;AAEpD;;;;;;;GAOG;AACH,MAAM,WAAW,0BAA0B;IACzC,QAAQ,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,KAAK,CAAC,SAAS,CAAC;CACnD;AACD,MAAM,MAAM,8BAA8B,GAAG,KAAK,CAAC,aAAa,CAAC,0BAA0B,CAAC,CAAC;AAoB7F,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,8FAA8F;IAC9F,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,4EAA4E;IAC5E,WAAW,CAAC,EAAE,SAAS,CAAC;IACxB,wGAAwG;IACxG,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,IAAI,CAAC;IACzC,+DAA+D;IAC/D,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,0EAA0E;IAC1E,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,MAAM,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC;IACzB,qFAAqF;IACrF,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,8BAA8B,CAAC;CACxD;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,EACzB,QAAQ,EACR,KAAoB,EACpB,WAAsB,EACtB,IAAI,EAAE,cAAc,EACpB,YAAY,EACZ,KAAyB,EACzB,MAAmB,EACnB,MAAM,EAAE,cAAc,EACtB,gBAAwB,EACxB,qBAAoD,GACrD,EAAE,eAAe,qBAwDjB;AAUD,wBAAgB,QAAQ,IAAI,KAAK,CAEhC;AAED,wBAAgB,SAAS,IAAI,KAAK,CAAC,QAAQ,CAAC,CAE3C;AAED,wBAAgB,YAAY;;oBA9HV,SAAS,KAAK,IAAI;EAiInC;AAED,wBAAgB,QAAQ,IAAI,YAAY,CAEvC;AAED,wBAAgB,SAAS,IAAI,UAAU,CAEtC;AAED,wBAAgB,SAAS,IAAI,MAAM,CAElC;AAED,wBAAgB,mBAAmB,IAAI,OAAO,CAE7C;AAED,yGAAyG;AACzG,wBAAgB,wBAAwB,IAAI,8BAA8B,CAEzE;AAED,8FAA8F;AAC9F,eAAO,MAAM,WAAW;;CAAiB,CAAC"}
|
package/llms.txt
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# orn-ui
|
|
2
|
+
|
|
3
|
+
> React Native / Expo component library: 44 typed components (atoms, molecules,
|
|
4
|
+
> organisms), light/dark theming, zero runtime dependencies (only `react` and
|
|
5
|
+
> `react-native` as peers). Verified in CI against Expo SDK 54, 55, 56 and 57.
|
|
6
|
+
> Built to be used by AI coding agents: one component replaces dozens of lines
|
|
7
|
+
> of hand-written `View`/`Text`/`StyleSheet`, so a screen costs a fraction of
|
|
8
|
+
> the tokens and ships production-ready instead of prototype-only.
|
|
9
|
+
|
|
10
|
+
Install: `pnpm add orn-ui` — no native modules, no pods, no babel or metro
|
|
11
|
+
config, works in Expo Go and bare React Native >= 0.81.
|
|
12
|
+
Or copy the source into the project with no dependency: `npx orn-ui add button select`.
|
|
13
|
+
|
|
14
|
+
Import from subpaths (Metro does not tree-shake barrels):
|
|
15
|
+
`import { Button } from 'orn-ui/button'`, `import { UIProvider } from 'orn-ui/theme'`.
|
|
16
|
+
|
|
17
|
+
Wrap the app once: `<SafeAreaUIProvider defaultMode="system">` from `orn-ui/safe-area`
|
|
18
|
+
(or `<UIProvider insets={...}>` from `orn-ui/theme` to skip the optional
|
|
19
|
+
`react-native-safe-area-context` peer).
|
|
20
|
+
|
|
21
|
+
## Docs
|
|
22
|
+
|
|
23
|
+
- [Agent guide](https://github.com/DavidTrujillo123/orn-ui/blob/main/packages/ui/AGENTS.md): full component + props cheat sheet, import rules, gotchas, recipes — shipped in the package as `node_modules/orn-ui/AGENTS.md`
|
|
24
|
+
- [README](https://github.com/DavidTrujillo123/orn-ui#readme): install modes, safe area, theming, compatibility matrix
|
|
25
|
+
- [Live docs](https://orn-ui-docs.vercel.app/): props tables and demo GIFs per component
|
|
26
|
+
- [Repository](https://github.com/DavidTrujillo123/orn-ui): source, tests, CI compatibility matrix
|
|
27
|
+
|
|
28
|
+
## Components
|
|
29
|
+
|
|
30
|
+
- Atoms: Title, Subtitle, Body, Caption, Button, IconButton, Input, Checkbox, Badge, Card, Divider, Avatar, Image, Spinner, Skeleton, Transition, EmptyState, KeyValueRow, Fab, PressableScale
|
|
31
|
+
- Molecules: Stepper, OptionCard, InfoRow, FormActions, AvatarHeader, SegmentedControl, Steps, SymmetricGrid
|
|
32
|
+
- Organisms: Screen, Modal, BottomSheet, Select, List, SearchList, ReorderableList, Alert, AlertProvider, Toast, ToastProvider, DatePicker, DateField, Wizard, ThemeToggle, NavigationBar
|
|
33
|
+
- Theme: UIProvider, SafeAreaUIProvider, createTheme, createStyles, useTheme, useColors, useThemeMode, useIcons, useInsets, useLabels
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orn-ui",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.7",
|
|
4
|
+
"description": "React Native / Expo component library: 44 typed components, light/dark theming, zero runtime dependencies. Verified on Expo SDK 54-57. Agent-friendly: one component instead of hundreds of lines of hand-written UI.",
|
|
5
5
|
"author": "David Trujillo <davot098@gmail.com>",
|
|
6
6
|
"main": "lib/commonjs/index.js",
|
|
7
7
|
"module": "lib/module/index.js",
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
"lib",
|
|
18
18
|
"bin",
|
|
19
19
|
"registry",
|
|
20
|
+
"AGENTS.md",
|
|
21
|
+
"llms.txt",
|
|
20
22
|
"!**/__tests__",
|
|
21
23
|
"!**/*.test.*"
|
|
22
24
|
],
|
|
@@ -342,12 +344,14 @@
|
|
|
342
344
|
"typecheck": "tsc --noEmit",
|
|
343
345
|
"test": "jest",
|
|
344
346
|
"test:coverage": "jest --coverage",
|
|
347
|
+
"compat": "node scripts/compat-matrix.mjs",
|
|
348
|
+
"compat:typecheck": "node scripts/compat-matrix.mjs --only typecheck",
|
|
345
349
|
"prepare": "node scripts/gen-exports.mjs && node scripts/build-registry.mjs && bob build"
|
|
346
350
|
},
|
|
347
351
|
"peerDependencies": {
|
|
348
|
-
"react": ">=
|
|
349
|
-
"react-native": ">=0.
|
|
350
|
-
"react-native-safe-area-context": ">=4.0
|
|
352
|
+
"react": ">=19.1.0",
|
|
353
|
+
"react-native": ">=0.81.0",
|
|
354
|
+
"react-native-safe-area-context": ">=5.4.0"
|
|
351
355
|
},
|
|
352
356
|
"peerDependenciesMeta": {
|
|
353
357
|
"react-native-safe-area-context": {
|
|
@@ -370,9 +374,23 @@
|
|
|
370
374
|
},
|
|
371
375
|
"keywords": [
|
|
372
376
|
"react-native",
|
|
377
|
+
"expo",
|
|
378
|
+
"expo-sdk-54",
|
|
379
|
+
"expo-sdk-55",
|
|
380
|
+
"expo-sdk-56",
|
|
381
|
+
"expo-sdk-57",
|
|
373
382
|
"ui",
|
|
374
383
|
"components",
|
|
375
|
-
"
|
|
384
|
+
"component-library",
|
|
385
|
+
"design-system",
|
|
386
|
+
"atomic-design",
|
|
387
|
+
"dark-mode",
|
|
388
|
+
"theming",
|
|
389
|
+
"typescript",
|
|
390
|
+
"zero-dependencies",
|
|
391
|
+
"ai",
|
|
392
|
+
"llm",
|
|
393
|
+
"agents"
|
|
376
394
|
],
|
|
377
395
|
"license": "MIT",
|
|
378
396
|
"repository": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "molecules/AvatarHeader.tsx",
|
|
12
|
-
"content": "import React, { memo } from 'react';\nimport { Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface AvatarHeaderProps {\n /**
|
|
12
|
+
"content": "import React, { memo } from 'react';\nimport { Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport interface AvatarHeaderProps {\n /** Icon to show inside the circle. If omitted, `initials` is used instead. */\n iconName?: IconName;\n iconColor?: string;\n /** Short text (e.g. 2 letters) shown when `iconName` isn't provided. */\n initials?: string;\n title: string;\n subtitle?: React.ReactNode;\n style?: StyleProp<ViewStyle>;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { alignItems: 'center', marginBottom: theme.tokens.spacing.xl },\n avatar: {\n width: 80,\n height: 80,\n borderRadius: 40,\n backgroundColor: theme.colors.primarySoft,\n justifyContent: 'center',\n alignItems: 'center',\n marginBottom: theme.tokens.spacing.md,\n },\n initials: { fontSize: theme.tokens.fontSize.xxl, fontWeight: theme.tokens.fontWeight.bold, color: theme.colors.primaryText },\n title: { fontSize: theme.tokens.fontSize.xl, fontWeight: theme.tokens.fontWeight.bold, textAlign: 'center', color: theme.colors.text, marginBottom: theme.tokens.spacing.xs },\n subtitle: { fontSize: theme.tokens.fontSize.md, color: theme.colors.textLight },\n}));\n\n/**\n * AvatarHeader\n * Círculo 80x80 con ícono o iniciales + título/subtítulo centrado. Header\n * típico de modales de detalle (cliente, producto, etc).\n */\nexport const AvatarHeader = memo(({ iconName, iconColor, initials, title, subtitle, style }: AvatarHeaderProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n\n return (\n <View style={[styles.container, style]}>\n <View style={styles.avatar}>\n {iconName ? (\n <Icon name={iconName} size={40} color={iconColor || colors.primary} />\n ) : (\n <Text allowFontScaling={allowFontScaling} style={styles.initials}>\n {initials}\n </Text>\n )}\n </View>\n <Text allowFontScaling={allowFontScaling} style={styles.title}>\n {title}\n </Text>\n {typeof subtitle === 'string' ? (\n <Text allowFontScaling={allowFontScaling} style={styles.subtitle}>\n {subtitle}\n </Text>\n ) : (\n subtitle\n )}\n </View>\n );\n});\nAvatarHeader.displayName = 'AvatarHeader';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
package/registry/badge.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "atoms/Badge.tsx",
|
|
12
|
-
"content": "import React, { memo } from 'react';\nimport { Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useTheme } from '../theme/UIProvider';\n\nexport type BadgeVariant = 'success' | 'error' | 'warning' | 'info' | 'neutral';\n\nexport interface BadgeProps {\n label: string;\n variant?: BadgeVariant;\n /**
|
|
12
|
+
"content": "import React, { memo } from 'react';\nimport { Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useTheme } from '../theme/UIProvider';\n\nexport type BadgeVariant = 'success' | 'error' | 'warning' | 'info' | 'neutral';\n\nexport interface BadgeProps {\n label: string;\n variant?: BadgeVariant;\n /** Manual override, takes precedence over `variant`. */\n backgroundColor?: string;\n textColor?: string;\n style?: StyleProp<ViewStyle>;\n}\n\nconst useStyles = createStyles((theme) => ({\n badge: {\n paddingHorizontal: theme.tokens.spacing.md - 2,\n paddingVertical: theme.tokens.spacing.xs + 1,\n borderRadius: theme.tokens.radius.full,\n alignSelf: 'flex-start',\n },\n text: { fontSize: theme.tokens.fontSize.xs, fontWeight: theme.tokens.fontWeight.bold, letterSpacing: 0.3 },\n}));\n\n/**\n * Badge\n * Pill de estado. `variant` cubre los casos comunes (éxito/error/advertencia/\n * info/neutral); `backgroundColor`/`textColor` quedan para casos que no\n * encajan en el set semántico.\n */\nexport const Badge = memo(({ label, variant = 'neutral', backgroundColor, textColor, style }: BadgeProps) => {\n const theme = useTheme();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const { colors } = theme;\n\n const palette: Record<BadgeVariant, { bg: string; fg: string }> = {\n // fg es el rol `*Text`, no el acento pleno: el acento sobre su propio\n // tinte da ~2.2:1 en light — el label quedaba ilegible.\n success: { bg: colors.successSoft, fg: colors.successText },\n error: { bg: colors.errorSoft, fg: colors.errorText },\n warning: { bg: colors.warningSoft, fg: colors.warningText },\n info: { bg: colors.secondarySoft, fg: colors.secondaryText },\n neutral: { bg: colors.inputBackground, fg: colors.textLight },\n };\n\n return (\n <View style={[styles.badge, { backgroundColor: backgroundColor ?? palette[variant].bg }, style]}>\n <Text allowFontScaling={allowFontScaling} style={[styles.text, { color: textColor ?? palette[variant].fg }]}>\n {label}\n </Text>\n </View>\n );\n});\nBadge.displayName = 'Badge';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/BottomSheet.tsx",
|
|
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, useModalSafeAreaBoundary } 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 /**
|
|
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, useModalSafeAreaBoundary } 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 /** With a title, the header appears, with its close button. */\n title?: string;\n /** Sheet actions. Where they live is decided by `footerPlacement`. */\n footer?: React.ReactNode;\n /**\n * 'fixed' anchors it to the bottom of the sheet; 'scroll' leaves it at the\n * end of the content; 'hide-with-keyboard' hides it while typing, which in a\n * long form gives that strip back to the focused field. @default 'fixed'\n */\n footerPlacement?: FooterPlacement;\n /** The content scrolls when it doesn't fit. @default true */\n scrollable?: boolean;\n /** Dragging the sheet down closes it. @default true */\n draggable?: boolean;\n /** Fraction of the screen (0-1) or pixels. @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 ModalSafeAreaBoundary = useModalSafeAreaBoundary();\n const [mounted, setMounted] = useState(visible);\n const [keyboardOpen, setKeyboardOpen] = useState(false);\n const progress = useRef(new Animated.Value(0)).current;\n // El PanResponder de más abajo se crea una sola vez; leer `onClose`\n // directo del closure daría siempre el de ese primer render. El ref\n // refleja la última prop sin importar cuándo se creó la closure.\n const onCloseRef = useRef(onClose);\n onCloseRef.current = onClose;\n\n useEffect(() => {\n const animation = visible\n ? Animated.timing(progress, { toValue: 1, duration: 250, useNativeDriver: true })\n : Animated.timing(progress, { toValue: 0, duration: 200, useNativeDriver: true });\n if (visible) setMounted(true);\n animation.start(({ finished }) => {\n if (!visible && finished) setMounted(false);\n });\n // Si el efecto vuelve a correr (o el componente se desmonta) antes de\n // que termine, se corta: si no, un `finished` tardío podría llamar\n // `setMounted` sobre un componente ya desmontado.\n return () => animation.stop();\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 onCloseRef.current();\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 {/* Insets medidos dentro de esta ventana nativa (el Modal nativo que\n usa BottomSheet por debajo), no los de la raíz de la app. */}\n <ModalSafeAreaBoundary>\n {(insets) => (\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 )}\n </ModalSafeAreaBoundary>\n </KeyboardAvoidingView>\n </Modal>\n );\n }\n);\nBottomSheet.displayName = 'BottomSheet';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
package/registry/core.json
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
"path": "theme/UIProvider.tsx",
|
|
24
|
-
"content": "import React, { createContext, useContext, useMemo, useState, useCallback, useRef } 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\n/**\n * Un `<Modal>`/`<BottomSheet>` nativo abre su propia ventana (en Android\n * siempre; en iOS según `presentationStyle`), así que los insets medidos en\n * la raíz de la app pueden no valer ahí adentro. Este boundary es el punto de\n * inyección: el default sólo repite los insets ambientes (comportamiento de\n * siempre, cero deps), pero `SafeAreaUIProvider` lo reemplaza por uno que\n * monta un `<SafeAreaProvider>` nuevo y remide dentro de esa ventana.\n */\nexport interface ModalSafeAreaBoundaryProps {\n children: (insets: EdgeInsets) => React.ReactNode;\n}\nexport type ModalSafeAreaBoundaryComponent = React.ComponentType<ModalSafeAreaBoundaryProps>;\n\nfunction DefaultModalSafeAreaBoundary({ children }: ModalSafeAreaBoundaryProps) {\n const insets = useInsets();\n return <>{children(insets)}</>;\n}\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 ModalSafeAreaBoundary: ModalSafeAreaBoundaryComponent;\n}\n\nconst UIContext = createContext<UIContextValue | undefined>(undefined);\n\nexport interface UIProviderProps {\n children: React.ReactNode;\n /**
|
|
24
|
+
"content": "import React, { createContext, useContext, useMemo, useState, useCallback, useRef } 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\n/**\n * Un `<Modal>`/`<BottomSheet>` nativo abre su propia ventana (en Android\n * siempre; en iOS según `presentationStyle`), así que los insets medidos en\n * la raíz de la app pueden no valer ahí adentro. Este boundary es el punto de\n * inyección: el default sólo repite los insets ambientes (comportamiento de\n * siempre, cero deps), pero `SafeAreaUIProvider` lo reemplaza por uno que\n * monta un `<SafeAreaProvider>` nuevo y remide dentro de esa ventana.\n */\nexport interface ModalSafeAreaBoundaryProps {\n children: (insets: EdgeInsets) => React.ReactNode;\n}\nexport type ModalSafeAreaBoundaryComponent = React.ComponentType<ModalSafeAreaBoundaryProps>;\n\nfunction DefaultModalSafeAreaBoundary({ children }: ModalSafeAreaBoundaryProps) {\n const insets = useInsets();\n return <>{children(insets)}</>;\n}\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 ModalSafeAreaBoundary: ModalSafeAreaBoundaryComponent;\n}\n\nconst UIContext = createContext<UIContextValue | undefined>(undefined);\n\nexport interface UIProviderProps {\n children: React.ReactNode;\n /** Light/dark pair. Defaults to the library's brand theme (create one with createTheme()). */\n theme?: ThemePair;\n /** Initial mode (uncontrolled) if `mode` isn't passed. @default 'system' */\n defaultMode?: ThemeMode;\n /** Controlled mode. If passed together with onModeChange, the provider doesn't manage its own state. */\n mode?: ThemeMode;\n onModeChange?: (mode: ThemeMode) => void;\n /** Icon renderer. Defaults to the included zero-dep glyphs. */\n icons?: IconRenderer;\n /** Safe area insets (from useSafeAreaInsets()). Defaults to {0,0,0,0}. */\n insets?: EdgeInsets;\n labels?: Partial<Labels>;\n /** @default false, for parity with the rest of the library (accessibility first). */\n allowFontScaling?: boolean;\n /**\n * Source of insets used by `Modal`/`BottomSheet` for their own native\n * window. Don't pass this by hand — `SafeAreaUIProvider` wires it up itself.\n * @default repeats the ambient insets (behavior without safe-area-context)\n */\n ModalSafeAreaBoundary?: ModalSafeAreaBoundaryComponent;\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 ModalSafeAreaBoundary = DefaultModalSafeAreaBoundary,\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 // Por instancia, no por módulo: dos `<UIProvider>` en el mismo árbol (tests,\n // un modal con su propio provider) avisan cada uno si le faltan insets, en\n // vez de que el segundo se calle porque el primero ya avisó una vez.\n const warnedAboutInsets = useRef(false);\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.current) {\n warnedAboutInsets.current = 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 () => ({\n theme: resolvedTheme,\n mode,\n setMode,\n icons,\n insets,\n labels,\n allowFontScaling,\n ModalSafeAreaBoundary,\n }),\n [resolvedTheme, mode, setMode, icons, insets, labels, allowFontScaling, ModalSafeAreaBoundary]\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/** Interno: lo consumen `Modal`/`BottomSheet` para remedir insets dentro de su propia ventana nativa. */\nexport function useModalSafeAreaBoundary(): ModalSafeAreaBoundaryComponent {\n return useUIContext().ModalSafeAreaBoundary;\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
26
|
{
|
|
27
27
|
"path": "theme/colors.ts",
|
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, 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'
|
|
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' picks one date; 'range' picks an interval with two taps.\n * @default 'single'\n */\n mode?: 'single' | 'range';\n /** Selected date in mode=\"single\". */\n value?: Date;\n /** Called in mode=\"single\". */\n onChange?: (date: Date) => void;\n /** Selected range in mode=\"range\". */\n range?: DateRange;\n /** Called in mode=\"range\", both when setting the start and when closing the range. */\n onRangeChange?: (range: DateRange) => void;\n placeholder?: string;\n error?: string;\n disabled?: boolean;\n /** Formats the date in the field. @default \"Month D, YYYY\" */\n format?: (date: Date) => string;\n /** Title of the calendar modal. @default the `selectDate` from `useLabels()` */\n modalTitle?: string;\n /**\n * If provided, the modal shows a button that clears the selection and closes.\n * Without this there's no button: the previous \"Clear\" only closed, without clearing anything.\n */\n onClear?: () => void;\n /** Text for that button. @default the `clear` from `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={hasValue ? `${label ?? placeholder}, ${display}` : (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": [
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
{
|
|
14
14
|
"path": "organisms/DatePicker.tsx",
|
|
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"
|
|
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' picks one date; 'range' picks an interval with two taps.\n * @default 'single'\n */\n mode?: 'single' | 'range';\n /** Selected date in mode=\"single\". */\n value?: Date;\n /** Called in mode=\"single\". */\n onChange?: (date: Date) => void;\n /** Selected range in mode=\"range\". */\n range?: DateRange;\n /** Called in mode=\"range\", both when setting the start and when closing the range. */\n onRangeChange?: (range: DateRange) => void;\n minDate?: Date;\n maxDate?: Date;\n /** Month shown on open if there's no `value`. @default today */\n defaultMonth?: Date;\n /** Month names. @default those from `useLabels()` */\n monthNames?: string[];\n /** Day initials, index 0 = Sunday. @default those from `useLabels()` */\n weekdayNames?: string[];\n /** 0 = Sunday, 1 = Monday. @default 0 */\n firstDayOfWeek?: 0 | 1;\n /** Reports which month became visible when navigating with the arrows. */\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": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "molecules/FormActions.tsx",
|
|
12
|
-
"content": "import React, { memo } from 'react';\nimport { View, type StyleProp, type ViewStyle } from 'react-native';\nimport { Button } from '../atoms/Button';\nimport type { IconName } from '../icons/types';\nimport { useLabels } from '../theme/UIProvider';\n\nexport interface FormActionsProps {\n /**
|
|
12
|
+
"content": "import React, { memo } from 'react';\nimport { View, type StyleProp, type ViewStyle } from 'react-native';\nimport { Button } from '../atoms/Button';\nimport type { IconName } from '../icons/types';\nimport { useLabels } from '../theme/UIProvider';\n\nexport interface FormActionsProps {\n /** If omitted, only the secondary button is shown. */\n primaryLabel?: string;\n onPrimaryPress?: () => void;\n primaryDisabled?: boolean;\n primaryLoading?: boolean;\n primaryLeftIconName?: IconName;\n secondaryLabel?: string;\n onSecondaryPress: () => void;\n style?: StyleProp<ViewStyle>;\n}\n\n/**\n * FormActions\n * Par de botones: uno primario (guardar/editar) y uno ghost (cerrar/cancelar).\n * Uso típico: al final de un form, o en el footer de un Modal.\n */\nexport const FormActions = memo(\n ({\n primaryLabel,\n onPrimaryPress,\n primaryDisabled = false,\n primaryLoading = false,\n primaryLeftIconName,\n secondaryLabel,\n onSecondaryPress,\n style,\n }: FormActionsProps) => {\n const labels = useLabels();\n return (\n <View style={[{ gap: 12 }, style]}>\n {primaryLabel && onPrimaryPress && (\n <Button\n title={primaryLabel}\n onPress={onPrimaryPress}\n disabled={primaryDisabled}\n loading={primaryLoading}\n leftIconName={primaryLeftIconName}\n />\n )}\n <Button title={secondaryLabel ?? labels.close} onPress={onSecondaryPress} variant=\"ghost\" />\n </View>\n );\n }\n);\nFormActions.displayName = 'FormActions';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
package/registry/image.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
{
|
|
12
12
|
"path": "atoms/Image.tsx",
|
|
13
|
-
"content": "import React, { memo, useCallback, useEffect, useState } from 'react';\nimport {\n Image as RNImage,\n Platform,\n StyleSheet,\n View,\n type ImageErrorEventData,\n type ImageResizeMode,\n type ImageSourcePropType,\n type NativeSyntheticEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { Spinner } from './Spinner';\n\nexport interface ImageProps {\n source: ImageSourcePropType;\n width?: number;\n height?: number;\n /** @default 'cover' */\n resizeMode?: ImageResizeMode;\n /**
|
|
13
|
+
"content": "import React, { memo, useCallback, useEffect, useState } from 'react';\nimport {\n Image as RNImage,\n Platform,\n StyleSheet,\n View,\n type ImageErrorEventData,\n type ImageResizeMode,\n type ImageSourcePropType,\n type NativeSyntheticEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { Spinner } from './Spinner';\n\nexport interface ImageProps {\n source: ImageSourcePropType;\n width?: number;\n height?: number;\n /** @default 'cover' */\n resizeMode?: ImageResizeMode;\n /** Corner radius, same pattern as Avatar/Card. @default 0 */\n radius?: number;\n /** What to show while loading. `false` to show nothing. @default true (small spinner) */\n loading?: React.ReactNode | boolean;\n /** What to show if loading fails. @default a gray placeholder, no content */\n fallback?: React.ReactNode;\n /**\n * 'high' triggers Image.prefetch() on mount, before <Image> starts\n * its own load cycle — meant for the above-the-fold image of a\n * screen (the first one the user sees). No cost if it's already cached.\n * @default 'normal'\n */\n priority?: 'low' | 'normal' | 'high';\n style?: StyleProp<ViewStyle>;\n onLoadEnd?: () => void;\n onError?: (error: NativeSyntheticEvent<ImageErrorEventData>) => void;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { overflow: 'hidden', backgroundColor: theme.colors.skeleton },\n overlay: { alignItems: 'center', justifyContent: 'center' },\n fallback: { flex: 1, backgroundColor: theme.colors.skeleton },\n}));\n\nfunction sourceUri(source: ImageSourcePropType): string | undefined {\n return typeof source === 'object' && source !== null && 'uri' in source ? source.uri : undefined;\n}\n\n/**\n * Image\n * Wrapper cero-dependencias sobre <Image> de RN: agrega estado de carga y\n * error (con los mismos placeholders que el resto de la librería), y expone\n * `priority=\"high\"` para adelantar la descarga vía Image.prefetch(). El\n * cache real (disco/memoria) lo maneja el motor de imágenes nativo de RN —\n * esto no lo reimplementa, solo no lo deja apagado por accidente: en iOS\n * fuerza `cache=\"force-cache\"` (usa el disco aunque el header HTTP no lo\n * pida); en Android el cache de disco de la imagen nativa ya está siempre\n * activo, no hay knob equivalente que tocar.\n */\nexport const Image = memo(\n ({\n source,\n width,\n height,\n resizeMode = 'cover',\n radius = 0,\n loading = true,\n fallback,\n priority = 'normal',\n style,\n onLoadEnd,\n onError,\n testID,\n }: ImageProps) => {\n const styles = useStyles();\n const [status, setStatus] = useState<'loading' | 'loaded' | 'error'>('loading');\n const uri = sourceUri(source);\n\n useEffect(() => {\n // Resetea el estado si cambia la imagen (misma instancia de Image,\n // source distinto: p.ej. avatar de un usuario a otro en la misma fila).\n setStatus('loading');\n }, [uri ?? source]);\n\n useEffect(() => {\n if (priority === 'high' && uri) {\n RNImage.prefetch(uri).catch(() => {});\n }\n }, [priority, uri]);\n\n const handleError = useCallback(\n (e: NativeSyntheticEvent<ImageErrorEventData>) => {\n setStatus('error');\n onError?.(e);\n },\n [onError]\n );\n\n const handleLoadEnd = useCallback(() => {\n setStatus('loaded');\n onLoadEnd?.();\n }, [onLoadEnd]);\n\n return (\n <View style={[styles.container, { width, height, borderRadius: radius }, style]} testID={testID}>\n {status !== 'error' && (\n <RNImage\n source={source}\n resizeMode={resizeMode}\n style={StyleSheet.absoluteFill}\n fadeDuration={Platform.OS === 'android' ? 150 : undefined}\n {...(Platform.OS === 'ios' ? { cache: 'force-cache' as const } : null)}\n onLoadEnd={handleLoadEnd}\n onError={handleError}\n />\n )}\n {status === 'loading' && loading !== false && (\n <View style={[StyleSheet.absoluteFill, styles.overlay]}>\n {loading === true ? <Spinner size=\"small\" fullscreen={false} /> : loading}\n </View>\n )}\n {status === 'error' && (fallback ?? <View style={styles.fallback} />)}\n </View>\n );\n }\n);\nImage.displayName = 'Image';\n\n/**\n * Precarga una URL antes de montar el componente (p.ej. al recibir la\n * respuesta de un listado, antes de navegar al detalle). Re-exporta\n * Image.prefetch de RN — no hay nada zero-dep que agregarle encima.\n */\nexport const prefetchImage = RNImage.prefetch;\n"
|
|
14
14
|
}
|
|
15
15
|
],
|
|
16
16
|
"registryDependencies": [
|