orn-ui 0.2.10 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +66 -51
- package/README.md +3 -7
- package/lib/commonjs/atoms/Avatar.js +77 -5
- package/lib/commonjs/atoms/Avatar.js.map +1 -1
- package/lib/commonjs/atoms/Badge.js +17 -8
- package/lib/commonjs/atoms/Badge.js.map +1 -1
- package/lib/commonjs/atoms/Button.js +108 -39
- package/lib/commonjs/atoms/Button.js.map +1 -1
- package/lib/commonjs/atoms/Card.js +24 -2
- package/lib/commonjs/atoms/Card.js.map +1 -1
- package/lib/commonjs/atoms/Checkbox.js +41 -10
- package/lib/commonjs/atoms/Checkbox.js.map +1 -1
- package/lib/commonjs/atoms/Divider.js +30 -5
- package/lib/commonjs/atoms/Divider.js.map +1 -1
- package/lib/commonjs/atoms/EmptyState.js +58 -20
- package/lib/commonjs/atoms/EmptyState.js.map +1 -1
- package/lib/commonjs/atoms/Fab.js +32 -12
- package/lib/commonjs/atoms/Fab.js.map +1 -1
- package/lib/commonjs/atoms/Gradient.js +102 -36
- package/lib/commonjs/atoms/Gradient.js.map +1 -1
- package/lib/commonjs/atoms/IconButton.js +25 -4
- package/lib/commonjs/atoms/IconButton.js.map +1 -1
- package/lib/commonjs/atoms/Image.js +61 -25
- package/lib/commonjs/atoms/Image.js.map +1 -1
- package/lib/commonjs/atoms/Input.js +105 -17
- package/lib/commonjs/atoms/Input.js.map +1 -1
- package/lib/commonjs/atoms/KeyValueRow.js +37 -9
- package/lib/commonjs/atoms/KeyValueRow.js.map +1 -1
- package/lib/commonjs/atoms/PressableScale.js +25 -5
- package/lib/commonjs/atoms/PressableScale.js.map +1 -1
- package/lib/commonjs/atoms/ShinyText.js +29 -29
- package/lib/commonjs/atoms/Skeleton.js +28 -13
- package/lib/commonjs/atoms/Skeleton.js.map +1 -1
- package/lib/commonjs/atoms/Spinner.js +81 -22
- package/lib/commonjs/atoms/Spinner.js.map +1 -1
- package/lib/commonjs/atoms/Text.js +56 -5
- package/lib/commonjs/atoms/Text.js.map +1 -1
- package/lib/commonjs/atoms/Transition.js +46 -43
- package/lib/commonjs/atoms/Transition.js.map +1 -1
- package/lib/commonjs/atoms/index.js +6 -0
- package/lib/commonjs/atoms/index.js.map +1 -1
- package/lib/commonjs/atoms/inputValidation.js +56 -0
- package/lib/commonjs/atoms/inputValidation.js.map +1 -0
- package/lib/commonjs/icons/Icon.js +4 -4
- package/lib/commonjs/icons/defaultIcons.js +576 -24
- package/lib/commonjs/icons/defaultIcons.js.map +1 -1
- package/lib/commonjs/icons/index.js +6 -0
- package/lib/commonjs/icons/index.js.map +1 -1
- package/lib/commonjs/molecules/AvatarHeader.js +47 -14
- package/lib/commonjs/molecules/AvatarHeader.js.map +1 -1
- package/lib/commonjs/molecules/FormActions.js +25 -9
- package/lib/commonjs/molecules/FormActions.js.map +1 -1
- package/lib/commonjs/molecules/InfoRow.js +30 -14
- package/lib/commonjs/molecules/InfoRow.js.map +1 -1
- package/lib/commonjs/molecules/OptionCard.js +37 -22
- package/lib/commonjs/molecules/OptionCard.js.map +1 -1
- package/lib/commonjs/molecules/SegmentedControl.js +103 -14
- package/lib/commonjs/molecules/SegmentedControl.js.map +1 -1
- package/lib/commonjs/molecules/Stepper.js +61 -16
- package/lib/commonjs/molecules/Stepper.js.map +1 -1
- package/lib/commonjs/molecules/Steps.js +105 -41
- package/lib/commonjs/molecules/Steps.js.map +1 -1
- package/lib/commonjs/molecules/SymmetricGrid.js +26 -18
- package/lib/commonjs/molecules/SymmetricGrid.js.map +1 -1
- package/lib/commonjs/organisms/Alert.js +49 -35
- package/lib/commonjs/organisms/Alert.js.map +1 -1
- package/lib/commonjs/organisms/AlertProvider.js +10 -10
- package/lib/commonjs/organisms/BottomSheet.js +137 -85
- package/lib/commonjs/organisms/BottomSheet.js.map +1 -1
- package/lib/commonjs/organisms/Chart.js +174 -154
- package/lib/commonjs/organisms/Chart.js.map +1 -1
- package/lib/commonjs/organisms/DateField.js +36 -20
- package/lib/commonjs/organisms/DateField.js.map +1 -1
- package/lib/commonjs/organisms/DatePicker.js +56 -33
- package/lib/commonjs/organisms/DatePicker.js.map +1 -1
- package/lib/commonjs/organisms/List.js +62 -34
- package/lib/commonjs/organisms/List.js.map +1 -1
- package/lib/commonjs/organisms/Modal.js +120 -82
- package/lib/commonjs/organisms/Modal.js.map +1 -1
- package/lib/commonjs/organisms/NavigationBar.js +63 -24
- package/lib/commonjs/organisms/NavigationBar.js.map +1 -1
- package/lib/commonjs/organisms/OptionWheel.js +64 -64
- package/lib/commonjs/organisms/OptionWheel.js.map +1 -1
- package/lib/commonjs/organisms/ReorderableList.js +48 -27
- package/lib/commonjs/organisms/ReorderableList.js.map +1 -1
- package/lib/commonjs/organisms/Screen.js +68 -35
- package/lib/commonjs/organisms/Screen.js.map +1 -1
- package/lib/commonjs/organisms/SearchList.js +56 -26
- package/lib/commonjs/organisms/SearchList.js.map +1 -1
- package/lib/commonjs/organisms/Select.js +45 -24
- package/lib/commonjs/organisms/Select.js.map +1 -1
- package/lib/commonjs/organisms/Slides.js +36 -23
- package/lib/commonjs/organisms/Slides.js.map +1 -1
- package/lib/commonjs/organisms/ThemeToggle.js +10 -9
- package/lib/commonjs/organisms/ThemeToggle.js.map +1 -1
- package/lib/commonjs/organisms/Timeline.js +73 -56
- package/lib/commonjs/organisms/Timeline.js.map +1 -1
- package/lib/commonjs/organisms/Toast.js +46 -15
- package/lib/commonjs/organisms/Toast.js.map +1 -1
- package/lib/commonjs/organisms/ToastProvider.js +14 -10
- package/lib/commonjs/organisms/ToastProvider.js.map +1 -1
- package/lib/commonjs/organisms/Wizard.js +40 -15
- package/lib/commonjs/organisms/Wizard.js.map +1 -1
- package/lib/commonjs/organisms/chart/frame.js +25 -25
- package/lib/commonjs/organisms/chart/frame.js.map +1 -1
- package/lib/commonjs/organisms/chart/marks.js +42 -42
- package/lib/commonjs/organisms/chart/math.js +84 -85
- package/lib/commonjs/organisms/chart/math.js.map +1 -1
- package/lib/commonjs/organisms/chart/palette.js +28 -28
- package/lib/commonjs/organisms/imperativeBridge.js +5 -5
- package/lib/commonjs/organisms/imperativeBridge.js.map +1 -1
- package/lib/commonjs/organisms/useSlides.js +53 -21
- package/lib/commonjs/organisms/useSlides.js.map +1 -1
- package/lib/commonjs/safe-area/SafeAreaUIProvider.js +9 -66
- package/lib/commonjs/safe-area/SafeAreaUIProvider.js.map +1 -1
- package/lib/commonjs/safe-area/index.js.map +1 -1
- package/lib/commonjs/theme/KeyboardAvoidance.js +206 -0
- package/lib/commonjs/theme/KeyboardAvoidance.js.map +1 -0
- package/lib/commonjs/theme/NativeOverlay.js +263 -0
- package/lib/commonjs/theme/NativeOverlay.js.map +1 -0
- package/lib/commonjs/theme/UIProvider.js +86 -33
- package/lib/commonjs/theme/UIProvider.js.map +1 -1
- package/lib/commonjs/theme/colors.js +17 -17
- package/lib/commonjs/theme/colors.js.map +1 -1
- package/lib/commonjs/theme/createStyles.js +19 -16
- package/lib/commonjs/theme/createStyles.js.map +1 -1
- package/lib/commonjs/theme/createTheme.js +6 -6
- package/lib/commonjs/theme/index.js +6 -0
- package/lib/commonjs/theme/index.js.map +1 -1
- package/lib/commonjs/theme/insets.js +1 -1
- package/lib/commonjs/theme/labels.js +29 -1
- package/lib/commonjs/theme/labels.js.map +1 -1
- package/lib/commonjs/theme/palettes.js +25 -23
- package/lib/commonjs/theme/palettes.js.map +1 -1
- package/lib/commonjs/theme/tokens.js +28 -14
- package/lib/commonjs/theme/tokens.js.map +1 -1
- package/lib/commonjs/theme/useKeyboardField.js +36 -0
- package/lib/commonjs/theme/useKeyboardField.js.map +1 -0
- package/lib/commonjs/theme/useReduceMotion.js +31 -0
- package/lib/commonjs/theme/useReduceMotion.js.map +1 -0
- package/lib/module/atoms/Avatar.js +79 -8
- package/lib/module/atoms/Avatar.js.map +1 -1
- package/lib/module/atoms/Badge.js +17 -8
- package/lib/module/atoms/Badge.js.map +1 -1
- package/lib/module/atoms/Button.js +110 -41
- package/lib/module/atoms/Button.js.map +1 -1
- package/lib/module/atoms/Card.js +24 -2
- package/lib/module/atoms/Card.js.map +1 -1
- package/lib/module/atoms/Checkbox.js +42 -11
- package/lib/module/atoms/Checkbox.js.map +1 -1
- package/lib/module/atoms/Divider.js +31 -6
- package/lib/module/atoms/Divider.js.map +1 -1
- package/lib/module/atoms/EmptyState.js +58 -20
- package/lib/module/atoms/EmptyState.js.map +1 -1
- package/lib/module/atoms/Fab.js +33 -13
- package/lib/module/atoms/Fab.js.map +1 -1
- package/lib/module/atoms/Gradient.js +103 -37
- package/lib/module/atoms/Gradient.js.map +1 -1
- package/lib/module/atoms/IconButton.js +25 -4
- package/lib/module/atoms/IconButton.js.map +1 -1
- package/lib/module/atoms/Image.js +61 -25
- package/lib/module/atoms/Image.js.map +1 -1
- package/lib/module/atoms/Input.js +108 -20
- package/lib/module/atoms/Input.js.map +1 -1
- package/lib/module/atoms/KeyValueRow.js +37 -9
- package/lib/module/atoms/KeyValueRow.js.map +1 -1
- package/lib/module/atoms/PressableScale.js +25 -5
- package/lib/module/atoms/PressableScale.js.map +1 -1
- package/lib/module/atoms/ShinyText.js +29 -29
- package/lib/module/atoms/Skeleton.js +30 -13
- package/lib/module/atoms/Skeleton.js.map +1 -1
- package/lib/module/atoms/Spinner.js +82 -23
- package/lib/module/atoms/Spinner.js.map +1 -1
- package/lib/module/atoms/Text.js +56 -5
- package/lib/module/atoms/Text.js.map +1 -1
- package/lib/module/atoms/Transition.js +41 -42
- package/lib/module/atoms/Transition.js.map +1 -1
- package/lib/module/atoms/index.js +1 -1
- package/lib/module/atoms/index.js.map +1 -1
- package/lib/module/atoms/inputValidation.js +52 -0
- package/lib/module/atoms/inputValidation.js.map +1 -0
- package/lib/module/icons/Icon.js +4 -4
- package/lib/module/icons/defaultIcons.js +574 -24
- package/lib/module/icons/defaultIcons.js.map +1 -1
- package/lib/module/icons/index.js +1 -1
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/molecules/AvatarHeader.js +47 -14
- package/lib/module/molecules/AvatarHeader.js.map +1 -1
- package/lib/module/molecules/FormActions.js +25 -9
- package/lib/module/molecules/FormActions.js.map +1 -1
- package/lib/module/molecules/InfoRow.js +30 -14
- package/lib/module/molecules/InfoRow.js.map +1 -1
- package/lib/module/molecules/OptionCard.js +37 -22
- package/lib/module/molecules/OptionCard.js.map +1 -1
- package/lib/module/molecules/SegmentedControl.js +107 -18
- package/lib/module/molecules/SegmentedControl.js.map +1 -1
- package/lib/module/molecules/Stepper.js +63 -18
- package/lib/module/molecules/Stepper.js.map +1 -1
- package/lib/module/molecules/Steps.js +107 -43
- package/lib/module/molecules/Steps.js.map +1 -1
- package/lib/module/molecules/SymmetricGrid.js +26 -18
- package/lib/module/molecules/SymmetricGrid.js.map +1 -1
- package/lib/module/organisms/Alert.js +51 -37
- package/lib/module/organisms/Alert.js.map +1 -1
- package/lib/module/organisms/AlertProvider.js +10 -10
- package/lib/module/organisms/BottomSheet.js +142 -88
- package/lib/module/organisms/BottomSheet.js.map +1 -1
- package/lib/module/organisms/Chart.js +176 -156
- package/lib/module/organisms/Chart.js.map +1 -1
- package/lib/module/organisms/DateField.js +36 -20
- package/lib/module/organisms/DateField.js.map +1 -1
- package/lib/module/organisms/DatePicker.js +56 -33
- package/lib/module/organisms/DatePicker.js.map +1 -1
- package/lib/module/organisms/List.js +62 -34
- package/lib/module/organisms/List.js.map +1 -1
- package/lib/module/organisms/Modal.js +122 -84
- package/lib/module/organisms/Modal.js.map +1 -1
- package/lib/module/organisms/NavigationBar.js +64 -25
- package/lib/module/organisms/NavigationBar.js.map +1 -1
- package/lib/module/organisms/OptionWheel.js +64 -64
- package/lib/module/organisms/OptionWheel.js.map +1 -1
- package/lib/module/organisms/ReorderableList.js +48 -27
- package/lib/module/organisms/ReorderableList.js.map +1 -1
- package/lib/module/organisms/Screen.js +70 -37
- package/lib/module/organisms/Screen.js.map +1 -1
- package/lib/module/organisms/SearchList.js +56 -26
- package/lib/module/organisms/SearchList.js.map +1 -1
- package/lib/module/organisms/Select.js +46 -25
- package/lib/module/organisms/Select.js.map +1 -1
- package/lib/module/organisms/Slides.js +37 -24
- package/lib/module/organisms/Slides.js.map +1 -1
- package/lib/module/organisms/ThemeToggle.js +11 -10
- package/lib/module/organisms/ThemeToggle.js.map +1 -1
- package/lib/module/organisms/Timeline.js +73 -56
- package/lib/module/organisms/Timeline.js.map +1 -1
- package/lib/module/organisms/Toast.js +47 -16
- package/lib/module/organisms/Toast.js.map +1 -1
- package/lib/module/organisms/ToastProvider.js +15 -11
- package/lib/module/organisms/ToastProvider.js.map +1 -1
- package/lib/module/organisms/Wizard.js +41 -16
- package/lib/module/organisms/Wizard.js.map +1 -1
- package/lib/module/organisms/chart/frame.js +25 -25
- package/lib/module/organisms/chart/frame.js.map +1 -1
- package/lib/module/organisms/chart/marks.js +42 -42
- package/lib/module/organisms/chart/math.js +84 -85
- package/lib/module/organisms/chart/math.js.map +1 -1
- package/lib/module/organisms/chart/palette.js +28 -28
- package/lib/module/organisms/imperativeBridge.js +5 -5
- package/lib/module/organisms/imperativeBridge.js.map +1 -1
- package/lib/module/organisms/useSlides.js +53 -21
- package/lib/module/organisms/useSlides.js.map +1 -1
- package/lib/module/safe-area/SafeAreaUIProvider.js +10 -65
- package/lib/module/safe-area/SafeAreaUIProvider.js.map +1 -1
- package/lib/module/safe-area/index.js +1 -0
- package/lib/module/safe-area/index.js.map +1 -1
- package/lib/module/theme/KeyboardAvoidance.js +198 -0
- package/lib/module/theme/KeyboardAvoidance.js.map +1 -0
- package/lib/module/theme/NativeOverlay.js +257 -0
- package/lib/module/theme/NativeOverlay.js.map +1 -0
- package/lib/module/theme/UIProvider.js +88 -35
- package/lib/module/theme/UIProvider.js.map +1 -1
- package/lib/module/theme/colors.js +17 -17
- package/lib/module/theme/colors.js.map +1 -1
- package/lib/module/theme/createStyles.js +20 -16
- package/lib/module/theme/createStyles.js.map +1 -1
- package/lib/module/theme/createTheme.js +6 -6
- package/lib/module/theme/index.js +1 -1
- package/lib/module/theme/index.js.map +1 -1
- package/lib/module/theme/insets.js +1 -1
- package/lib/module/theme/labels.js +29 -1
- package/lib/module/theme/labels.js.map +1 -1
- package/lib/module/theme/palettes.js +25 -23
- package/lib/module/theme/palettes.js.map +1 -1
- package/lib/module/theme/tokens.js +27 -13
- package/lib/module/theme/tokens.js.map +1 -1
- package/lib/module/theme/useKeyboardField.js +33 -0
- package/lib/module/theme/useKeyboardField.js.map +1 -0
- package/lib/module/theme/useReduceMotion.js +28 -0
- package/lib/module/theme/useReduceMotion.js.map +1 -0
- package/lib/typescript/atoms/Avatar.d.ts +35 -5
- package/lib/typescript/atoms/Avatar.d.ts.map +1 -1
- package/lib/typescript/atoms/Badge.d.ts +14 -3
- package/lib/typescript/atoms/Badge.d.ts.map +1 -1
- package/lib/typescript/atoms/Button.d.ts +34 -3
- package/lib/typescript/atoms/Button.d.ts.map +1 -1
- package/lib/typescript/atoms/Card.d.ts +13 -2
- package/lib/typescript/atoms/Card.d.ts.map +1 -1
- package/lib/typescript/atoms/Checkbox.d.ts +26 -1
- package/lib/typescript/atoms/Checkbox.d.ts.map +1 -1
- package/lib/typescript/atoms/Divider.d.ts +18 -1
- package/lib/typescript/atoms/Divider.d.ts.map +1 -1
- package/lib/typescript/atoms/EmptyState.d.ts +30 -3
- package/lib/typescript/atoms/EmptyState.d.ts.map +1 -1
- package/lib/typescript/atoms/Fab.d.ts +24 -4
- package/lib/typescript/atoms/Fab.d.ts.map +1 -1
- package/lib/typescript/atoms/Gradient.d.ts +21 -15
- package/lib/typescript/atoms/Gradient.d.ts.map +1 -1
- package/lib/typescript/atoms/IconButton.d.ts +18 -4
- package/lib/typescript/atoms/IconButton.d.ts.map +1 -1
- package/lib/typescript/atoms/Image.d.ts +39 -17
- package/lib/typescript/atoms/Image.d.ts.map +1 -1
- package/lib/typescript/atoms/Input.d.ts +57 -7
- package/lib/typescript/atoms/Input.d.ts.map +1 -1
- package/lib/typescript/atoms/KeyValueRow.d.ts +16 -4
- package/lib/typescript/atoms/KeyValueRow.d.ts.map +1 -1
- package/lib/typescript/atoms/PressableScale.d.ts +12 -5
- package/lib/typescript/atoms/PressableScale.d.ts.map +1 -1
- package/lib/typescript/atoms/ShinyText.d.ts +17 -17
- package/lib/typescript/atoms/ShinyText.d.ts.map +1 -1
- package/lib/typescript/atoms/Skeleton.d.ts +12 -7
- package/lib/typescript/atoms/Skeleton.d.ts.map +1 -1
- package/lib/typescript/atoms/Spinner.d.ts +29 -1
- package/lib/typescript/atoms/Spinner.d.ts.map +1 -1
- package/lib/typescript/atoms/Text.d.ts +46 -0
- package/lib/typescript/atoms/Text.d.ts.map +1 -1
- package/lib/typescript/atoms/Transition.d.ts +19 -15
- package/lib/typescript/atoms/Transition.d.ts.map +1 -1
- package/lib/typescript/atoms/index.d.ts +3 -3
- package/lib/typescript/atoms/index.d.ts.map +1 -1
- package/lib/typescript/atoms/inputValidation.d.ts +29 -0
- package/lib/typescript/atoms/inputValidation.d.ts.map +1 -0
- package/lib/typescript/icons/Icon.d.ts +4 -4
- package/lib/typescript/icons/defaultIcons.d.ts +19 -2
- package/lib/typescript/icons/defaultIcons.d.ts.map +1 -1
- package/lib/typescript/icons/index.d.ts +2 -2
- package/lib/typescript/icons/index.d.ts.map +1 -1
- package/lib/typescript/icons/types.d.ts +13 -3
- package/lib/typescript/icons/types.d.ts.map +1 -1
- package/lib/typescript/molecules/AvatarHeader.d.ts +23 -4
- package/lib/typescript/molecules/AvatarHeader.d.ts.map +1 -1
- package/lib/typescript/molecules/FormActions.d.ts +21 -4
- package/lib/typescript/molecules/FormActions.d.ts.map +1 -1
- package/lib/typescript/molecules/InfoRow.d.ts +17 -3
- package/lib/typescript/molecules/InfoRow.d.ts.map +1 -1
- package/lib/typescript/molecules/OptionCard.d.ts +20 -5
- package/lib/typescript/molecules/OptionCard.d.ts.map +1 -1
- package/lib/typescript/molecules/SegmentedControl.d.ts +18 -2
- package/lib/typescript/molecules/SegmentedControl.d.ts.map +1 -1
- package/lib/typescript/molecules/Stepper.d.ts +23 -7
- package/lib/typescript/molecules/Stepper.d.ts.map +1 -1
- package/lib/typescript/molecules/Steps.d.ts +18 -4
- package/lib/typescript/molecules/Steps.d.ts.map +1 -1
- package/lib/typescript/molecules/SymmetricGrid.d.ts +15 -5
- package/lib/typescript/molecules/SymmetricGrid.d.ts.map +1 -1
- package/lib/typescript/organisms/Alert.d.ts +28 -2
- package/lib/typescript/organisms/Alert.d.ts.map +1 -1
- package/lib/typescript/organisms/AlertProvider.d.ts +10 -10
- package/lib/typescript/organisms/AlertProvider.d.ts.map +1 -1
- package/lib/typescript/organisms/BottomSheet.d.ts +20 -5
- package/lib/typescript/organisms/BottomSheet.d.ts.map +1 -1
- package/lib/typescript/organisms/Chart.d.ts +86 -75
- package/lib/typescript/organisms/Chart.d.ts.map +1 -1
- package/lib/typescript/organisms/DateField.d.ts +17 -3
- package/lib/typescript/organisms/DateField.d.ts.map +1 -1
- package/lib/typescript/organisms/DatePicker.d.ts +7 -11
- package/lib/typescript/organisms/DatePicker.d.ts.map +1 -1
- package/lib/typescript/organisms/List.d.ts +30 -3
- package/lib/typescript/organisms/List.d.ts.map +1 -1
- package/lib/typescript/organisms/Modal.d.ts +30 -3
- package/lib/typescript/organisms/Modal.d.ts.map +1 -1
- package/lib/typescript/organisms/NavigationBar.d.ts +26 -7
- package/lib/typescript/organisms/NavigationBar.d.ts.map +1 -1
- package/lib/typescript/organisms/OptionWheel.d.ts +20 -16
- package/lib/typescript/organisms/OptionWheel.d.ts.map +1 -1
- package/lib/typescript/organisms/ReorderableList.d.ts +20 -11
- package/lib/typescript/organisms/ReorderableList.d.ts.map +1 -1
- package/lib/typescript/organisms/Screen.d.ts +27 -8
- package/lib/typescript/organisms/Screen.d.ts.map +1 -1
- package/lib/typescript/organisms/SearchList.d.ts +46 -3
- package/lib/typescript/organisms/SearchList.d.ts.map +1 -1
- package/lib/typescript/organisms/Select.d.ts +26 -6
- package/lib/typescript/organisms/Select.d.ts.map +1 -1
- package/lib/typescript/organisms/Slides.d.ts +44 -26
- package/lib/typescript/organisms/Slides.d.ts.map +1 -1
- package/lib/typescript/organisms/ThemeToggle.d.ts +6 -2
- package/lib/typescript/organisms/ThemeToggle.d.ts.map +1 -1
- package/lib/typescript/organisms/Timeline.d.ts +26 -9
- package/lib/typescript/organisms/Timeline.d.ts.map +1 -1
- package/lib/typescript/organisms/Toast.d.ts +19 -4
- package/lib/typescript/organisms/Toast.d.ts.map +1 -1
- package/lib/typescript/organisms/ToastProvider.d.ts +8 -8
- package/lib/typescript/organisms/ToastProvider.d.ts.map +1 -1
- package/lib/typescript/organisms/Wizard.d.ts +24 -6
- package/lib/typescript/organisms/Wizard.d.ts.map +1 -1
- package/lib/typescript/organisms/chart/frame.d.ts +21 -21
- package/lib/typescript/organisms/chart/frame.d.ts.map +1 -1
- package/lib/typescript/organisms/chart/marks.d.ts +28 -28
- package/lib/typescript/organisms/chart/marks.d.ts.map +1 -1
- package/lib/typescript/organisms/chart/math.d.ts +69 -70
- package/lib/typescript/organisms/chart/math.d.ts.map +1 -1
- package/lib/typescript/organisms/chart/palette.d.ts +2 -2
- package/lib/typescript/organisms/chart/palette.d.ts.map +1 -1
- package/lib/typescript/organisms/imperativeBridge.d.ts +4 -4
- package/lib/typescript/organisms/useSlides.d.ts +8 -16
- package/lib/typescript/organisms/useSlides.d.ts.map +1 -1
- package/lib/typescript/safe-area/SafeAreaUIProvider.d.ts +9 -7
- package/lib/typescript/safe-area/SafeAreaUIProvider.d.ts.map +1 -1
- package/lib/typescript/safe-area/index.d.ts.map +1 -1
- package/lib/typescript/theme/KeyboardAvoidance.d.ts +62 -0
- package/lib/typescript/theme/KeyboardAvoidance.d.ts.map +1 -0
- package/lib/typescript/theme/NativeOverlay.d.ts +43 -0
- package/lib/typescript/theme/NativeOverlay.d.ts.map +1 -0
- package/lib/typescript/theme/UIProvider.d.ts +38 -20
- package/lib/typescript/theme/UIProvider.d.ts.map +1 -1
- package/lib/typescript/theme/colors.d.ts +13 -13
- package/lib/typescript/theme/colors.d.ts.map +1 -1
- package/lib/typescript/theme/createStyles.d.ts +20 -14
- package/lib/typescript/theme/createStyles.d.ts.map +1 -1
- package/lib/typescript/theme/createTheme.d.ts +5 -5
- package/lib/typescript/theme/index.d.ts +1 -1
- package/lib/typescript/theme/index.d.ts.map +1 -1
- package/lib/typescript/theme/insets.d.ts +1 -1
- package/lib/typescript/theme/insets.d.ts.map +1 -1
- package/lib/typescript/theme/labels.d.ts +57 -2
- package/lib/typescript/theme/labels.d.ts.map +1 -1
- package/lib/typescript/theme/palettes.d.ts +25 -19
- package/lib/typescript/theme/palettes.d.ts.map +1 -1
- package/lib/typescript/theme/tokens.d.ts +15 -8
- package/lib/typescript/theme/tokens.d.ts.map +1 -1
- package/lib/typescript/theme/types.d.ts +7 -7
- package/lib/typescript/theme/types.d.ts.map +1 -1
- package/lib/typescript/theme/useKeyboardField.d.ts +17 -0
- package/lib/typescript/theme/useKeyboardField.d.ts.map +1 -0
- package/lib/typescript/theme/useReduceMotion.d.ts +7 -0
- package/lib/typescript/theme/useReduceMotion.d.ts.map +1 -0
- package/llms.txt +4 -4
- package/package.json +15 -14
- package/registry/alert-provider.json +4 -3
- package/registry/alert.json +3 -2
- package/registry/avatar-header.json +1 -1
- package/registry/avatar.json +3 -2
- package/registry/badge.json +1 -1
- package/registry/bottom-sheet.json +1 -1
- package/registry/button.json +3 -2
- package/registry/card.json +1 -1
- package/registry/chart.json +5 -5
- package/registry/checkbox.json +3 -2
- package/registry/core.json +31 -15
- package/registry/date-field.json +1 -1
- package/registry/date-picker.json +3 -2
- package/registry/divider.json +1 -1
- package/registry/empty-state.json +4 -2
- package/registry/fab.json +3 -2
- package/registry/form-actions.json +3 -2
- package/registry/gradient.json +1 -1
- package/registry/icon-button.json +3 -2
- package/registry/image.json +1 -1
- package/registry/index.json +79 -61
- package/registry/info-row.json +1 -1
- package/registry/input.json +5 -1
- package/registry/key-value-row.json +1 -1
- package/registry/list.json +3 -1
- package/registry/manifest.json +3 -1
- package/registry/modal.json +1 -1
- package/registry/navigation-bar.json +1 -1
- package/registry/option-card.json +3 -2
- package/registry/option-wheel.json +1 -1
- package/registry/pressable-scale.json +3 -2
- package/registry/reorderable-list.json +1 -1
- package/registry/screen.json +1 -1
- package/registry/search-list.json +7 -1
- package/registry/segmented-control.json +1 -1
- package/registry/select.json +1 -1
- package/registry/shiny-text.json +1 -1
- package/registry/skeleton.json +3 -2
- package/registry/slides.json +2 -2
- 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/theme-toggle.json +1 -1
- package/registry/timeline.json +1 -1
- package/registry/title.json +1 -1
- package/registry/toast-provider.json +2 -2
- package/registry/toast.json +1 -1
- package/registry/transition.json +1 -1
- package/registry/wizard.json +1 -1
- package/src/atoms/Avatar.tsx +117 -26
- package/src/atoms/Badge.tsx +20 -9
- package/src/atoms/Button.tsx +102 -30
- package/src/atoms/Card.tsx +23 -3
- package/src/atoms/Checkbox.tsx +49 -12
- package/src/atoms/Divider.tsx +34 -4
- package/src/atoms/EmptyState.tsx +63 -16
- package/src/atoms/Fab.tsx +56 -9
- package/src/atoms/Gradient.tsx +92 -41
- package/src/atoms/IconButton.tsx +27 -4
- package/src/atoms/Image.tsx +74 -28
- package/src/atoms/Input.tsx +129 -19
- package/src/atoms/KeyValueRow.tsx +72 -28
- package/src/atoms/PressableScale.tsx +31 -7
- package/src/atoms/ShinyText.tsx +31 -31
- package/src/atoms/Skeleton.tsx +37 -17
- package/src/atoms/Spinner.tsx +87 -24
- package/src/atoms/Text.tsx +66 -6
- package/src/atoms/Transition.tsx +40 -44
- package/src/atoms/index.ts +3 -3
- package/src/atoms/inputValidation.ts +59 -0
- package/src/icons/Icon.tsx +4 -4
- package/src/icons/defaultIcons.tsx +445 -22
- package/src/icons/index.ts +2 -2
- package/src/icons/types.ts +26 -4
- package/src/molecules/AvatarHeader.tsx +88 -34
- package/src/molecules/FormActions.tsx +36 -6
- package/src/molecules/InfoRow.tsx +39 -12
- package/src/molecules/OptionCard.tsx +55 -20
- package/src/molecules/SegmentedControl.tsx +135 -15
- package/src/molecules/Stepper.tsx +73 -19
- package/src/molecules/Steps.tsx +121 -39
- package/src/molecules/SymmetricGrid.tsx +28 -12
- package/src/organisms/Alert.tsx +97 -26
- package/src/organisms/AlertProvider.tsx +13 -13
- package/src/organisms/BottomSheet.tsx +115 -43
- package/src/organisms/Chart.tsx +193 -164
- package/src/organisms/DateField.tsx +40 -15
- package/src/organisms/DatePicker.tsx +53 -32
- package/src/organisms/List.tsx +64 -15
- package/src/organisms/Modal.tsx +122 -63
- package/src/organisms/NavigationBar.tsx +73 -23
- package/src/organisms/OptionWheel.tsx +70 -66
- package/src/organisms/ReorderableList.tsx +49 -27
- package/src/organisms/Screen.tsx +79 -42
- package/src/organisms/SearchList.tsx +84 -20
- package/src/organisms/Select.tsx +63 -29
- package/src/organisms/Slides.tsx +63 -44
- package/src/organisms/ThemeToggle.tsx +15 -6
- package/src/organisms/Timeline.tsx +79 -62
- package/src/organisms/Toast.tsx +50 -15
- package/src/organisms/ToastProvider.tsx +17 -13
- package/src/organisms/Wizard.tsx +58 -21
- package/src/organisms/chart/frame.tsx +35 -35
- package/src/organisms/chart/marks.tsx +53 -53
- package/src/organisms/chart/math.ts +93 -94
- package/src/organisms/chart/palette.ts +28 -28
- package/src/organisms/imperativeBridge.ts +5 -5
- package/src/organisms/useSlides.ts +66 -31
- package/src/safe-area/SafeAreaUIProvider.tsx +9 -70
- package/src/safe-area/index.ts +1 -0
- package/src/theme/KeyboardAvoidance.tsx +216 -0
- package/src/theme/NativeOverlay.tsx +269 -0
- package/src/theme/UIProvider.tsx +109 -46
- package/src/theme/colors.ts +21 -21
- package/src/theme/createStyles.ts +23 -19
- package/src/theme/createTheme.ts +6 -6
- package/src/theme/index.ts +1 -1
- package/src/theme/insets.ts +1 -1
- package/src/theme/labels.ts +85 -2
- package/src/theme/palettes.ts +33 -25
- package/src/theme/tokens.ts +27 -13
- package/src/theme/types.ts +7 -7
- package/src/theme/useKeyboardField.ts +33 -0
- package/src/theme/useReduceMotion.ts +27 -0
package/registry/core.json
CHANGED
|
@@ -1,63 +1,79 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "core",
|
|
3
3
|
"type": "core",
|
|
4
|
-
"description": "theme (UIProvider, tokens, palettes) + icons —
|
|
4
|
+
"description": "theme (UIProvider, tokens, palettes) + icons — required by every component.",
|
|
5
5
|
"files": [
|
|
6
6
|
{
|
|
7
7
|
"path": "icons/Icon.tsx",
|
|
8
|
-
"content": "import React, { memo } from 'react';\nimport { View, type StyleProp, type ViewStyle } from 'react-native';\nimport { useColors, useIcons } from '../theme/UIProvider';\nimport type { IconName } from './types';\n\nexport interface IconProps {\n name: IconName;\n size?: number;\n color?: string;\n style?: StyleProp<ViewStyle>;\n}\n\n/**\n * Icon\n *
|
|
8
|
+
"content": "import React, { memo } from 'react';\nimport { View, type StyleProp, type ViewStyle } from 'react-native';\nimport { useColors, useIcons } from '../theme/UIProvider';\nimport type { IconName } from './types';\n\nexport interface IconProps {\n name: IconName;\n size?: number;\n color?: string;\n style?: StyleProp<ViewStyle>;\n}\n\n/**\n * Icon\n * Resolves the glyph through the active renderer (UIProvider.icons, the\n * zero-dep default if none was configured). Internal use by the library —\n * public components expose `icon?: ReactNode` in addition to this, so the\n * consumer can inject any icon without going through the name system.\n */\nexport const Icon = memo(({ name, size = 20, color, style }: IconProps) => {\n const colors = useColors();\n const render = useIcons();\n const glyph = render(name, { size, color: color ?? colors.text });\n if (!style) return <>{glyph}</>;\n return <View style={style}>{glyph}</View>;\n});\nIcon.displayName = 'Icon';\n"
|
|
9
9
|
},
|
|
10
10
|
{
|
|
11
11
|
"path": "icons/defaultIcons.tsx",
|
|
12
|
-
"content": "import React, { memo } from 'react';\nimport { Text, View } from 'react-native';\nimport type { IconName, IconRenderProps } from './types';\n\n/**\n * Glifos default dibujados con primitivas de RN (View + border + transform).\n * Cero dependencias, cero fuentes, cero carga asíncrona. Cubren únicamente\n * lo que la librería necesita internamente (checkbox, chevrons de Select,\n * toggle de password en Input, etc). Para íconos de producto, el consumidor\n * inyecta su propio set vía <UIProvider icons={...}> o la prop `icon`.\n */\n\nconst Chevron = memo(({ size, color, direction }: IconRenderProps & { direction: 'up' | 'down' | 'left' | 'right' }) => {\n const strokeWidth = Math.max(1.5, size / 12);\n const arm = size * 0.32;\n const rotation: Record<typeof direction, number> = {\n down: 45,\n up: 225,\n left: 135,\n right: -45,\n };\n return (\n <View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>\n <View\n style={{\n width: arm,\n height: arm,\n borderRightWidth: strokeWidth,\n borderBottomWidth: strokeWidth,\n borderColor: color,\n transform: [{ rotate: `${rotation[direction]}deg` }, { translateY: -arm * 0.15 }],\n }}\n />\n </View>\n );\n});\nChevron.displayName = 'Chevron';\n\nconst Check = memo(({ size, color }: IconRenderProps) => {\n const strokeWidth = Math.max(1.5, size / 10);\n return (\n <View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>\n <View\n style={{\n width: size * 0.5,\n height: size * 0.28,\n borderLeftWidth: strokeWidth,\n borderBottomWidth: strokeWidth,\n borderColor: color,\n transform: [{ rotate: '-45deg' }, { translateY: -size * 0.06 }],\n }}\n />\n </View>\n );\n});\nCheck.displayName = 'Check';\n\nconst Close = memo(({ size, color }: IconRenderProps) => {\n const strokeWidth = Math.max(1.5, size / 10);\n const line = size * 0.62;\n return (\n <View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>\n <View\n style={{\n position: 'absolute',\n width: line,\n height: strokeWidth,\n backgroundColor: color,\n borderRadius: strokeWidth / 2,\n transform: [{ rotate: '45deg' }],\n }}\n />\n <View\n style={{\n position: 'absolute',\n width: line,\n height: strokeWidth,\n backgroundColor: color,\n borderRadius: strokeWidth / 2,\n transform: [{ rotate: '-45deg' }],\n }}\n />\n </View>\n );\n});\nClose.displayName = 'Close';\n\nconst PlusMinus = memo(({ size, color, minusOnly }: IconRenderProps & { minusOnly?: boolean }) => {\n const strokeWidth = Math.max(1.5, size / 10);\n const line = size * 0.56;\n return (\n <View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>\n <View\n style={{\n position: 'absolute',\n width: line,\n height: strokeWidth,\n backgroundColor: color,\n borderRadius: strokeWidth / 2,\n }}\n />\n {!minusOnly && (\n <View\n style={{\n position: 'absolute',\n width: strokeWidth,\n height: line,\n backgroundColor: color,\n borderRadius: strokeWidth / 2,\n }}\n />\n )}\n </View>\n );\n});\nPlusMinus.displayName = 'PlusMinus';\n\n/**\n * Círculo con un glifo tipográfico adentro (!, i, ?).\n *\n * Usa <Text> con la fuente del sistema en vez de componer el glifo con\n * Views: sigue siendo cero-dependencias (no carga ninguna fuente), y \"i\" vs\n * \"!\" vs \"?\" quedan inequívocos. Dibujarlos con barras y puntos daba un\n * \"info\" idéntico a un \"!\" invertido, que es exactamente lo que un ícono de\n * información no debe comunicar.\n */\nconst Circle = memo(\n ({ size, color, filled, label }: IconRenderProps & { filled?: boolean; label?: string }) => {\n const strokeWidth = Math.max(1.5, size / 12);\n return (\n <View\n style={{\n width: size,\n height: size,\n borderRadius: size / 2,\n borderWidth: filled ? 0 : strokeWidth,\n borderColor: color,\n backgroundColor: filled ? color : 'transparent',\n alignItems: 'center',\n justifyContent: 'center',\n }}\n >\n {!!label && (\n <Text\n allowFontScaling={false}\n style={{\n color: filled ? '#fff' : color,\n fontSize: size * 0.62,\n lineHeight: size * 0.74,\n fontWeight: '700',\n textAlign: 'center',\n includeFontPadding: false,\n }}\n >\n {label}\n </Text>\n )}\n </View>\n );\n }\n);\nCircle.displayName = 'Circle';\n\nconst Eye = memo(({ size, color, off }: IconRenderProps & { off?: boolean }) => {\n const strokeWidth = Math.max(1.5, size / 12);\n return (\n <View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>\n <View\n style={{\n width: size * 0.8,\n height: size * 0.8,\n borderRadius: size * 0.4,\n borderWidth: strokeWidth,\n borderColor: color,\n overflow: 'hidden',\n alignItems: 'center',\n justifyContent: 'center',\n transform: [{ scaleY: 0.55 }],\n }}\n >\n <View\n style={{\n width: size * 0.26,\n height: size * 0.26,\n borderRadius: size * 0.13,\n backgroundColor: color,\n transform: [{ scaleY: 1 / 0.55 }],\n }}\n />\n </View>\n {off && (\n <View\n style={{\n position: 'absolute',\n width: size * 1.05,\n height: strokeWidth,\n backgroundColor: color,\n borderRadius: strokeWidth / 2,\n transform: [{ rotate: '-45deg' }],\n }}\n />\n )}\n </View>\n );\n});\nEye.displayName = 'Eye';\n\nconst Search = memo(({ size, color }: IconRenderProps) => {\n const strokeWidth = Math.max(1.5, size / 9);\n // Lente grande (72% del alto) arriba-izquierda y mango corto saliendo de su\n // borde inferior-derecho sobre la diagonal de 45°. Las proporciones importan\n // a tamaños chicos (16-20px): con un lente chico y mango largo el glifo se\n // lee como una coma, no como una lupa.\n const lensD = size * 0.72;\n const lensR = lensD / 2;\n const handleLen = size * 0.34;\n const diag = Math.SQRT1_2; // cos(45°) === sin(45°)\n // El mango se centra sobre la diagonal, arrancando justo en el borde del\n // lente: distancia = radio + mitad del mango, menos un solapamiento de\n // medio trazo para que no quede un hueco entre borde y mango.\n const dist = lensR + handleLen / 2 - strokeWidth / 2;\n const cx = lensR + dist * diag;\n const cy = lensR + dist * diag;\n\n return (\n <View style={{ width: size, height: size }}>\n <View\n style={{\n position: 'absolute',\n top: 0,\n left: 0,\n width: lensD,\n height: lensD,\n borderRadius: lensR,\n borderWidth: strokeWidth,\n borderColor: color,\n }}\n />\n <View\n style={{\n position: 'absolute',\n left: cx - strokeWidth / 2,\n top: cy - handleLen / 2,\n width: strokeWidth,\n height: handleLen,\n backgroundColor: color,\n borderRadius: strokeWidth / 2,\n transform: [{ rotate: '-45deg' }],\n }}\n />\n </View>\n );\n});\nSearch.displayName = 'Search';\n\n/** Renderer default. Se usa cuando <UIProvider icons={...}> no pasa uno propio. */\nexport function renderDefaultIcon(name: IconName, props: IconRenderProps): React.ReactNode {\n switch (name) {\n case 'check':\n return <Check {...props} />;\n case 'close':\n return <Close {...props} />;\n case 'chevron-down':\n return <Chevron {...props} direction=\"down\" />;\n case 'chevron-up':\n return <Chevron {...props} direction=\"up\" />;\n case 'chevron-left':\n return <Chevron {...props} direction=\"left\" />;\n case 'chevron-right':\n return <Chevron {...props} direction=\"right\" />;\n case 'eye':\n return <Eye {...props} />;\n case 'eye-off':\n return <Eye {...props} off />;\n case 'search':\n return <Search {...props} />;\n case 'plus':\n return <PlusMinus {...props} />;\n case 'minus':\n return <PlusMinus {...props} minusOnly />;\n case 'alert':\n return <Circle {...props} filled label=\"!\" />;\n case 'info':\n return <Circle {...props} label=\"i\" />;\n case 'warning':\n return <Circle {...props} filled label=\"!\" />;\n case 'question':\n return <Circle {...props} label=\"?\" />;\n default:\n return null;\n }\n}\n"
|
|
12
|
+
"content": "import React, { memo } from 'react';\nimport { Text, View } from 'react-native';\nimport type { BuiltInIconName, IconName, IconRenderProps } from './types';\n\n/**\n * Default glyphs drawn with RN primitives (View + border + transform).\n * Zero dependencies, zero fonts, zero async loading. They cover what the\n * library needs internally (checkbox, Select chevrons, password toggle in\n * Input, etc) plus a basic set for apps (menu, home, user…).\n * For a complete set, the consumer injects their own via\n * <UIProvider icons={...}> or the `icon` prop.\n */\n\n/**\n * Color of the inner glyph of a filled icon (alert, warning): black or\n * white depending on the fill's luminance. A fixed white disappeared over\n * dark mode's light fills (figure and ground). Non-hex colors (rgb(),\n * names) fall back to white, the common case over saturated accents.\n */\nexport function onColor(fill: string): string {\n const hex = fill.trim().replace('#', '');\n const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex.slice(0, 6);\n if (!/^[0-9a-fA-F]{6}$/.test(full)) return '#fff';\n const channel = (i: number) => {\n const c = parseInt(full.slice(i, i + 2), 16) / 255;\n return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;\n };\n const r = channel(0);\n const g = channel(2);\n const b = channel(4);\n const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;\n // Threshold where contrast with black exceeds contrast with white.\n return luminance > 0.179 ? '#000' : '#fff';\n}\n\nconst Chevron = memo(({ size, color, direction }: IconRenderProps & { direction: 'up' | 'down' | 'left' | 'right' }) => {\n const strokeWidth = Math.max(1.5, size / 12);\n const arm = size * 0.32;\n const rotation: Record<typeof direction, number> = {\n down: 45,\n up: 225,\n left: 135,\n right: -45,\n };\n return (\n <View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>\n <View\n style={{\n width: arm,\n height: arm,\n borderRightWidth: strokeWidth,\n borderBottomWidth: strokeWidth,\n borderColor: color,\n transform: [{ rotate: `${rotation[direction]}deg` }, { translateY: -arm * 0.15 }],\n }}\n />\n </View>\n );\n});\nChevron.displayName = 'Chevron';\n\nconst Check = memo(({ size, color }: IconRenderProps) => {\n const strokeWidth = Math.max(1.5, size / 10);\n return (\n <View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>\n <View\n style={{\n width: size * 0.5,\n height: size * 0.28,\n borderLeftWidth: strokeWidth,\n borderBottomWidth: strokeWidth,\n borderColor: color,\n transform: [{ rotate: '-45deg' }, { translateY: -size * 0.06 }],\n }}\n />\n </View>\n );\n});\nCheck.displayName = 'Check';\n\nconst Close = memo(({ size, color }: IconRenderProps) => {\n const strokeWidth = Math.max(1.5, size / 10);\n const line = size * 0.62;\n return (\n <View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>\n <View\n style={{\n position: 'absolute',\n width: line,\n height: strokeWidth,\n backgroundColor: color,\n borderRadius: strokeWidth / 2,\n transform: [{ rotate: '45deg' }],\n }}\n />\n <View\n style={{\n position: 'absolute',\n width: line,\n height: strokeWidth,\n backgroundColor: color,\n borderRadius: strokeWidth / 2,\n transform: [{ rotate: '-45deg' }],\n }}\n />\n </View>\n );\n});\nClose.displayName = 'Close';\n\nconst PlusMinus = memo(({ size, color, minusOnly }: IconRenderProps & { minusOnly?: boolean }) => {\n const strokeWidth = Math.max(1.5, size / 10);\n const line = size * 0.56;\n return (\n <View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>\n <View\n style={{\n position: 'absolute',\n width: line,\n height: strokeWidth,\n backgroundColor: color,\n borderRadius: strokeWidth / 2,\n }}\n />\n {!minusOnly && (\n <View\n style={{\n position: 'absolute',\n width: strokeWidth,\n height: line,\n backgroundColor: color,\n borderRadius: strokeWidth / 2,\n }}\n />\n )}\n </View>\n );\n});\nPlusMinus.displayName = 'PlusMinus';\n\n/**\n * Circle with a typographic glyph inside (!, i, ?).\n *\n * Uses <Text> with the system font instead of composing the glyph from\n * Views: it is still zero-dependency (it loads no font), and \"i\" vs \"!\" vs\n * \"?\" stay unambiguous. Drawing them with bars and dots gave an \"info\"\n * identical to an inverted \"!\", which is exactly what an information icon\n * must not convey.\n */\nconst Circle = memo(\n ({ size, color, filled, label }: IconRenderProps & { filled?: boolean; label?: string }) => {\n const strokeWidth = Math.max(1.5, size / 12);\n return (\n <View\n style={{\n width: size,\n height: size,\n borderRadius: size / 2,\n borderWidth: filled ? 0 : strokeWidth,\n borderColor: color,\n backgroundColor: filled ? color : 'transparent',\n alignItems: 'center',\n justifyContent: 'center',\n }}\n >\n {!!label && (\n <Text\n allowFontScaling={false}\n style={{\n color: filled ? onColor(color) : color,\n fontSize: size * 0.62,\n lineHeight: size * 0.74,\n fontWeight: '700',\n textAlign: 'center',\n includeFontPadding: false,\n }}\n >\n {label}\n </Text>\n )}\n </View>\n );\n }\n);\nCircle.displayName = 'Circle';\n\nconst Eye = memo(({ size, color, off }: IconRenderProps & { off?: boolean }) => {\n const strokeWidth = Math.max(1.5, size / 12);\n return (\n <View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>\n <View\n style={{\n width: size * 0.8,\n height: size * 0.8,\n borderRadius: size * 0.4,\n borderWidth: strokeWidth,\n borderColor: color,\n overflow: 'hidden',\n alignItems: 'center',\n justifyContent: 'center',\n transform: [{ scaleY: 0.55 }],\n }}\n >\n <View\n style={{\n width: size * 0.26,\n height: size * 0.26,\n borderRadius: size * 0.13,\n backgroundColor: color,\n transform: [{ scaleY: 1 / 0.55 }],\n }}\n />\n </View>\n {off && (\n <View\n style={{\n position: 'absolute',\n width: size * 1.05,\n height: strokeWidth,\n backgroundColor: color,\n borderRadius: strokeWidth / 2,\n transform: [{ rotate: '-45deg' }],\n }}\n />\n )}\n </View>\n );\n});\nEye.displayName = 'Eye';\n\nconst Search = memo(({ size, color }: IconRenderProps) => {\n const strokeWidth = Math.max(1.5, size / 9);\n // Large lens (72% of the height) at the top-left and a short handle coming\n // out of its bottom-right edge along the 45° diagonal. The proportions matter\n // at small sizes (16-20px): with a small lens and a long handle the glyph\n // reads as a comma, not a magnifying glass.\n const lensD = size * 0.72;\n const lensR = lensD / 2;\n const handleLen = size * 0.34;\n const diag = Math.SQRT1_2; // cos(45°) === sin(45°)\n // The handle is centered on the diagonal, starting right at the lens's\n // edge: distance = radius + half the handle, minus a half-stroke overlap\n // so no gap is left between edge and handle.\n const dist = lensR + handleLen / 2 - strokeWidth / 2;\n const cx = lensR + dist * diag;\n const cy = lensR + dist * diag;\n\n return (\n <View style={{ width: size, height: size }}>\n <View\n style={{\n position: 'absolute',\n top: 0,\n left: 0,\n width: lensD,\n height: lensD,\n borderRadius: lensR,\n borderWidth: strokeWidth,\n borderColor: color,\n }}\n />\n <View\n style={{\n position: 'absolute',\n left: cx - strokeWidth / 2,\n top: cy - handleLen / 2,\n width: strokeWidth,\n height: handleLen,\n backgroundColor: color,\n borderRadius: strokeWidth / 2,\n transform: [{ rotate: '-45deg' }],\n }}\n />\n </View>\n );\n});\nSearch.displayName = 'Search';\n\n// ── Basic set for apps ─────────────────────────────────────────────────────\n// Same rules as the rest: only View/Text, stroke proportional to the size\n// (`size / 10`, minimum 1.5) and a square `size` box, so they all line up\n// the same way in a row (Gestalt, similarity).\n\nconst stroke = (size: number) => Math.max(1.5, size / 10);\nconst box = (size: number) => ({ width: size, height: size, alignItems: 'center' as const, justifyContent: 'center' as const });\n\nconst Menu = memo(({ size, color }: IconRenderProps) => {\n const sw = stroke(size);\n const bar = { width: size * 0.7, height: sw, borderRadius: sw / 2, backgroundColor: color };\n return (\n <View style={[box(size), { gap: size * 0.16 }]}>\n <View style={bar} />\n <View style={bar} />\n <View style={bar} />\n </View>\n );\n});\nMenu.displayName = 'Menu';\n\nconst Scan = memo(({ size, color }: IconRenderProps) => {\n const sw = stroke(size);\n const arm = size * 0.26;\n const inset = size * 0.1;\n const corner = (v: 'top' | 'bottom', h: 'left' | 'right') => ({\n position: 'absolute' as const,\n [v]: inset,\n [h]: inset,\n width: arm,\n height: arm,\n borderColor: color,\n [v === 'top' ? 'borderTopWidth' : 'borderBottomWidth']: sw,\n [h === 'left' ? 'borderLeftWidth' : 'borderRightWidth']: sw,\n });\n return (\n <View style={box(size)}>\n {/* Viewfinder: four corner brackets and the scan line across the middle. */}\n <View style={corner('top', 'left')} />\n <View style={corner('top', 'right')} />\n <View style={corner('bottom', 'left')} />\n <View style={corner('bottom', 'right')} />\n <View style={{ width: size * 0.6, height: sw, borderRadius: sw / 2, backgroundColor: color }} />\n </View>\n );\n});\nScan.displayName = 'Scan';\n\nconst More = memo(({ size, color }: IconRenderProps) => {\n const d = Math.max(3, size * 0.16);\n const dot = { width: d, height: d, borderRadius: d / 2, backgroundColor: color };\n return (\n <View style={[box(size), { flexDirection: 'row', gap: size * 0.12 }]}>\n <View style={dot} />\n <View style={dot} />\n <View style={dot} />\n </View>\n );\n});\nMore.displayName = 'More';\n\nconst Home = memo(({ size, color }: IconRenderProps) => {\n const sw = stroke(size);\n const roof = size * 0.5;\n return (\n <View style={box(size)}>\n {/* Roof: a square with two borders, rotated 45°. */}\n <View\n style={{\n position: 'absolute',\n top: size * 0.14,\n width: roof,\n height: roof,\n borderTopWidth: sw,\n borderLeftWidth: sw,\n borderColor: color,\n transform: [{ rotate: '45deg' }],\n }}\n />\n {/* Body: walls and floor. */}\n <View\n style={{\n position: 'absolute',\n bottom: size * 0.12,\n width: size * 0.56,\n height: size * 0.4,\n borderLeftWidth: sw,\n borderRightWidth: sw,\n borderBottomWidth: sw,\n borderColor: color,\n }}\n />\n </View>\n );\n});\nHome.displayName = 'Home';\n\nconst Settings = memo(({ size, color }: IconRenderProps) => {\n const sw = stroke(size);\n const ring = size * 0.5;\n const tooth = { width: size * 0.16, height: size * 0.2, backgroundColor: color, borderRadius: sw / 2 };\n return (\n <View style={box(size)}>\n {[0, 45, 90, 135, 180, 225, 270, 315].map((deg) => (\n <View key={deg} style={[box(size), { position: 'absolute', transform: [{ rotate: `${deg}deg` }] }]}>\n <View style={[tooth, { position: 'absolute', top: size * 0.08 }]} />\n </View>\n ))}\n <View style={{ width: ring, height: ring, borderRadius: ring / 2, borderWidth: sw * 1.6, borderColor: color }} />\n </View>\n );\n});\nSettings.displayName = 'Settings';\n\nconst User = memo(({ size, color }: IconRenderProps) => {\n const sw = stroke(size);\n const head = size * 0.38;\n return (\n <View style={box(size)}>\n <View\n style={{\n position: 'absolute',\n top: size * 0.08,\n width: head,\n height: head,\n borderRadius: head / 2,\n borderWidth: sw,\n borderColor: color,\n }}\n />\n {/* Shoulders: half an ellipse open at the bottom. */}\n <View\n style={{\n position: 'absolute',\n bottom: size * 0.08,\n width: size * 0.74,\n height: size * 0.34,\n borderTopLeftRadius: size * 0.37,\n borderTopRightRadius: size * 0.37,\n borderTopWidth: sw,\n borderLeftWidth: sw,\n borderRightWidth: sw,\n borderColor: color,\n }}\n />\n </View>\n );\n});\nUser.displayName = 'User';\n\nconst Trash = memo(({ size, color }: IconRenderProps) => {\n const sw = stroke(size);\n const bodyW = size * 0.52;\n const bodyH = size * 0.56;\n return (\n <View style={box(size)}>\n {/* Handle and lid. */}\n <View\n style={{\n position: 'absolute',\n top: size * 0.08,\n width: size * 0.26,\n height: size * 0.12,\n borderTopWidth: sw,\n borderLeftWidth: sw,\n borderRightWidth: sw,\n borderColor: color,\n }}\n />\n <View\n style={{\n position: 'absolute',\n top: size * 0.19,\n width: size * 0.72,\n height: sw,\n borderRadius: sw / 2,\n backgroundColor: color,\n }}\n />\n {/* Body with two slots. */}\n <View\n style={{\n position: 'absolute',\n bottom: size * 0.08,\n width: bodyW,\n height: bodyH,\n borderLeftWidth: sw,\n borderRightWidth: sw,\n borderBottomWidth: sw,\n borderBottomLeftRadius: sw * 1.5,\n borderBottomRightRadius: sw * 1.5,\n borderColor: color,\n flexDirection: 'row',\n justifyContent: 'center',\n alignItems: 'center',\n gap: bodyW * 0.22,\n }}\n >\n <View style={{ width: sw * 0.8, height: bodyH * 0.55, backgroundColor: color, borderRadius: sw / 2 }} />\n <View style={{ width: sw * 0.8, height: bodyH * 0.55, backgroundColor: color, borderRadius: sw / 2 }} />\n </View>\n </View>\n );\n});\nTrash.displayName = 'Trash';\n\nconst Edit = memo(({ size, color }: IconRenderProps) => {\n const sw = stroke(size);\n return (\n <View style={box(size)}>\n {/* Pencil: outlined body, rotated 45°, with the tip pointing down-left. */}\n <View style={{ alignItems: 'center', transform: [{ rotate: '45deg' }, { translateY: -size * 0.02 }] }}>\n <View\n style={{\n width: size * 0.3,\n height: size * 0.66,\n borderWidth: sw,\n borderColor: color,\n borderTopLeftRadius: sw,\n borderTopRightRadius: sw,\n }}\n />\n <View\n style={{\n width: 0,\n height: 0,\n borderLeftWidth: size * 0.15,\n borderRightWidth: size * 0.15,\n borderTopWidth: size * 0.22,\n borderLeftColor: 'transparent',\n borderRightColor: 'transparent',\n borderTopColor: color,\n }}\n />\n </View>\n </View>\n );\n});\nEdit.displayName = 'Edit';\n\nconst Share = memo(({ size, color }: IconRenderProps) => {\n const sw = stroke(size);\n const arm = size * 0.26;\n return (\n <View style={box(size)}>\n {/* Box open at the top. */}\n <View\n style={{\n position: 'absolute',\n bottom: size * 0.08,\n width: size * 0.64,\n height: size * 0.42,\n borderLeftWidth: sw,\n borderRightWidth: sw,\n borderBottomWidth: sw,\n borderBottomLeftRadius: sw * 1.5,\n borderBottomRightRadius: sw * 1.5,\n borderColor: color,\n }}\n />\n {/* Arrow pointing up, coming out of the box. */}\n <View\n style={{\n position: 'absolute',\n top: size * 0.1,\n width: sw,\n height: size * 0.56,\n backgroundColor: color,\n borderRadius: sw / 2,\n }}\n />\n <View\n style={{\n position: 'absolute',\n top: size * 0.12,\n width: arm,\n height: arm,\n borderTopWidth: sw,\n borderLeftWidth: sw,\n borderColor: color,\n transform: [{ rotate: '45deg' }],\n }}\n />\n </View>\n );\n});\nShare.displayName = 'Share';\n\nconst Heart = memo(({ size, color }: IconRenderProps) => {\n // Two circles over a square rotated 45°: the classic shape, filled.\n const s = size * 0.46;\n const lobe = { position: 'absolute' as const, width: s, height: s, borderRadius: s / 2, backgroundColor: color };\n return (\n <View style={box(size)}>\n <View style={{ width: s, height: s, transform: [{ translateY: size * 0.05 }, { rotate: '45deg' }] }}>\n <View style={{ position: 'absolute', width: s, height: s, backgroundColor: color }} />\n <View style={[lobe, { left: -s / 2 }]} />\n <View style={[lobe, { top: -s / 2 }]} />\n </View>\n </View>\n );\n});\nHeart.displayName = 'Heart';\n\n/**\n * Star: typographic glyph (★, U+2605) like Circle's. A five-pointed star\n * can't be composed well from rectangles; the variation selector U+FE0E\n * forces the text version (monochrome, in the requested color) instead of\n * the emoji.\n */\nconst Star = memo(({ size, color }: IconRenderProps) => (\n <View style={box(size)}>\n <Text\n allowFontScaling={false}\n style={{ color, fontSize: size * 0.95, lineHeight: size, textAlign: 'center', includeFontPadding: false }}\n >\n {'\\u2605\\uFE0E'}\n </Text>\n </View>\n));\nStar.displayName = 'Star';\n\n/**\n * Warning: filled triangle with a \"!\" (not a circle like `alert`), so that\n * warning and error aren't told apart by color alone.\n */\nconst WarningTriangle = memo(({ size, color }: IconRenderProps) => {\n const half = size * 0.5;\n const height = size * 0.88;\n return (\n <View style={box(size)}>\n <View\n style={{\n width: 0,\n height: 0,\n borderLeftWidth: half,\n borderRightWidth: half,\n borderBottomWidth: height,\n borderLeftColor: 'transparent',\n borderRightColor: 'transparent',\n borderBottomColor: color,\n }}\n />\n <Text\n allowFontScaling={false}\n style={{\n position: 'absolute',\n bottom: size * 0.06,\n color: onColor(color),\n fontSize: size * 0.58,\n lineHeight: size * 0.66,\n fontWeight: '700',\n textAlign: 'center',\n includeFontPadding: false,\n }}\n >\n !\n </Text>\n </View>\n );\n});\nWarningTriangle.displayName = 'WarningTriangle';\n\nconst warned = new Set<string>();\n\n/** All included glyphs, in catalog order. */\nexport const BUILT_IN_ICON_NAMES: readonly BuiltInIconName[] = [\n 'check', 'close', 'plus', 'minus', 'search', 'scan', 'menu', 'more', 'home', 'settings', 'user',\n 'trash', 'edit', 'share', 'heart', 'star', 'eye', 'eye-off',\n 'chevron-left', 'chevron-right', 'chevron-up', 'chevron-down', 'alert', 'warning', 'info', 'question',\n];\n\n/** Default renderer. Used when <UIProvider icons={...}> doesn't pass its own. */\nexport function renderDefaultIcon(name: IconName, props: IconRenderProps): React.ReactNode {\n switch (name) {\n case 'check':\n return <Check {...props} />;\n case 'close':\n return <Close {...props} />;\n case 'chevron-down':\n return <Chevron {...props} direction=\"down\" />;\n case 'chevron-up':\n return <Chevron {...props} direction=\"up\" />;\n case 'chevron-left':\n return <Chevron {...props} direction=\"left\" />;\n case 'chevron-right':\n return <Chevron {...props} direction=\"right\" />;\n case 'eye':\n return <Eye {...props} />;\n case 'eye-off':\n return <Eye {...props} off />;\n case 'search':\n return <Search {...props} />;\n case 'scan':\n return <Scan {...props} />;\n case 'plus':\n return <PlusMinus {...props} />;\n case 'minus':\n return <PlusMinus {...props} minusOnly />;\n case 'alert':\n return <Circle {...props} filled label=\"!\" />;\n case 'info':\n return <Circle {...props} label=\"i\" />;\n case 'warning':\n return <WarningTriangle {...props} />;\n case 'question':\n return <Circle {...props} label=\"?\" />;\n case 'menu':\n return <Menu {...props} />;\n case 'more':\n return <More {...props} />;\n case 'home':\n return <Home {...props} />;\n case 'settings':\n return <Settings {...props} />;\n case 'user':\n return <User {...props} />;\n case 'trash':\n return <Trash {...props} />;\n case 'edit':\n return <Edit {...props} />;\n case 'share':\n return <Share {...props} />;\n case 'heart':\n return <Heart {...props} />;\n case 'star':\n return <Star {...props} />;\n default:\n // A name outside the included set only makes sense with a custom\n // renderer (<UIProvider icons>). Without one there is no glyph: warn once.\n if (typeof __DEV__ !== 'undefined' && __DEV__ && !warned.has(name)) {\n warned.add(name);\n console.warn(`orn-ui: no built-in icon named \"${name}\". Pass <UIProvider icons={...}> to render custom names.`);\n }\n return null;\n }\n}\n"
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
"path": "icons/index.ts",
|
|
16
|
-
"content": "export { Icon } from './Icon';\nexport type { IconProps } from './Icon';\nexport { renderDefaultIcon } from './defaultIcons';\nexport type { IconName, IconRenderer, IconRenderProps } from './types';\n"
|
|
16
|
+
"content": "export { Icon } from './Icon';\nexport type { IconProps } from './Icon';\nexport { renderDefaultIcon, BUILT_IN_ICON_NAMES } from './defaultIcons';\nexport type { BuiltInIconName, IconName, IconRenderer, IconRenderProps } from './types';\n"
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
"path": "icons/types.ts",
|
|
20
|
-
"content": "import type { ReactNode } from 'react';\n\n
|
|
20
|
+
"content": "import type { ReactNode } from 'react';\n\n/**\n * Included glyphs (drawn with RN primitives, zero dependencies): the ones\n * the library uses internally plus a basic set for apps.\n */\nexport type BuiltInIconName =\n | 'check'\n | 'close'\n | 'chevron-down'\n | 'chevron-up'\n | 'chevron-left'\n | 'chevron-right'\n | 'eye'\n | 'eye-off'\n | 'search'\n | 'scan'\n | 'plus'\n | 'minus'\n | 'alert'\n | 'info'\n | 'warning'\n | 'question'\n | 'menu'\n | 'more'\n | 'home'\n | 'settings'\n | 'user'\n | 'trash'\n | 'edit'\n | 'share'\n | 'heart'\n | 'star';\n\n/**\n * Icon name accepted by `iconName`, `leftIconName`, etc. Autocompletes the\n * included ones and also accepts any string, for those who plug in their own\n * set with `<UIProvider icons={...}>` (e.g. `iconName=\"calendar\"`). A name\n * the active renderer doesn't know draws nothing (and warns in development).\n */\nexport type IconName = BuiltInIconName | (string & {});\n\nexport interface IconRenderProps {\n size: number;\n color: string;\n}\n\n/** Injectable renderer: replaces the default glyphs with those of any icon set. */\nexport type IconRenderer = (name: IconName, props: IconRenderProps) => ReactNode;\n"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"path": "theme/KeyboardAvoidance.tsx",
|
|
24
|
+
"content": "import React, { createContext, useContext, useEffect, useMemo, useRef, type ComponentRef } from 'react';\nimport {\n Animated,\n Easing,\n Keyboard,\n Platform,\n StyleSheet,\n View,\n type KeyboardEvent,\n} from 'react-native';\nimport { useReduceMotion } from './useReduceMotion';\n\n/** A focused field the host has to keep in view. */\nexport interface KeyboardField {\n /** The field's box in window coordinates (label, field and error included). */\n measure: () => Promise<{ y: number; height: number } | null>;\n}\n\nexport interface KeyboardHost {\n focus: (field: KeyboardField) => void;\n blur: (field: KeyboardField) => void;\n}\n\nconst KeyboardHostContext = createContext<KeyboardHost | null>(null);\n\n/** The active host, or null if a container already handles the keyboard (or it is off). */\nexport function useKeyboardHost(): KeyboardHost | null {\n return useContext(KeyboardHostContext);\n}\n\n/**\n * For containers that already keep the focused field visible on their own:\n * a Modal or BottomSheet (its own native window, which the root's shift\n * doesn't move) or a ScrollView with `automaticallyAdjustKeyboardInsets`.\n * Inside, Inputs ask nothing of the host and the keyboard isn't compensated\n * twice.\n */\nexport function KeyboardHandled({ children }: { children: React.ReactNode }) {\n return <KeyboardHostContext.Provider value={null}>{children}</KeyboardHostContext.Provider>;\n}\n\n/** Duration when the keyboard event doesn't carry one (Android). */\nconst FALLBACK_DURATION = 250;\n\n/**\n * Vertical shift (≤ 0) so the whole field sits above the keyboard, with\n * `margin` of room, without its top edge going past `topLimit`.\n * `fieldY` is the position measured with the `current` shift applied.\n * If the field is already visible, it moves back toward 0: it never rises more than needed.\n */\nexport function computeKeyboardOffset({\n fieldY,\n fieldHeight,\n keyboardTop,\n current,\n margin,\n topLimit,\n}: {\n fieldY: number;\n fieldHeight: number;\n keyboardTop: number;\n current: number;\n margin: number;\n topLimit: number;\n}): number {\n const base = fieldY - current;\n let target = Math.min(0, keyboardTop - margin - (base + fieldHeight));\n // A field taller than the available gap: seeing where you type (its top\n // part) takes priority over its error.\n target = Math.max(target, topLimit - base);\n return Math.min(0, target);\n}\n\n/**\n * An area that shifts so the focused Input inside it sits above the\n * keyboard (transform on the native thread; it returns to 0 on close). It is\n * Android's `adjustPan`, the same on both platforms.\n *\n * - The root UIProvider mounts one around the whole app: the fallback, so\n * an Input is visible even when it isn't inside a Screen.\n * - Screen mounts another around its content, with `clip`: only the content\n * moves and the header (`header` prop) and the tab bar stay still, as in a\n * native app. The Input registers with the nearest area, so inside a\n * Screen the root doesn't move.\n *\n * With `clip`, whatever rises is clipped at the area's top edge and the field\n * never goes above that edge (it stays under the header).\n */\nexport function KeyboardPanArea({\n margin,\n topLimit,\n clip = false,\n children,\n}: {\n margin: number;\n /** Top limit in window coordinates. With `clip`, the area's edge is used if it is lower. */\n topLimit: number;\n clip?: boolean;\n children: React.ReactNode;\n}) {\n const areaRef = useRef<ComponentRef<typeof View>>(null);\n const pan = useRef(new Animated.Value(0)).current;\n const offset = useRef(0);\n const field = useRef<KeyboardField | null>(null);\n const keyboardTop = useRef<number | null>(null);\n const reduceMotion = useReduceMotion();\n const config = useRef({ margin, topLimit, reduceMotion, clip });\n config.current = { margin, topLimit, reduceMotion, clip };\n\n const host = useMemo<KeyboardHost>(() => {\n const animateTo = (to: number, duration: number) => {\n if (to === offset.current) return;\n offset.current = to;\n Animated.timing(pan, {\n toValue: to,\n duration: config.current.reduceMotion ? 0 : duration,\n easing: Easing.out(Easing.cubic),\n useNativeDriver: true,\n }).start();\n };\n\n const adjust = async (duration: number) => {\n const current = field.current;\n const top = keyboardTop.current;\n if (!current || top === null) return;\n const box = await current.measure();\n // While measuring, focus may have moved to another field or the keyboard closed.\n if (!box || field.current !== current || keyboardTop.current === null) return;\n let limit = config.current.topLimit;\n if (config.current.clip) {\n const areaTop = await measureTop(areaRef.current);\n if (areaTop !== null) limit = Math.max(limit, areaTop);\n }\n animateTo(\n computeKeyboardOffset({\n fieldY: box.y,\n fieldHeight: box.height,\n keyboardTop: keyboardTop.current,\n current: offset.current,\n margin: config.current.margin,\n topLimit: limit,\n }),\n duration\n );\n };\n\n return {\n focus(next) {\n field.current = next;\n // With the keyboard already open (moving from one field to another) it readjusts right away.\n if (keyboardTop.current !== null) void adjust(FALLBACK_DURATION);\n },\n blur(prev) {\n if (field.current === prev) field.current = null;\n },\n // Internal use by the listeners below.\n _show(e: KeyboardEvent | undefined) {\n if (!e?.endCoordinates) return;\n keyboardTop.current = e.endCoordinates.screenY;\n const duration = e.duration || FALLBACK_DURATION;\n // Android emits didShow after resizing the window: measure on the\n // next frame, with the layout already settled.\n if (Platform.OS === 'android') requestAnimationFrame(() => void adjust(duration));\n else void adjust(duration);\n },\n _hide(e: KeyboardEvent | undefined) {\n keyboardTop.current = null;\n animateTo(0, e?.duration || FALLBACK_DURATION);\n },\n } as KeyboardHost & { _show: (e: KeyboardEvent) => void; _hide: (e?: KeyboardEvent) => void };\n }, [pan]);\n\n useEffect(() => {\n const h = host as KeyboardHost & { _show: (e: KeyboardEvent) => void; _hide: (e?: KeyboardEvent) => void };\n // iOS notifies before animating (will*): the shift follows the keyboard.\n // Android only has did*.\n const subs =\n Platform.OS === 'ios'\n ? [\n Keyboard.addListener('keyboardWillShow', h._show),\n Keyboard.addListener('keyboardWillChangeFrame', h._show),\n Keyboard.addListener('keyboardWillHide', h._hide),\n ]\n : [Keyboard.addListener('keyboardDidShow', h._show), Keyboard.addListener('keyboardDidHide', h._hide)];\n return () => subs.forEach((s) => s.remove());\n }, [host]);\n\n const moving = <Animated.View style={[styles.fill, { transform: [{ translateY: pan }] }]}>{children}</Animated.View>;\n return (\n <KeyboardHostContext.Provider value={host}>\n {clip ? (\n <View ref={areaRef} style={[styles.fill, styles.clip]} collapsable={false}>\n {moving}\n </View>\n ) : (\n moving\n )}\n </KeyboardHostContext.Provider>\n );\n}\n\n/** If the measurement never comes back (node unmounted), carry on without that limit. */\nconst MEASURE_TIMEOUT_MS = 100;\n\nfunction measureTop(node: ComponentRef<typeof View> | null): Promise<number | null> {\n return new Promise((resolve) => {\n if (!node) return resolve(null);\n const timer = setTimeout(() => resolve(null), MEASURE_TIMEOUT_MS);\n node.measureInWindow((_x, y) => {\n clearTimeout(timer);\n resolve(y);\n });\n });\n}\n\nconst styles = StyleSheet.create({ fill: { flex: 1 }, clip: { overflow: 'hidden' } });\n"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"path": "theme/NativeOverlay.tsx",
|
|
28
|
+
"content": "import React, { createContext, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport { Modal, Platform, View, type ModalProps, type ViewStyle } from 'react-native';\n\n/**\n * NativeOverlay\n * The native `Modal` every orn-ui overlay (Alert, BottomSheet, Modal, Select)\n * goes through.\n *\n * Why: iOS presents a native modal from the root view controller and only one\n * at a time. A second `<Modal>` that is not rendered inside the first one is\n * silently ignored while the first is presented, and also while it is still\n * being dismissed. In practice an Alert from `showAlert()` over an open\n * BottomSheet never showed, and neither did one opened right after closing a\n * sheet. Android opens a window per modal and never had the problem: there\n * this is a plain `Modal`.\n *\n * iOS, when an overlay becomes visible:\n * - Rendered inside another overlay (nested): plain native modal; iOS presents\n * it from that one, which works.\n * - Another overlay is presented and this one is not inside it: it is drawn\n * as a layer inside the topmost presented overlay instead of presenting a\n * second native modal. Its React context then comes from that overlay's\n * position: a provider that wraps only this overlay doesn't reach it.\n * - Another overlay is closing (exit animation or native dismissal): it waits\n * until that one is gone, then presents.\n */\n\nexport interface NativeOverlayProps\n extends Pick<\n ModalProps,\n 'transparent' | 'animationType' | 'presentationStyle' | 'statusBarTranslucent' | 'onRequestClose' | 'hardwareAccelerated'\n > {\n /**\n * The overlay is logically hidden but still mounted to play its exit\n * animation: it stays on screen, but no other overlay may layer into it.\n */\n closing?: boolean;\n /**\n * A dialog (Alert) always ends up on top: whatever gets layered into it is\n * drawn beneath its own content, and as a layer it is drawn after any\n * non-dialog layer of the same host.\n */\n dialog?: boolean;\n children: React.ReactNode;\n}\n\ntype Mode = 'pending' | 'native' | 'layer' | 'wait';\n\ninterface Host {\n id: number;\n closing: boolean;\n}\n\nconst fill: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 };\n/** After unmount, how long iOS may still be dismissing the native modal. */\nconst DISMISS_SETTLE_MS = 450;\n\n// Module-level registry: overlays live in different React subtrees, and the\n// constraint they share (one native presentation at a time) is global too.\nconst hosts: Host[] = [];\nconst layers = new Map<number, Map<number, { node: React.ReactNode; dialog: boolean }>>();\nconst ownerOf = new Map<number, number>();\nlet nextId = 1;\n\n// Two channels, so publishing a layer (every render of a layered overlay)\n// only re-renders that host's outlet, never the overlays themselves — which\n// would publish again and loop.\nconst hostListeners = new Set<() => void>();\nconst layerListeners = new Map<number, Set<() => void>>();\n\n/** The host stack changed: waiting/layered overlays re-evaluate. */\nfunction notify() {\n hostListeners.forEach((listener) => listener());\n}\n\n/** A host's layers changed: only its outlet re-renders. */\nfunction notifyLayers(hostId: number) {\n layerListeners.get(hostId)?.forEach((listener) => listener());\n}\n\nfunction useChannel(subscribe: (listener: () => void) => () => void) {\n const [, setVersion] = useState(0);\n useEffect(() => subscribe(() => setVersion((v) => v + 1)), [subscribe]);\n}\n\nconst subscribeHosts = (listener: () => void) => {\n hostListeners.add(listener);\n return () => {\n hostListeners.delete(listener);\n };\n};\n\nfunction liveTop(): Host | undefined {\n const top = hosts[hosts.length - 1];\n return top && !top.closing ? top : undefined;\n}\n\nfunction decide(parentHost: number | null): Mode {\n if (hosts.length === 0) return 'native';\n const top = liveTop();\n if (!top) return 'wait';\n // Nested inside the topmost overlay: iOS can present it from there.\n if (top.id === parentHost) return 'native';\n return 'layer';\n}\n\nfunction unpublish(id: number) {\n const owner = ownerOf.get(id);\n if (owner === undefined) return;\n layers.get(owner)?.delete(id);\n ownerOf.delete(id);\n notifyLayers(owner);\n}\n\n/** Takes the native slot right away, before any later overlay decides. */\nfunction claim(id: number, closing: boolean) {\n if (hosts.some((h) => h.id === id)) return;\n hosts.push({ id, closing });\n notify();\n}\n\nfunction removeHost(id: number) {\n const index = hosts.findIndex((h) => h.id === id);\n if (index < 0) return;\n hosts.splice(index, 1);\n notify();\n}\n\n/** Id of the native overlay this subtree is rendered in, if any. */\nconst HostContext = createContext<number | null>(null);\n\n/** Draws, inside a native host, the layers other overlays put into it. */\nfunction LayerOutlet({ hostId }: { hostId: number }) {\n const [, setVersion] = useState(0);\n // Layout effect + an immediate re-read: a layer may have been published in\n // the same commit, before this outlet subscribed.\n useLayoutEffect(() => {\n const listener = () => setVersion((v) => v + 1);\n if (!layerListeners.has(hostId)) layerListeners.set(hostId, new Set());\n layerListeners.get(hostId)!.add(listener);\n if (layers.get(hostId)?.size) listener();\n return () => {\n layerListeners.get(hostId)?.delete(listener);\n };\n }, [hostId]);\n const own = layers.get(hostId);\n if (!own || own.size === 0) return null;\n // Dialogs last, so they are drawn on top of sheets and modals.\n const ordered = [...own.entries()].sort(([, a], [, b]) => Number(a.dialog) - Number(b.dialog));\n return (\n <>\n {ordered.map(([key, layer]) => (\n <View key={key} style={fill} pointerEvents=\"box-none\">\n {layer.node}\n </View>\n ))}\n </>\n );\n}\n\nexport function NativeOverlay({ closing = false, dialog = false, children, ...modalProps }: NativeOverlayProps) {\n const ios = Platform.OS === 'ios';\n const parentHost = useContext(HostContext);\n const idRef = useRef(0);\n if (idRef.current === 0) idRef.current = nextId++;\n const id = idRef.current;\n useChannel(subscribeHosts);\n\n // Decided in a layout effect, not during render: layout effects run in tree\n // order before paint, so when two overlays mount in the same commit the\n // first one claims the native slot before the second one decides.\n const [mode, setMode] = useState<Mode>(ios ? 'pending' : 'native');\n\n useLayoutEffect(() => {\n if (!ios) return;\n if (mode === 'pending') {\n const next = decide(parentHost);\n if (next === 'native') claim(id, closing);\n setMode(next);\n return;\n }\n if (mode === 'wait' && !closing) {\n const next = decide(parentHost);\n if (next === 'native') claim(id, closing);\n if (next !== 'wait') setMode(next);\n }\n // The host it layered into started closing or went away: move out.\n if (mode === 'layer' && !closing) {\n const owner = ownerOf.get(id);\n const host = owner === undefined ? undefined : hosts.find((h) => h.id === owner);\n if (owner !== undefined && (!host || host.closing)) {\n unpublish(id);\n const next = decide(parentHost);\n if (next === 'native') claim(id, closing);\n setMode(next);\n }\n }\n });\n\n // Native host bookkeeping.\n useLayoutEffect(() => {\n if (!ios || mode !== 'native') return;\n const existing = hosts.find((h) => h.id === id);\n if (!existing) hosts.push({ id, closing });\n else if (existing.closing !== closing) existing.closing = closing;\n else return;\n notify();\n }, [ios, mode, closing, id]);\n\n // Layer publishing: every render, so prop changes reach the host.\n useLayoutEffect(() => {\n if (!ios || mode !== 'layer') return;\n let owner = ownerOf.get(id);\n if (owner === undefined) {\n owner = liveTop()?.id;\n // The host it chose started closing in the same commit (a sheet closed\n // and an alert opened in one tick): decide again, usually to wait.\n if (owner === undefined) {\n const next = decide(parentHost);\n if (next === 'native') claim(id, closing);\n setMode(next);\n return;\n }\n ownerOf.set(id, owner);\n }\n if (!layers.has(owner)) layers.set(owner, new Map());\n layers.get(owner)!.set(id, { node: <HostContext.Provider value={owner}>{children}</HostContext.Provider>, dialog });\n notifyLayers(owner);\n });\n\n // Unmount: a layer just leaves; a native host stays \"closing\" while iOS\n // finishes dismissing it, so the next overlay doesn't present too early.\n useEffect(\n () => () => {\n if (!ios) return;\n unpublish(id);\n const host = hosts.find((h) => h.id === id);\n if (host) {\n host.closing = true;\n notify();\n setTimeout(() => removeHost(id), DISMISS_SETTLE_MS);\n }\n },\n [ios, id]\n );\n\n if (!ios || mode === 'native') {\n return (\n <Modal visible {...modalProps}>\n <HostContext.Provider value={id}>\n {/* A dialog host draws what was layered into it underneath itself. */}\n {ios && dialog && <LayerOutlet hostId={id} />}\n {children}\n {ios && !dialog && <LayerOutlet hostId={id} />}\n </HostContext.Provider>\n </Modal>\n );\n }\n\n // 'layer' renders inside the host; 'wait' and 'pending' render nothing yet.\n return null;\n}\n\n/** Test-only: clears the registry between tests. */\nexport function __resetNativeOverlays() {\n hosts.splice(0);\n layers.clear();\n ownerOf.clear();\n}\n"
|
|
21
29
|
},
|
|
22
30
|
{
|
|
23
31
|
"path": "theme/UIProvider.tsx",
|
|
24
|
-
"content": "import React, { createContext, useContext, useMemo, useState, useCallback,
|
|
32
|
+
"content": "import React, { createContext, useContext, useMemo, useState, useCallback, useEffect } from 'react';\nimport { Appearance, useColorScheme, type ViewStyle } from 'react-native';\nimport { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';\nimport { defaultTheme } from './createTheme';\nimport { defaultLabels, type Labels } from './labels';\nimport type { EdgeInsets } from './insets';\nimport type { Theme, ThemePair } from './types';\nimport { renderDefaultIcon } from '../icons/defaultIcons';\nimport type { IconRenderer } from '../icons/types';\nimport { KeyboardHandled, KeyboardPanArea } from './KeyboardAvoidance';\n\nexport type ThemeMode = 'system' | 'light' | 'dark';\n\n/**\n * A native `<Modal>`/`<BottomSheet>` opens its own window (always on Android;\n * on iOS depending on `presentationStyle`), so the insets measured at the app\n * root may not hold in there. The boundary mounts a new `<SafeAreaProvider>`\n * inside that window and re-measures; until the first measurement arrives it\n * repeats the ambient insets.\n */\nexport interface ModalSafeAreaBoundaryProps {\n children: (insets: EdgeInsets) => React.ReactNode;\n}\nexport type ModalSafeAreaBoundaryComponent = React.ComponentType<ModalSafeAreaBoundaryProps>;\n\nconst probeStyle: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 };\n\nfunction sameInsets(a: EdgeInsets, b: EdgeInsets) {\n return a.top === b.top && a.right === b.right && a.bottom === b.bottom && a.left === b.left;\n}\n\nfunction InsetsProbe({ onMeasure }: { onMeasure: (insets: EdgeInsets) => void }) {\n const insets = useSafeAreaInsets();\n useEffect(() => {\n onMeasure(insets);\n }, [insets, onMeasure]);\n return null;\n}\n\nfunction RemeasuringModalSafeAreaBoundary({ children }: ModalSafeAreaBoundaryProps) {\n const ambient = useInsets();\n const [measured, setMeasured] = useState<EdgeInsets | null>(null);\n const onMeasure = useCallback(\n (next: EdgeInsets) => setMeasured((prev) => (prev && sameInsets(prev, next) ? prev : next)),\n []\n );\n\n return (\n <>\n <SafeAreaProvider style={probeStyle} pointerEvents=\"none\">\n <InsetsProbe onMeasure={onMeasure} />\n </SafeAreaProvider>\n {children(measured ?? ambient)}\n </>\n );\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 /**\n * Overrides the measured safe area insets. Leave it out: the provider\n * measures them itself with react-native-safe-area-context.\n */\n insets?: EdgeInsets;\n labels?: Partial<Labels>;\n /**\n * Lets every orn-ui text follow the system font size (Dynamic Type / Android\n * font scale). Opt-in: layouts are tuned for the default size, so turn it on\n * only after checking your screens at large sizes.\n * @default false\n */\n allowFontScaling?: boolean;\n /**\n * Mounts a `<SafeAreaProvider>` around the app. Pass `false` only if the app\n * already mounts one above (e.g. expo-router does): insets are then read\n * from that one.\n * @default true\n */\n mountSafeAreaProvider?: boolean;\n /**\n * Keeps the focused Input visible above the keyboard by moving the whole app\n * up just enough (like Android's adjustPan, on both platforms), with no\n * Screen or ScrollView needed. Only the root provider does it. Turn it off if\n * your app already handles the keyboard its own way.\n * @default true\n */\n keyboardAvoidance?: boolean;\n}\n\ninterface UIProviderBaseProps extends Omit<UIProviderProps, 'insets' | 'mountSafeAreaProvider'> {\n insets: EdgeInsets;\n}\n\n/**\n * UIProvider\n * The library's single entry point: resolves the active theme (system or\n * manual override), measures the safe area insets (react-native-safe-area-context,\n * a required peer) and injects icons, insets and default texts into the whole\n * tree. It doesn't persist the mode — if the consumer wants to remember the\n * preference across sessions, it controls `mode`/`onModeChange` and persists\n * wherever it prefers.\n */\nexport function UIProvider({ mountSafeAreaProvider = true, ...props }: UIProviderProps) {\n if (!mountSafeAreaProvider) return <MeasuredUIProvider {...props} />;\n return (\n <SafeAreaProvider>\n <MeasuredUIProvider {...props} />\n </SafeAreaProvider>\n );\n}\n\nfunction MeasuredUIProvider({ insets: override, ...rest }: Omit<UIProviderProps, 'mountSafeAreaProvider'>) {\n const measured = useSafeAreaInsets();\n return <UIProviderBase insets={override ?? measured} {...rest} />;\n}\n\nfunction UIProviderBase({\n children,\n theme = defaultTheme,\n defaultMode = 'system',\n mode: controlledMode,\n onModeChange,\n icons = renderDefaultIcon,\n insets,\n labels: labelOverrides,\n allowFontScaling = false,\n keyboardAvoidance = true,\n}: UIProviderBaseProps) {\n // Only the root provider shifts the app: a nested one (a modal with its own\n // provider, a test) wraps nothing and doesn't change the layout.\n const isRoot = useContext(UIContext) === undefined;\n const ModalSafeAreaBoundary = RemeasuringModalSafeAreaBoundary;\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 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 let content = children;\n if (isRoot) {\n content = keyboardAvoidance ? (\n <KeyboardPanArea margin={resolvedTheme.tokens.spacing.md} topLimit={insets.top}>\n {children}\n </KeyboardPanArea>\n ) : (\n <KeyboardHandled>{children}</KeyboardHandled>\n );\n }\n\n return <UIContext.Provider value={value}>{content}</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/** Internal: consumed by `Modal`/`BottomSheet` to re-measure insets inside their own native window. */\nexport function useModalSafeAreaBoundary(): ModalSafeAreaBoundaryComponent {\n return useUIContext().ModalSafeAreaBoundary;\n}\n\n/** Re-exported so tests/consumers can simulate system changes without real RN. */\nexport const __testUtils = { Appearance };\n"
|
|
25
33
|
},
|
|
26
34
|
{
|
|
27
35
|
"path": "theme/colors.ts",
|
|
28
|
-
"content": "/**\n *
|
|
36
|
+
"content": "/**\n * Dependency-free color utilities, used to derive a family of roles\n * (fill / on-top / soft / readable) from a single brand hex.\n */\n\n/** Minimum AA contrast for normal text. */\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 a theme accent needs, in a given scheme. */\nexport interface AccentRoles {\n /** Solid fill: button background, current step's circle. */\n base: string;\n /** Text/icon placed on top of the fill. */\n on: string;\n /** Subtle background: badges, active indicator pill. */\n soft: string;\n /** The accent legible on `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: expected a hex color like \"#7c3aed\", got \"${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/** WCAG relative luminance of a 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/** WCAG contrast ratio between two hexes: 1 (equal) to 21 (white on black). */\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 * White if it reaches AA contrast —which is what you expect on top of a color\n * fill— and otherwise whichever extreme gives the most contrast.\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 * Shifts the hue's lightness until it reaches the requested contrast against\n * `against`. If not even the extreme gets there —pure yellow on white, for\n * example— it returns the most contrasting one found along the way.\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 * Derives an accent's four roles from a hex, for one scheme.\n * `surface` is the background the `text` role has to be read on, so each\n * scheme passes its own.\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 // In dark the accent goes up in lightness and what sits on top goes down: a\n // #004cef over #1e1e1e can't be told apart from the background.\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
37
|
},
|
|
30
38
|
{
|
|
31
39
|
"path": "theme/createStyles.ts",
|
|
32
|
-
"content": "import { StyleSheet } from 'react-native';\nimport { useTheme } from './UIProvider';\nimport type { Theme } from './types';\n\n/**\n * createStyles(factory)\n *
|
|
40
|
+
"content": "import { StyleSheet, type ImageStyle, type TextStyle, type ViewStyle } from 'react-native';\nimport { useTheme } from './UIProvider';\nimport type { Theme } from './types';\n\n/**\n * createStyles(factory)\n * Returns a useStyles() hook that memoizes the resulting StyleSheet per\n * theme (WeakMap<Theme, Styles>), not per component instance. All rows of a\n * list share the same styles object: without this, N rows ⇒ N\n * StyleSheet.create() per render, which is what every component of the\n * original app did with `useMemo(() => createStyles(colors), [colors])`.\n *\n * The cache lives in the factory's closure (one WeakMap per createStyles\n * call, i.e. one per component), so themes from different <UIProvider>s in\n * the same tree don't collide with each other.\n *\n * The `factory` type follows RN's pattern for typed StyleSheets\n * (`T extends NamedStyles<T> | NamedStyles<any>`): it is what gives TS the\n * context to infer literals ('row', 'center', ...) instead of widening them\n * to `string` inside the returned object. `NamedStyles` is declared here:\n * react-native 0.88's generated types (Expo SDK 58) no longer export\n * `StyleSheet.NamedStyles`.\n */\ntype NamedStyles<T> = { [P in keyof T]: ViewStyle | TextStyle | ImageStyle };\n\nexport function createStyles<T extends NamedStyles<T> | NamedStyles<any>>(\n factory: (theme: Theme) => T | 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 // The cast is safe: StyleSheet.create<T>(...) with the factory's same T\n // always returns T; TS can't prove it on its own inside a generic body\n // (T could be \"instantiated\" with an unrelated subtype from its point\n // of view, even though at runtime it is the same).\n styles = StyleSheet.create(factory(theme)) as T;\n cache.set(theme, styles);\n }\n return styles;\n };\n}\n"
|
|
33
41
|
},
|
|
34
42
|
{
|
|
35
43
|
"path": "theme/createTheme.ts",
|
|
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/**
|
|
44
|
+
"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/** Expands each `brand` hex into the accent's four roles in a scheme. */\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 * Builds the light/dark theme pair consumed by <UIProvider theme={...}>.\n * Never mutates the defaults: each call produces new objects, safe to share\n * between different providers (tests, multi-brand in the same tree).\n *\n * `brand` is applied before `colors`, so a hand-written role always wins\n * over the derived one.\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"
|
|
37
45
|
},
|
|
38
46
|
{
|
|
39
47
|
"path": "theme/index.ts",
|
|
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"
|
|
48
|
+
"content": "export { spacing, radius, fontSize, fontWeight, lineHeight, letterSpacing, 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"
|
|
41
49
|
},
|
|
42
50
|
{
|
|
43
51
|
"path": "theme/insets.ts",
|
|
44
|
-
"content": "/**
|
|
52
|
+
"content": "/** Structurally identical to react-native-safe-area-context's EdgeInsets, without depending on the package. */\nexport interface EdgeInsets {\n top: number;\n right: number;\n bottom: number;\n left: number;\n}\n\nexport const zeroInsets: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };\n"
|
|
45
53
|
},
|
|
46
54
|
{
|
|
47
55
|
"path": "theme/labels.ts",
|
|
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 /**
|
|
56
|
+
"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 /** Month names, January to December. */\n months: string[];\n /** Weekday initials, index 0 = Sunday (matches `Date.getDay()`). */\n weekdaysShort: string[];\n selectDate: string;\n clear: string;\n /** Eye button in Input with `isPassword`. */\n showPassword: string;\n hidePassword: string;\n /** Appended to the accessible name of a `required` field (the asterisk is visual only). */\n required: string;\n /** Input validation: shown when a `required` field is empty. */\n requiredError: string;\n /** Input validation, `rules.minLength`. `{n}` is replaced by the limit. */\n minLengthError: string;\n /** Input validation, `rules.maxLength`. `{n}` is replaced by the limit. */\n maxLengthError: string;\n /** Input validation, `rules.pattern`. */\n patternError: string;\n /** Stepper: accessible name of the numeric field. */\n quantity: string;\n /** Stepper: \"+\" button. */\n increment: string;\n /** Stepper: \"-\" button. */\n decrement: string;\n /** Steps: accessible name of a step. `{n}`, `{total}` and `{label}` are replaced. */\n stepOf: string;\n /** Steps: state of a step already done. */\n stepCompleted: string;\n /** Steps: state of the step in progress. */\n stepCurrent: string;\n /** Steps: state of a step not reached yet. */\n stepPending: string;\n /** DatePicker: button to the previous month. */\n previousMonth: string;\n /** DatePicker: button to the next month. */\n nextMonth: string;\n /** ReorderableList: screen-reader action that moves a row one place up. */\n moveUp: string;\n /** ReorderableList: screen-reader action that moves a row one place down. */\n moveDown: string;\n /** SearchList: accessible name of the scan button. */\n scan: string;\n /** Slides: indicator name. `{n}` and `{total}` are replaced. */\n slideOf: string;\n /** Slides: button that pauses autoPlay. */\n pauseSlides: string;\n /** Slides: button that resumes autoPlay. */\n resumeSlides: string;\n /** ThemeToggle: segment that follows the system appearance. */\n themeSystem: string;\n /** ThemeToggle: light segment. */\n themeLight: string;\n /** ThemeToggle: dark segment. */\n themeDark: string;\n /** Wizard: button to the previous step. */\n back: string;\n /** Wizard: button to the next step. */\n next: string;\n /** Wizard: next button on the last step. */\n finish: 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 showPassword: 'Show password',\n hidePassword: 'Hide password',\n required: 'required',\n requiredError: 'This field is required',\n minLengthError: 'Enter at least {n} characters',\n maxLengthError: 'Enter at most {n} characters',\n patternError: 'Invalid format',\n quantity: 'Quantity',\n increment: 'Increment',\n decrement: 'Decrement',\n stepOf: 'Step {n} of {total}: {label}',\n stepCompleted: 'completed',\n stepCurrent: 'current',\n stepPending: 'pending',\n previousMonth: 'Previous month',\n nextMonth: 'Next month',\n moveUp: 'Move up',\n moveDown: 'Move down',\n scan: 'Scan',\n slideOf: 'Slide {n} of {total}',\n pauseSlides: 'Pause automatic slides',\n resumeSlides: 'Resume automatic slides',\n themeSystem: 'Auto',\n themeLight: 'Light',\n themeDark: 'Dark',\n back: 'Back',\n next: 'Next',\n finish: 'Finish',\n};\n"
|
|
49
57
|
},
|
|
50
58
|
{
|
|
51
59
|
"path": "theme/palettes.ts",
|
|
52
|
-
"content": "/**\n *
|
|
60
|
+
"content": "/**\n * Each color is a `string`, not a literal (`as const`): light and dark have\n * different values for the same key, and createTheme() must be able to\n * accept any hex/rgba in the overrides — literal typing would prevent it.\n *\n * Each accent comes in four roles, and they are not interchangeable:\n *\n * `primary` solid fill (button background, selected day, FAB).\n * `onPrimary` text/icon *on top of* that fill.\n * `primarySoft` tinted background for selected states and pills.\n * `primaryText` accent text/icon over `surface`, `background`\n * or over its own `primarySoft`.\n *\n * The reason is contrast: an accent that reads well as a fill doesn't read\n * as text. Components used to use `colors.primary` for both things and tinted\n * it by appending alpha to the hex (`colors.primary + '15'`), which in dark\n * gave 2.6:1 over the surface — well below WCAG AA's 4.5:1. Every pair in\n * this palette passes AA in both schemes.\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 /**\n * Fill of a surface that sits on another surface (Card). In light it equals\n * `surface` (the shadow separates it); in dark it is lighter, because\n * shadows don't show and elevation reads as a lighter fill.\n */\n surfaceRaised: string;\n text: string;\n textLight: string;\n border: string;\n inputBackground: string;\n overlay: string;\n /** Semi-transparent veil over custom content (List's loading footer). */\n scrim: string;\n /** Placeholder background while an image loads. */\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 // Red goes with white text, which is what people expect from a destructive\n // button. #ff3b30 doesn't reach 4.5:1 with white and forced near-black text\n // over red; this shade does, and still reads as an alert red.\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 surfaceRaised: '#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: '#e3e5e8',\n};\n\n/**\n * The dark accents are NOT the light ones: over `#1e1e1e` the accent's\n * lightness has to go up and that of the text on top has to go down (which\n * is why `onPrimary` is dark blue here and white in 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 surfaceRaised: '#303036',\n text: '#ecedee',\n textLight: '#9ba1a6',\n border: '#3a3a40',\n inputBackground: '#232326',\n overlay: 'rgba(0,0,0,0.6)',\n scrim: 'rgba(18,18,18,0.8)',\n skeleton: '#44444b',\n};\n"
|
|
53
61
|
},
|
|
54
62
|
{
|
|
55
63
|
"path": "theme/tokens.ts",
|
|
56
|
-
"content": "import { Platform, type ViewStyle } from 'react-native';\n\n//
|
|
64
|
+
"content": "import { Platform, type ViewStyle } from 'react-native';\n\n// Numeric scales are typed explicitly as `number` (not `as const`):\n// createTheme() must be able to override any value with any other number:\n// with literal typing, overriding `radius.md` to `999` would fail because\n// the inferred type would be the literal `8`, not `number`.\n\n/**\n * Spacing scale. Always use these values instead of magic numbers so the\n * vertical/horizontal rhythm is consistent across components.\n */\nexport const spacing: Record<'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'xxxl', number> = {\n xxs: 2,\n xs: 4,\n sm: 8,\n md: 12,\n lg: 16,\n xl: 20,\n xxl: 24,\n xxxl: 32,\n};\n\nexport const radius: Record<'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'full', number> = {\n none: 0,\n xs: 4,\n sm: 6,\n md: 8,\n lg: 12,\n xl: 16,\n xxl: 20,\n full: 9999,\n};\n\nexport const fontSize: Record<'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl', number> = {\n xs: 11,\n sm: 12,\n md: 14,\n lg: 16,\n xl: 18,\n xxl: 24,\n};\n\n// fontWeight does need literals: RN's TextStyle.fontWeight requires a union\n// of specific strings ('400' | '500' | ...), not an arbitrary `string`.\nexport const fontWeight = {\n regular: '400',\n medium: '500',\n semibold: '600',\n bold: '700',\n} as const;\n\nexport const lineHeight: Record<'sm' | 'md' | 'lg' | 'xl', number> = {\n sm: 16,\n md: 20,\n lg: 24,\n xl: 30,\n};\n\n/**\n * Tracking (letterSpacing) in points. `tight` offsets the extra room that\n * large sizes bring by default (titles); `normal` is the system's;\n * `wide` spaces out small caps.\n */\nexport const letterSpacing: Record<'tight' | 'normal' | 'wide', number> = {\n tight: -0.5,\n normal: 0,\n /** Short, small, uppercase labels (Badge): a little room makes them legible. */\n wide: 0.3,\n};\n\n/** Durations in ms for Animated.timing/spring. */\nexport const duration: Record<'fast' | 'base' | 'slow', number> = {\n fast: 150,\n base: 200,\n slow: 250,\n};\n\ntype ShadowStyle = Pick<\n ViewStyle,\n 'shadowColor' | 'shadowOffset' | 'shadowOpacity' | 'shadowRadius' | 'elevation'\n>;\n\n/**\n * Cross-platform shadows: shadow* for iOS, elevation for Android.\n * `shadowColor` is left fixed at black — a per-theme shadow color is too\n * fine a detail to justify the complexity of resolving it here.\n */\nexport const shadow: Record<'none' | 'sm' | 'md' | 'lg', ShadowStyle> = {\n none: Platform.select({\n android: { elevation: 0 },\n default: { shadowOpacity: 0 },\n }) as ShadowStyle,\n sm: {\n shadowColor: '#000',\n shadowOffset: { width: 0, height: 1 },\n shadowOpacity: 0.08,\n shadowRadius: 2,\n elevation: 2,\n },\n md: {\n shadowColor: '#000',\n shadowOffset: { width: 0, height: 2 },\n shadowOpacity: 0.1,\n shadowRadius: 8,\n elevation: 4,\n },\n lg: {\n shadowColor: '#000',\n shadowOffset: { width: 0, height: 4 },\n shadowOpacity: 0.15,\n shadowRadius: 16,\n elevation: 8,\n },\n};\n\nexport const tokens = {\n spacing,\n radius,\n fontSize,\n fontWeight,\n lineHeight,\n letterSpacing,\n duration,\n shadow,\n};\n\nexport type Tokens = typeof tokens;\n"
|
|
57
65
|
},
|
|
58
66
|
{
|
|
59
67
|
"path": "theme/types.ts",
|
|
60
|
-
"content": "import type { Tokens } from './tokens';\nimport type { ThemeColors } from './palettes';\n\n/**
|
|
68
|
+
"content": "import type { Tokens } from './tokens';\nimport type { ThemeColors } from './palettes';\n\n/** Resolved theme: the palette of a given scheme + shared tokens. */\nexport interface Theme {\n colors: ThemeColors;\n tokens: Tokens;\n scheme: 'light' | 'dark';\n}\n\n/** Light/dark theme pair produced by createTheme() and consumed by 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/** Accents that can be defined with a single hex via `brand`. */\nexport type BrandAccent = 'primary' | 'secondary' | 'success' | 'error' | 'warning';\n\nexport interface ThemeOverrides {\n /**\n * One hex per accent; each one derives its four roles (fill, content on top,\n * subtle background and the variant legible on `surface`) for light and\n * dark, with contrast already resolved. A plain string sets `primary`.\n * Anything passed in `colors` wins over the derived values.\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"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"path": "theme/useKeyboardField.ts",
|
|
72
|
+
"content": "import { useMemo, useRef, type RefObject, type ComponentRef } from 'react';\nimport type { View } from 'react-native';\nimport { useKeyboardHost, type KeyboardField } from './KeyboardAvoidance';\n\n/**\n * Registers a text field with the nearest keyboard host (a Screen's content or\n * the root UIProvider) so it stays visible above the keyboard. `boxRef` is the\n * block that has to be seen whole: label, field and error.\n *\n * Call `focus()` from the TextInput's onFocus (and again when the block grows,\n * e.g. an error shows up while typing) and `blur()` from onBlur. Inside a\n * container that already handles the keyboard there is no host and both are\n * no-ops.\n */\nexport function useKeyboardField(boxRef: RefObject<ComponentRef<typeof View> | null>) {\n const host = useKeyboardHost();\n const field = useRef<KeyboardField>({\n measure: () =>\n new Promise((resolve) => {\n const node = boxRef.current;\n if (!node) return resolve(null);\n node.measureInWindow((_x, y, _w, height) => resolve({ y, height }));\n }),\n }).current;\n\n return useMemo(\n () => ({\n focus: () => host?.focus(field),\n blur: () => host?.blur(field),\n }),\n [host, field]\n );\n}\n"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"path": "theme/useReduceMotion.ts",
|
|
76
|
+
"content": "import { useEffect, useState } from 'react';\nimport { AccessibilityInfo } from 'react-native';\n\n/**\n * Follows the system \"reduce motion\" setting: when it is on, animations jump\n * to their final state instead of being disabled, so callbacks and unmounting\n * still happen the same way.\n */\nexport function useReduceMotion(): boolean {\n const [reduceMotion, setReduceMotion] = useState(false);\n\n useEffect(() => {\n let alive = true;\n // The state is only updated if the setting is on: the initial value is\n // already `false` and a setState from false to false would cost a render per mount.\n AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {\n if (alive && enabled) setReduceMotion(true);\n });\n const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion);\n return () => {\n alive = false;\n sub.remove();\n };\n }, []);\n\n return reduceMotion;\n}\n"
|
|
61
77
|
}
|
|
62
78
|
],
|
|
63
79
|
"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, 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:
|
|
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';\nimport { Icon } from '../icons/Icon';\n\nexport interface DateFieldProps\n extends Pick<DatePickerProps, 'minDate' | 'maxDate' | 'monthNames' | 'weekdayNames' | 'firstDayOfWeek'> {\n /** Text above the field; also its accessible name. */\n label?: string;\n /** Shows an asterisk and says \"required\" to screen readers. @default false */\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 /** Text while empty. @default labels.selectDate */\n placeholder?: string;\n /** Error under the field (red border); announced to screen readers. */\n error?: string;\n /** Dims the field and ignores taps. @default false */\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 /** Style of the outer wrapper (label, field and error). */\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 DISABLED_OPACITY = 0.5;\nconst PRESSED_OPACITY = 0.7;\n/** Same box as Input, so a form reads as one column of identical fields. */\nconst FIELD_MIN_HEIGHT = 50;\nconst FIELD_BORDER = 2;\nconst CHEVRON_SIZE = 20;\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: FIELD_BORDER,\n borderColor: theme.colors.border,\n minHeight: FIELD_MIN_HEIGHT,\n paddingHorizontal: theme.tokens.spacing.lg,\n },\n fieldError: { borderColor: theme.colors.error },\n fieldDisabled: { opacity: DISABLED_OPACITY },\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 * Date field: looks like an Input, opens a `DatePicker` in a Modal when\n * tapped. No native system picker, so it looks and behaves the same on iOS\n * and Android.\n *\n * Gestalt, similarity (and Jakob's law): same box, border, height and label\n * as Input, and the same chevron as Select, so in a form it reads as one more\n * field; only the chevron says it opens something. Proximity: label, field\n * and error stack tight, as one unit.\n *\n * Accessibility: the name includes the label, \"required\" and the chosen date;\n * the error is a hint; `expanded` tells whether the calendar is open.\n */\nexport const DateField = memo(\n ({\n label,\n required = false,\n mode = 'single',\n value,\n onChange,\n range,\n onRangeChange,\n placeholder,\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 emptyText = placeholder ?? labels.selectDate;\n const isRange = mode === 'range';\n\n const formatDate = (date: Date) => (format ? format(date) : defaultFormat(date, months));\n\n // The calendar closes on its own only once there is nothing left to pick:\n // in range mode, only when the second tap closes the interval.\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 = emptyText;\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 && styles.fieldDisabled]}\n onPress={() => setOpen(true)}\n disabled={disabled}\n activeOpacity={PRESSED_OPACITY}\n accessibilityRole=\"button\"\n accessibilityLabel={[label ?? emptyText, required ? labels.required : null, hasValue ? display : null]\n .filter(Boolean)\n .join(', ')}\n accessibilityHint={error || undefined}\n accessibilityState={{ disabled, expanded: open }}\n >\n <Text allowFontScaling={allowFontScaling} style={[styles.fieldText, !hasValue && styles.placeholder]}>\n {display}\n </Text>\n <Icon name=\"chevron-down\" size={CHEVRON_SIZE} color={colors.textLight} />\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,12 +12,13 @@
|
|
|
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' 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"
|
|
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/** Index 0 = Sunday, to line up with Date.getDay(). */\nexport const DEFAULT_WEEKDAY_NAMES = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];\n\nexport interface DateRange {\n start: Date;\n /** Missing while the range is half-picked (only the start was tapped). */\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 /** Earliest selectable day; earlier days are dimmed and the back arrow stops there. */\n minDate?: Date;\n /** Latest selectable day; later days are dimmed and the forward arrow stops there. */\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/** The day's timestamp, to compare without creating a Date again in each cell. */\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/** Milliseconds left until the next local midnight. */\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/** Absolute month number (year * 12 + month), to compare months with a `<`. */\nfunction monthIndex(d: Date): number {\n return d.getFullYear() * 12 + d.getMonth();\n}\n\n/**\n * Returns the month grid: 6 rows of 7 cells. Cells outside the month are\n * null, so the layout doesn't jump in height between months (a month can\n * need 4, 5 or 6 rows depending on which day the 1st falls on).\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\n/** Drawn size of a day; hitSlop takes the touch target to 44pt (Fitts). */\nconst DAY_SIZE = 36;\nconst MIN_TOUCH = 44;\nconst DAY_HIT_SLOP = (MIN_TOUCH - DAY_SIZE) / 2;\nconst DAY_HIT = { top: DAY_HIT_SLOP, bottom: DAY_HIT_SLOP, left: DAY_HIT_SLOP, right: DAY_HIT_SLOP };\n\nconst useStyles = createStyles((theme) => ({\n container: {\n // Raised, like Card: in dark the calendar reads as a panel, not only as a border.\n backgroundColor: theme.colors.surfaceRaised,\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: DAY_SIZE, height: DAY_SIZE, borderRadius: DAY_SIZE / 2, 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 // The highlight of the in-between stretch goes on the cell (square, no gaps)\n // and not on the round button, so the range bar looks continuous.\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 * A calendar cell. It is memoized separately because the whole month is 42\n * cells: without this, any parent render reconciles all 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={PRESSED_OPACITY}\n hitSlop={DAY_HIT}\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 * Monthly calendar drawn with plain View/Text — without\n * @react-native-community/datetimepicker or any native picker, to keep the\n * library at zero dependencies and look the same on iOS and Android.\n *\n * Gestalt, common region and alignment: 6 rows of 7 cells always (the grid\n * never jumps between months), weekdays aligned over their column. Similarity:\n * every day is the same circle; state changes only fill and color (selected\n * filled, today outlined, out of range dimmed). Continuity: a range is one\n * band from start to end, not separate dots. Fitts: each day reaches a 44pt touch target (36pt circle + hitSlop).\n *\n * Accessibility: the month is a header, each day reads its full date, and the\n * arrows' names come from `labels` (`previousMonth`, `nextMonth`).\n */\nconst PRESSED_OPACITY = 0.7;\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 // First tap (or range already closed, or a tap before the start): starts\n // a new range. Second tap onward: closes it.\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 // The selected date can change from outside (controlled use): without this,\n // the calendar stayed stuck on the month it mounted with.\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 // \"Today\" is recalculated at the next midnight: an app left open all\n // night marked the previous day.\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 // The limits are resolved once and not 42 times per render: comparing\n // timestamps is free, creating a Date per cell isn't.\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 // A month entirely outside the limits has nothing to pick: the arrow\n // leading there turns off instead of letting you navigate into the void.\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={labels.previousMonth}\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={labels.nextMonth}\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 // A day outside the limits isn't painted as part of the\n // range: it looks disabled, and that is what it is.\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": [
|
|
19
19
|
"core",
|
|
20
20
|
"icon-button",
|
|
21
|
-
"pressable-scale"
|
|
21
|
+
"pressable-scale",
|
|
22
|
+
"transition"
|
|
22
23
|
]
|
|
23
24
|
}
|
package/registry/divider.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "atoms/Divider.tsx",
|
|
12
|
-
"content": "import React, { memo } from 'react';\nimport { View, type StyleProp, type ViewStyle } from 'react-native';\nimport {
|
|
12
|
+
"content": "import React, { memo } from 'react';\nimport { View, type StyleProp, type ViewStyle } from 'react-native';\nimport { useTheme } from '../theme/UIProvider';\nimport type { Tokens } from '../theme/tokens';\n\nexport interface DividerProps {\n /** `vertical` separates items in a row and stretches to the row's height. @default 'horizontal' */\n orientation?: 'horizontal' | 'vertical';\n /** Space on each side of the line, from the spacing scale. Unset, the line adds no margin. */\n spacing?: keyof Tokens['spacing'];\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\n/**\n * Divider\n * 1pt line in `colors.border`.\n *\n * Gestalt: separates groups when proximity alone isn't enough (dense lists,\n * rows spanning the whole screen). If spacing already separates the groups,\n * prefer spacing: one line too many fragments what should read together.\n * `spacing` takes its room from the token scale so the separation is the\n * same across the whole app, instead of a different margin on each screen.\n *\n * It is decorative: it doesn't enter the accessibility tree.\n */\nexport const Divider = memo(({ orientation = 'horizontal', spacing, style, testID }: DividerProps) => {\n const theme = useTheme();\n const gap = spacing ? theme.tokens.spacing[spacing] : 0;\n const line: ViewStyle =\n orientation === 'vertical'\n ? { width: 1, alignSelf: 'stretch', marginHorizontal: gap }\n : { height: 1, width: '100%', marginVertical: gap };\n return (\n <View\n testID={testID}\n accessible={false}\n importantForAccessibility=\"no-hide-descendants\"\n accessibilityElementsHidden\n style={[line, { backgroundColor: theme.colors.border }, style]}\n />\n );\n});\nDivider.displayName = 'Divider';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|