orn-ui 0.2.9 → 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 +67 -52
- package/README.md +5 -9
- 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 +197 -156
- 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 +199 -158
- 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 +223 -165
- 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/shiny-text.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "atoms/ShinyText.tsx",
|
|
12
|
-
"content": "import React, { memo, useEffect, useMemo, useRef } from 'react';\nimport {\n Animated,\n Easing,\n Text,\n View,\n type StyleProp,\n type TextStyle,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useTheme } from '../theme/UIProvider';\nimport { useReduceMotion } from './Transition';\n\nexport interface ShinyTextProps {\n /** The text to animate. Only a string: the effect needs the characters. */\n text: string;\n /** Milliseconds a single sweep takes end to end. @default 2400 */\n duration?: number;\n /** Pause between sweeps, in ms. @default 600 */\n delay?: number;\n /**\n * Width of the bright band as a fraction of the text, from 0 to 1. Small\n * values give a narrow glint, large ones a soft wave. @default 0.28\n */\n spread?: number;\n /** When false it renders plain text in `shineColor`. @default true */\n animated?: boolean;\n /** Resting color of the characters. @default theme.colors.textLight */\n color?: string;\n /** Color of the band that travels across. @default theme.colors.primary */\n shineColor?: string;\n /** @default 'left' */\n align?: 'left' | 'center' | 'right';\n /** Typography only: `color` and `shineColor` win over any color set here. */\n style?: StyleProp<TextStyle>;\n testID?: string;\n}\n\nconst DEFAULT_DURATION = 2400;\nconst DEFAULT_DELAY = 600;\nconst DEFAULT_SPREAD = 0.28;\n\nconst useStyles = createStyles((theme) => ({\n text: {\n fontSize: theme.tokens.fontSize.xl,\n lineHeight: theme.tokens.lineHeight.lg,\n fontWeight: theme.tokens.fontWeight.semibold,\n color: theme.colors.text,\n },\n row: { flexDirection: 'row', flexWrap: 'wrap' },\n //
|
|
12
|
+
"content": "import React, { memo, useEffect, useMemo, useRef } from 'react';\nimport {\n Animated,\n Easing,\n Text,\n View,\n type StyleProp,\n type TextStyle,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useTheme } from '../theme/UIProvider';\nimport { useReduceMotion } from './Transition';\n\nexport interface ShinyTextProps {\n /** The text to animate. Only a string: the effect needs the characters. */\n text: string;\n /** Milliseconds a single sweep takes end to end. @default 2400 */\n duration?: number;\n /** Pause between sweeps, in ms. @default 600 */\n delay?: number;\n /**\n * Width of the bright band as a fraction of the text, from 0 to 1. Small\n * values give a narrow glint, large ones a soft wave. @default 0.28\n */\n spread?: number;\n /** When false it renders plain text in `shineColor`. @default true */\n animated?: boolean;\n /** Resting color of the characters. Keep it readable on its own (WCAG AA): the shine is decorative. @default theme.colors.textLight */\n color?: string;\n /** Color of the band that travels across. @default theme.colors.primary */\n shineColor?: string;\n /** Horizontal alignment of the line. @default 'left' */\n align?: 'left' | 'center' | 'right';\n /** Typography only: `color` and `shineColor` win over any color set here. */\n style?: StyleProp<TextStyle>;\n testID?: string;\n}\n\nconst DEFAULT_DURATION = 2400;\nconst DEFAULT_DELAY = 600;\nconst DEFAULT_SPREAD = 0.28;\n\nconst useStyles = createStyles((theme) => ({\n text: {\n fontSize: theme.tokens.fontSize.xl,\n lineHeight: theme.tokens.lineHeight.lg,\n fontWeight: theme.tokens.fontWeight.semibold,\n color: theme.colors.text,\n },\n row: { flexDirection: 'row', flexWrap: 'wrap' },\n // Only top/left: an absoluteFill would stretch the letter to the group's width\n // and realign it. Anchored to the corner it lands exactly on the one below.\n overlay: { position: 'absolute', top: 0, left: 0 },\n // A word and the spaces that follow it: line breaks happen between\n // groups, never between the letters of a word.\n group: { flexDirection: 'row' },\n}));\n\nconst ALIGN: Record<'left' | 'center' | 'right', ViewStyle['justifyContent']> = {\n left: 'flex-start',\n center: 'center',\n right: 'flex-end',\n};\n\n/** A word with the spaces that follow it, and the index of its first letter. */\ninterface Group {\n start: number;\n chars: string[];\n}\n\nfunction toGroups(text: string): Group[] {\n const chars = Array.from(text);\n const groups: Group[] = [];\n let current: string[] = [];\n let start = 0;\n\n chars.forEach((char, index) => {\n if (current.length === 0) start = index;\n current.push(char);\n const next = chars[index + 1];\n // Closes the group when the run of spaces following the word ends:\n // that way the space travels with it and never starts a line on its own.\n const isSpace = /\\s/.test(char);\n if (isSpace && next !== undefined && !/\\s/.test(next)) {\n groups.push({ start, chars: current });\n current = [];\n }\n });\n if (current.length > 0) groups.push({ start, chars: current });\n\n return groups;\n}\n\n/**\n * ShinyText\n * A band of light sweeps across the text from left to right. Without\n * `background-clip: text` or masks —which React Native lacks in the supported\n * range— the shine is composed letter by letter: each one carries its resting\n * color and, overlaid on top, the same letter in the shine color with animated\n * opacity. The band is a moving interpolation window: the only thing that\n * changes per frame is `opacity`, which means the whole animation runs on the\n * native thread and JS takes part in no frame.\n *\n * Gestalt, common fate: the letters light up in sequence and at the same\n * rhythm, so they read as one moving thing —a word lighting up— and not as N\n * letters blinking on their own. That is why the band width is relative to\n * the text and not a fixed number of pixels: a long title and a short one take\n * the same time and are perceived the same.\n *\n * With \"reduce motion\" on, or with `animated={false}`, the text renders flat\n * in `shineColor` —legible, a single node and selectable— because the effect\n * is decorative and nothing it communicates lives in the shine.\n */\nexport const ShinyText = memo(\n ({\n text,\n duration = DEFAULT_DURATION,\n delay = DEFAULT_DELAY,\n spread = DEFAULT_SPREAD,\n animated = true,\n color,\n shineColor,\n align = 'left',\n style,\n testID,\n }: ShinyTextProps) => {\n const theme = useTheme();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const reduceMotion = useReduceMotion();\n const progress = useRef(new Animated.Value(0)).current;\n\n const base = color ?? theme.colors.textLight;\n // The accent, not `text`: against `textLight` the difference is only in\n // luminance and the sweep is barely visible. The shine changes hue as well\n // as lightness, which is what makes the motion readable.\n const shine = shineColor ?? theme.colors.primary;\n const running = animated && !reduceMotion && text.length > 0;\n\n useEffect(() => {\n if (!running) return;\n const loop = Animated.loop(\n Animated.sequence([\n Animated.timing(progress, {\n toValue: 1,\n duration,\n easing: Easing.linear,\n useNativeDriver: true,\n }),\n Animated.delay(delay),\n ])\n );\n loop.start();\n return () => loop.stop();\n }, [running, duration, delay, progress]);\n\n const groups = useMemo(() => (running ? toGroups(text) : []), [running, text]);\n\n if (!running) {\n return (\n <Text\n testID={testID}\n allowFontScaling={allowFontScaling}\n style={[styles.text, style, { color: shine, textAlign: align }]}\n >\n {text}\n </Text>\n );\n }\n\n const total = Array.from(text).length;\n // The band enters on the left and exits on the right, so the normalized\n // path measures 1 + 2·spread. `half` is half a band on that scale: the\n // stretch of progress a letter takes to light up.\n const half = spread / (1 + 2 * spread);\n\n return (\n <View\n testID={testID}\n accessible\n accessibilityRole=\"text\"\n accessibilityLabel={text}\n style={[styles.row, { justifyContent: ALIGN[align] }]}\n >\n {groups.map((group) => (\n <View key={group.start} style={styles.group}>\n {group.chars.map((char, offset) => {\n const index = group.start + offset;\n const position = total === 1 ? 0.5 : index / (total - 1);\n // Progress at which the center of the band falls on this letter.\n const peak = (position + spread) / (1 + 2 * spread);\n const charStyle: StyleProp<TextStyle> = [styles.text, style];\n\n return (\n <View key={index}>\n <Text allowFontScaling={allowFontScaling} style={[charStyle, { color: base }]}>\n {char}\n </Text>\n <Animated.Text\n allowFontScaling={allowFontScaling}\n style={[\n charStyle,\n styles.overlay,\n {\n color: shine,\n opacity: progress.interpolate({\n inputRange: [peak - half, peak, peak + half],\n outputRange: [0, 1, 0],\n extrapolate: 'clamp',\n }),\n },\n ]}\n >\n {char}\n </Animated.Text>\n </View>\n );\n })}\n </View>\n ))}\n </View>\n );\n }\n);\nShinyText.displayName = 'ShinyText';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
package/registry/skeleton.json
CHANGED
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "atoms/Skeleton.tsx",
|
|
12
|
-
"content": "import React, { memo, useEffect, useRef } from 'react';\nimport {\n Animated,\n Easing,\n View,\n type DimensionValue,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useTheme } from '../theme/UIProvider';\n\nexport type SkeletonVariant = 'rect' | 'text' | 'circle';\n\nexport interface SkeletonProps {\n /** @default '100%' ('circle' also uses this as the diameter) */\n width?: DimensionValue;\n /** @default 16 ('text' uses
|
|
12
|
+
"content": "import React, { memo, useEffect, useRef, type ComponentProps } from 'react';\nimport {\n Animated,\n Easing,\n View,\n type DimensionValue,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useTheme } from '../theme/UIProvider';\nimport { useReduceMotion } from './Transition';\n\n/** Half a pulse cycle (opaque → faint). A loading loop, not a transition: it doesn't come from `duration`. */\nconst PULSE_HALF_MS = 700;\n\nexport type SkeletonVariant = 'rect' | 'text' | 'circle';\n\nexport interface SkeletonProps {\n /** Width of the placeholder. @default '100%' ('circle' also uses this as the diameter) */\n width?: DimensionValue;\n /** Height of the placeholder. @default 16 ('text' uses Body's line height; 'circle', the width) */\n height?: DimensionValue;\n /** Shape: a block, a line of text or a circle. @default 'rect' */\n variant?: SkeletonVariant;\n /** Override of the theme's radius. */\n radius?: number;\n /** Stacked lines, only for variant=\"text\". @default 1 */\n lines?: number;\n /** Width of the last line, so the paragraph doesn't end as a solid block. @default '60%' */\n lastLineWidth?: DimensionValue;\n /** Pulses while waiting. Off when the system's Reduce Motion is on. @default true */\n animated?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n block: { backgroundColor: theme.colors.skeleton },\n // Each line takes exactly `lineHeight.md` (Body's): a `fontSize.md` bar\n // plus half the leading above and below. That way the fake paragraph\n // measures the same as the real one and nothing jumps when the content arrives.\n textLine: {\n marginVertical: (theme.tokens.lineHeight.md - theme.tokens.fontSize.md) / 2,\n },\n}));\n\n/**\n * Skeleton\n * Placeholder that pulses while the real content arrives. It reserves the\n * exact space of what it replaces, so the screen doesn't jump when the data\n * shows up — which is precisely what a centered Spinner can't do.\n *\n * Gestalt, similarity: the silhouette repeats the shape and size of the\n * content (a circle for avatars, Body-height lines for text), so the eye\n * already groups the screen before the data arrives. Respects Reduce Motion:\n * no pulse, a static block.\n */\nexport const Skeleton = memo(\n ({\n width = '100%',\n height = 16,\n variant = 'rect',\n radius,\n lines = 1,\n lastLineWidth = '60%',\n animated = true,\n style,\n testID,\n }: SkeletonProps) => {\n const theme = useTheme();\n const styles = useStyles();\n const reduceMotion = useReduceMotion();\n const pulsing = animated && !reduceMotion;\n const pulse = useRef(new Animated.Value(0)).current;\n\n useEffect(() => {\n if (!pulsing) return;\n const loop = Animated.loop(\n Animated.sequence([\n Animated.timing(pulse, {\n toValue: 1,\n duration: PULSE_HALF_MS,\n useNativeDriver: true,\n easing: Easing.inOut(Easing.quad),\n }),\n Animated.timing(pulse, {\n toValue: 0,\n duration: PULSE_HALF_MS,\n useNativeDriver: true,\n easing: Easing.inOut(Easing.quad),\n }),\n ])\n );\n loop.start();\n return () => {\n loop.stop();\n pulse.setValue(0);\n };\n }, [pulsing, pulse]);\n\n const isCircle = variant === 'circle';\n const isText = variant === 'text';\n const opacity = pulsing ? pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 0.4] }) : 1;\n const resolvedHeight = isCircle ? width : isText ? theme.tokens.fontSize.md : height;\n const resolvedRadius =\n radius ?? (isCircle ? theme.tokens.radius.full : isText ? theme.tokens.radius.xs : theme.tokens.radius.sm);\n\n const barStyle = (barWidth: DimensionValue): ComponentProps<typeof Animated.View>['style'] => [\n styles.block,\n isText && styles.textLine,\n { width: barWidth, height: resolvedHeight, borderRadius: resolvedRadius, opacity },\n ];\n\n const a11y = { accessibilityElementsHidden: true, importantForAccessibility: 'no-hide-descendants' as const };\n\n if (isText && lines > 1) {\n return (\n <View style={style} testID={testID} {...a11y}>\n {Array.from({ length: lines }, (_, i) => (\n <Animated.View\n key={i}\n testID={testID && `${testID}-${i}`}\n style={barStyle(i === lines - 1 ? lastLineWidth : width)}\n />\n ))}\n </View>\n );\n }\n\n return <Animated.View style={[barStyle(width), style]} testID={testID} {...a11y} />;\n }\n);\nSkeleton.displayName = 'Skeleton';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
16
|
-
"core"
|
|
16
|
+
"core",
|
|
17
|
+
"transition"
|
|
17
18
|
]
|
|
18
19
|
}
|
package/registry/slides.json
CHANGED
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/Slides.tsx",
|
|
12
|
-
"content": "import React, { memo, useEffect, useRef } from 'react';\nimport {\n Animated,\n Easing,\n ScrollView,\n StyleSheet,\n Text,\n TouchableOpacity,\n View,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useTheme } from '../theme/UIProvider';\nimport { useReduceMotion } from '../atoms/Transition';\nimport { Gradient, type GradientDirection } from '../atoms/Gradient';\nimport { useSlides, type SlidesOrientation } from './useSlides';\n\nexport type { SlidesOrientation } from './useSlides';\nexport type SlidesIndicators = 'dots' | 'numbers' | 'none';\nexport type SlidesIndicatorPlacement = 'overlay' | 'outside';\n\n/** Un color sólido, o dos o más para un degradado (ver `Gradient`). */\nexport type SlideBackground = string | string[];\n\nexport interface SlidesProps<T> {\n data: T[];\n keyExtractor: (item: T, index: number) => string;\n /** Contenido de la slide: imágenes, botones, lo que sea. Se dibuja sobre `background`. */\n renderItem: (item: T, index: number) => React.ReactNode;\n /** Fondo por slide: `'#101014'` pinta plano, `['#004cef', '#00cae1']` degrada. */\n background?: (item: T, index: number) => SlideBackground | undefined;\n /** Dirección del degradado cuando `background` devuelve varios colores. @default 'vertical' */\n gradientDirection?: GradientDirection;\n /** @default 'horizontal' */\n orientation?: SlidesOrientation;\n /** Círculos, círculos numerados, o ninguno. @default 'dots' */\n indicators?: SlidesIndicators;\n /**\n * 'overlay' los apoya sobre la slide, en blanco; 'outside' los saca fuera\n * del área deslizable, con los colores del tema.\n * @default 'overlay'\n */\n indicatorPlacement?: SlidesIndicatorPlacement;\n /** Después de la última vuelve a la primera (y al revés). @default false */\n loop?: boolean;\n /** Avanza solo. Se pausa mientras el dedo arrastra. @default false */\n autoPlay?: boolean;\n /** ms entre avances de `autoPlay`. @default 4000 */\n interval?: number;\n /**\n * Botón para frenar el avance automático. Sólo aparece con `autoPlay`, y va\n * en `true` porque contenido que se mueve solo necesita una forma de\n * pausarlo (WCAG 2.2.2).\n * @default true\n */\n showAutoPlayToggle?: boolean;\n /** Hueco entre slides, en px. En 0 quedan pegadas. @default 0 */\n spacing?: number;\n /** Índice controlado. Sin él, el componente maneja el suyo desde `defaultIndex`. */\n index?: number;\n /** @default 0 */\n defaultIndex?: number;\n onIndexChange?: (index: number) => void;\n /** Alto del área deslizable. @default 240 */\n height?: number;\n /** @default true */\n swipeEnabled?: boolean;\n /** Estilos de la caja visible de cada slide — padding, alineación, radio. */\n slideStyle?: StyleProp<ViewStyle>;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst DOT = 8;\nconst DOT_ACTIVE = 20;\nconst NUMBER_CIRCLE = 24;\nconst TOGGLE = 32;\nconst DEFAULT_HEIGHT = 240;\nconst DEFAULT_INTERVAL = 4000;\n\nconst useStyles = createStyles((theme) => ({\n // Deja redondear el bloque por `style` sin que el degradado se salga.\n root: { overflow: 'hidden' },\n viewport: { flex: 1 },\n // La slide visible es la caja de adentro: con `spacing` la página es más\n // ancha que ella, y el fondo tiene que recortarse acá y no en la página, o\n // el degradado pinta también el hueco.\n slide: { flex: 1, overflow: 'hidden' },\n // Con indicadores fuera y orientación vertical el contenedor es una fila:\n // sin flex la ventana no tiene ancho propio y colapsa a 0.\n viewportInRow: { flex: 1 },\n slideContent: { flex: 1 },\n\n indicatorsRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center' },\n indicatorsColumn: { flexDirection: 'column', alignItems: 'center', justifyContent: 'center' },\n indicatorsOverlayHorizontal: { position: 'absolute', left: 0, right: 0, bottom: theme.tokens.spacing.md },\n indicatorsOverlayVertical: { position: 'absolute', top: 0, bottom: 0, right: theme.tokens.spacing.md },\n indicatorsOutsideHorizontal: { paddingTop: theme.tokens.spacing.md },\n indicatorsOutsideVertical: { paddingLeft: theme.tokens.spacing.md },\n indicatorSlot: { padding: theme.tokens.spacing.xs },\n\n dot: { width: DOT, height: DOT, borderRadius: DOT / 2 },\n\n autoPlayToggle: {\n position: 'absolute',\n top: theme.tokens.spacing.md,\n right: theme.tokens.spacing.md,\n width: TOGGLE,\n height: TOGGLE,\n borderRadius: TOGGLE / 2,\n alignItems: 'center',\n justifyContent: 'center',\n flexDirection: 'row',\n gap: 3,\n },\n pauseBar: { width: 3, height: 12, borderRadius: 1 },\n // Triángulo con bordes: un \"play\" sin SVG ni set de íconos.\n playTriangle: {\n width: 0,\n height: 0,\n marginLeft: 2,\n borderTopWidth: 6,\n borderBottomWidth: 6,\n borderLeftWidth: 10,\n borderTopColor: 'transparent',\n borderBottomColor: 'transparent',\n },\n\n numberCircle: {\n minWidth: NUMBER_CIRCLE,\n height: NUMBER_CIRCLE,\n borderRadius: NUMBER_CIRCLE / 2,\n borderWidth: 1,\n paddingHorizontal: theme.tokens.spacing.xs,\n alignItems: 'center',\n justifyContent: 'center',\n },\n numberText: { fontSize: theme.tokens.fontSize.xs, fontWeight: theme.tokens.fontWeight.semibold },\n\n outsideHorizontal: { flexDirection: 'column' },\n outsideVertical: { flexDirection: 'row' },\n}));\n\ninterface DotProps {\n selected: boolean;\n isHorizontal: boolean;\n /** En overlay el contraste lo da la opacidad; afuera, el color. */\n overlay: boolean;\n activeColor: string;\n inactiveColor: string;\n testID?: string;\n}\n\n/**\n * El punto activo se estira en vez de saltar: el cambio de golpe se lee como\n * \"apareció otro punto\", no como \"el mismo se movió\". Ancho y color son props\n * de layout/pintura, así que van por el driver de JS.\n */\nconst Dot = memo(({ selected, isHorizontal, overlay, activeColor, inactiveColor, testID }: DotProps) => {\n const styles = useStyles();\n const theme = useTheme();\n const reduceMotion = useReduceMotion();\n const progress = useRef(new Animated.Value(selected ? 1 : 0)).current;\n\n useEffect(() => {\n Animated.timing(progress, {\n toValue: selected ? 1 : 0,\n duration: reduceMotion ? 0 : theme.tokens.duration.base,\n easing: Easing.out(Easing.quad),\n useNativeDriver: false,\n }).start();\n }, [progress, reduceMotion, selected, theme]);\n\n const length = progress.interpolate({ inputRange: [0, 1], outputRange: [DOT, DOT_ACTIVE] });\n const backgroundColor = progress.interpolate({ inputRange: [0, 1], outputRange: [inactiveColor, activeColor] });\n\n return (\n <Animated.View\n testID={testID}\n style={[\n styles.dot,\n isHorizontal ? { width: length } : { height: length },\n { backgroundColor },\n overlay && { opacity: progress.interpolate({ inputRange: [0, 1], outputRange: [0.45, 1] }) },\n ]}\n />\n );\n});\nDot.displayName = 'SlidesDot';\n\n/**\n * Slides\n * Carrusel con fondo propio (color plano o degradado) y contenido libre:\n * `renderItem` decide si adentro va una imagen, texto o botones. El estado\n * vive en `useSlides`; acá sólo está el árbol.\n *\n * El deslizamiento es un `ScrollView` con `pagingEnabled` en vez de gestos a\n * mano: es lo único que da snap nativo en iOS y Android sin\n * `react-native-gesture-handler`, en todo el rango soportado (Expo SDK 54–57).\n */\nexport function Slides<T>({\n data,\n keyExtractor,\n renderItem,\n background,\n gradientDirection = 'vertical',\n orientation = 'horizontal',\n indicators = 'dots',\n indicatorPlacement = 'overlay',\n loop = false,\n autoPlay = false,\n interval = DEFAULT_INTERVAL,\n showAutoPlayToggle = true,\n spacing = 0,\n index,\n defaultIndex = 0,\n onIndexChange,\n height = DEFAULT_HEIGHT,\n swipeEnabled = true,\n slideStyle,\n style,\n testID,\n}: SlidesProps<T>) {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n\n const total = data.length;\n const {\n active,\n pages,\n isHorizontal,\n size,\n pageSize,\n scrollRef,\n autoPlayPaused,\n motionReduced,\n goTo,\n toggleAutoPlay,\n onLayout,\n onMomentumScrollEnd,\n onScrollBeginDrag,\n onScrollEndDrag,\n } = useSlides({ total, orientation, loop, autoPlay, interval, index, defaultIndex, onIndexChange });\n\n const isOverlay = indicatorPlacement === 'overlay';\n\n const renderBackground = (item: T, real: number) => {\n const value = background?.(item, real);\n if (!value) return null;\n if (Array.isArray(value)) {\n return <Gradient colors={value} direction={gradientDirection} style={StyleSheet.absoluteFill} />;\n }\n return <View style={[StyleSheet.absoluteFill, { backgroundColor: value }]} pointerEvents=\"none\" />;\n };\n\n const renderIndicator = (position: number) => {\n const selected = position === active;\n\n if (indicators === 'numbers') {\n const numberColors = isOverlay\n ? {\n background: selected ? colors.white : 'transparent',\n border: colors.white,\n text: selected ? colors.black : colors.white,\n }\n : {\n background: selected ? colors.primary : 'transparent',\n border: selected ? colors.primary : colors.border,\n text: selected ? colors.onPrimary : colors.textLight,\n };\n return (\n <View\n style={[\n styles.numberCircle,\n { backgroundColor: numberColors.background, borderColor: numberColors.border },\n ]}\n >\n <Text allowFontScaling={allowFontScaling} style={[styles.numberText, { color: numberColors.text }]}>\n {position + 1}\n </Text>\n </View>\n );\n }\n\n return (\n <Dot\n testID={`${testID ?? 'slides'}-dot-${position}`}\n selected={selected}\n isHorizontal={isHorizontal}\n overlay={isOverlay}\n activeColor={isOverlay ? colors.white : colors.primary}\n inactiveColor={isOverlay ? colors.white : colors.border}\n />\n );\n };\n\n // Con reduce-motion el autoPlay ya está apagado: no hay nada que pausar.\n const renderAutoPlayToggle = () => {\n if (!autoPlay || !showAutoPlayToggle || motionReduced || total < 2) return null;\n return (\n <TouchableOpacity\n testID={`${testID ?? 'slides'}-autoplay-toggle`}\n style={[styles.autoPlayToggle, { backgroundColor: colors.black, opacity: 0.55 }]}\n activeOpacity={0.7}\n onPress={toggleAutoPlay}\n accessibilityRole=\"button\"\n accessibilityLabel={autoPlayPaused ? 'Resume automatic slides' : 'Pause automatic slides'}\n accessibilityState={{ selected: !autoPlayPaused }}\n >\n {autoPlayPaused ? (\n <View style={[styles.playTriangle, { borderLeftColor: colors.white }]} />\n ) : (\n <>\n <View style={[styles.pauseBar, { backgroundColor: colors.white }]} />\n <View style={[styles.pauseBar, { backgroundColor: colors.white }]} />\n </>\n )}\n </TouchableOpacity>\n );\n };\n\n const renderIndicators = () => {\n if (indicators === 'none' || total < 2) return null;\n return (\n <View\n testID={`${testID ?? 'slides'}-indicators`}\n style={[\n isHorizontal ? styles.indicatorsRow : styles.indicatorsColumn,\n isOverlay\n ? isHorizontal\n ? styles.indicatorsOverlayHorizontal\n : styles.indicatorsOverlayVertical\n : isHorizontal\n ? styles.indicatorsOutsideHorizontal\n : styles.indicatorsOutsideVertical,\n ]}\n pointerEvents=\"box-none\"\n >\n {data.map((item, position) => (\n <TouchableOpacity\n key={`indicator-${keyExtractor(item, position)}`}\n testID={`${testID ?? 'slides'}-indicator-${position}`}\n style={styles.indicatorSlot}\n activeOpacity={0.7}\n onPress={() => goTo(position)}\n accessibilityRole=\"button\"\n accessibilityLabel={`Slide ${position + 1} of ${total}`}\n accessibilityState={{ selected: position === active }}\n >\n {renderIndicator(position)}\n </TouchableOpacity>\n ))}\n </View>\n );\n };\n\n const viewport = (\n <View\n testID={testID}\n style={[styles.root, { height }, !isOverlay && !isHorizontal && styles.viewportInRow, isOverlay && style]}\n onLayout={onLayout}\n >\n <ScrollView\n ref={scrollRef}\n testID={`${testID ?? 'slides'}-scroll`}\n style={styles.viewport}\n horizontal={isHorizontal}\n pagingEnabled\n scrollEnabled={swipeEnabled && total > 1}\n showsHorizontalScrollIndicator={false}\n showsVerticalScrollIndicator={false}\n onMomentumScrollEnd={onMomentumScrollEnd}\n onScrollBeginDrag={onScrollBeginDrag}\n onScrollEndDrag={onScrollEndDrag}\n >\n {pageSize > 0 &&\n pages.map(({ real, clone }) => {\n const item = data[real] as T;\n return (\n // El hueco se reparte a los dos lados de cada página: así la\n // separación entre dos slides es `spacing` entero y la página\n // sigue midiendo exactamente lo que pagina el ScrollView.\n <View\n key={clone ? `slides-clone-${clone}` : keyExtractor(item, real)}\n style={[\n { width: size.width, height: size.height },\n isHorizontal ? { paddingHorizontal: spacing / 2 } : { paddingVertical: spacing / 2 },\n ]}\n >\n <View style={[styles.slide, slideStyle]}>\n {renderBackground(item, real)}\n <View style={styles.slideContent}>{renderItem(item, real)}</View>\n </View>\n </View>\n );\n })}\n </ScrollView>\n {renderAutoPlayToggle()}\n {isOverlay && renderIndicators()}\n </View>\n );\n\n if (isOverlay) return viewport;\n\n return (\n <View style={[isHorizontal ? styles.outsideHorizontal : styles.outsideVertical, style]}>\n {viewport}\n {renderIndicators()}\n </View>\n );\n}\n"
|
|
12
|
+
"content": "import React, { memo, useEffect, useRef } from 'react';\nimport {\n Animated,\n Easing,\n ScrollView,\n StyleSheet,\n Text,\n TouchableOpacity,\n View,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useLabels, useTheme } from '../theme/UIProvider';\nimport { useReduceMotion } from '../atoms/Transition';\nimport { Gradient, type GradientDirection } from '../atoms/Gradient';\nimport { useSlides, type SlidesOrientation } from './useSlides';\n\nexport type { SlidesOrientation } from './useSlides';\nexport type SlidesIndicators = 'dots' | 'numbers' | 'none';\nexport type SlidesIndicatorPlacement = 'overlay' | 'outside';\n\n/** A solid color, or two or more for a gradient (see `Gradient`). */\nexport type SlideBackground = string | string[];\n\nexport interface SlidesProps<T> {\n /** One item per slide. */\n data: T[];\n /** Stable key per item. */\n keyExtractor: (item: T, index: number) => string;\n /** Slide content: images, buttons, anything. Drawn on top of `background`. */\n renderItem: (item: T, index: number) => React.ReactNode;\n /** Per-slide background: `'#101014'` paints flat, `['#004cef', '#00cae1']` paints a gradient. */\n background?: (item: T, index: number) => SlideBackground | undefined;\n /** Gradient direction when `background` returns several colors. @default 'vertical' */\n gradientDirection?: GradientDirection;\n /** Direction of the swipe. @default 'horizontal' */\n orientation?: SlidesOrientation;\n /** Dots, numbered dots, or none. @default 'dots' */\n indicators?: SlidesIndicators;\n /**\n * 'overlay' places them on the slide, in white; 'outside' moves them out of\n * the swipeable area, in theme colors.\n * @default 'overlay'\n */\n indicatorPlacement?: SlidesIndicatorPlacement;\n /** After the last slide, wraps to the first (and vice versa). @default false */\n loop?: boolean;\n /** Advances automatically. Pauses while a finger is dragging. @default false */\n autoPlay?: boolean;\n /** Milliseconds between `autoPlay` advances. @default 4000 */\n interval?: number;\n /**\n * Button that pauses auto-advance. Only shown with `autoPlay`; on by default\n * because content that moves on its own needs a way to pause it\n * (WCAG 2.2.2).\n * @default true\n */\n showAutoPlayToggle?: boolean;\n /** Gap between slides, in px. At 0 they touch. @default 0 */\n spacing?: number;\n /** Controlled index. Without it, the component manages its own from `defaultIndex`. */\n index?: number;\n /** Starting slide when the index is uncontrolled. @default 0 */\n defaultIndex?: number;\n /** Called with the new index when the active slide changes. */\n onIndexChange?: (index: number) => void;\n /** Height of the swipeable area. @default 240 */\n height?: number;\n /**\n * Lets the user swipe between slides. When false, they change only through the indicators,\n * `autoPlay` or `index`. @default true\n */\n swipeEnabled?: boolean;\n /** Styles for each slide's visible box — padding, alignment, radius. */\n slideStyle?: StyleProp<ViewStyle>;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst DOT = 8;\nconst DOT_ACTIVE = 20;\nconst NUMBER_CIRCLE = 24;\nconst TOGGLE = 32;\nconst DEFAULT_HEIGHT = 240;\nconst DEFAULT_INTERVAL = 4000;\n\nconst useStyles = createStyles((theme) => ({\n // Lets the block be rounded via `style` without the gradient spilling out.\n root: { overflow: 'hidden' },\n viewport: { flex: 1 },\n // The visible slide is the inner box: with `spacing` the page is wider\n // than it, and the background has to be clipped here and not on the page, or\n // the gradient paints the gap too.\n slide: { flex: 1, overflow: 'hidden' },\n // With indicators outside and vertical orientation the container is a row:\n // without flex the window has no width of its own and collapses to 0.\n viewportInRow: { flex: 1 },\n slideContent: { flex: 1 },\n\n indicatorsRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center' },\n indicatorsColumn: { flexDirection: 'column', alignItems: 'center', justifyContent: 'center' },\n indicatorsOverlayHorizontal: { position: 'absolute', left: 0, right: 0, bottom: theme.tokens.spacing.md },\n indicatorsOverlayVertical: { position: 'absolute', top: 0, bottom: 0, right: theme.tokens.spacing.md },\n indicatorsOutsideHorizontal: { paddingTop: theme.tokens.spacing.md },\n indicatorsOutsideVertical: { paddingLeft: theme.tokens.spacing.md },\n indicatorSlot: { padding: theme.tokens.spacing.xs },\n\n dot: { width: DOT, height: DOT, borderRadius: DOT / 2 },\n\n autoPlayToggle: {\n position: 'absolute',\n top: theme.tokens.spacing.md,\n right: theme.tokens.spacing.md,\n width: TOGGLE,\n height: TOGGLE,\n borderRadius: TOGGLE / 2,\n alignItems: 'center',\n justifyContent: 'center',\n flexDirection: 'row',\n gap: 3,\n },\n pauseBar: { width: 3, height: 12, borderRadius: 1 },\n // Triangle made of borders: a \"play\" without SVG or an icon set.\n playTriangle: {\n width: 0,\n height: 0,\n marginLeft: 2,\n borderTopWidth: 6,\n borderBottomWidth: 6,\n borderLeftWidth: 10,\n borderTopColor: 'transparent',\n borderBottomColor: 'transparent',\n },\n\n numberCircle: {\n minWidth: NUMBER_CIRCLE,\n height: NUMBER_CIRCLE,\n borderRadius: NUMBER_CIRCLE / 2,\n borderWidth: 1,\n paddingHorizontal: theme.tokens.spacing.xs,\n alignItems: 'center',\n justifyContent: 'center',\n },\n numberText: { fontSize: theme.tokens.fontSize.xs, fontWeight: theme.tokens.fontWeight.semibold },\n\n outsideHorizontal: { flexDirection: 'column' },\n outsideVertical: { flexDirection: 'row' },\n}));\n\ninterface DotProps {\n selected: boolean;\n isHorizontal: boolean;\n /** In overlay the contrast comes from opacity; outside, from color. */\n overlay: boolean;\n activeColor: string;\n inactiveColor: string;\n testID?: string;\n}\n\n/**\n * The active dot stretches instead of jumping: an abrupt change reads as\n * \"another dot appeared\", not as \"the same one moved\". Width and color are\n * layout/paint props, so they go through the JS driver.\n */\nconst Dot = memo(({ selected, isHorizontal, overlay, activeColor, inactiveColor, testID }: DotProps) => {\n const styles = useStyles();\n const theme = useTheme();\n const reduceMotion = useReduceMotion();\n const progress = useRef(new Animated.Value(selected ? 1 : 0)).current;\n\n useEffect(() => {\n Animated.timing(progress, {\n toValue: selected ? 1 : 0,\n duration: reduceMotion ? 0 : theme.tokens.duration.base,\n easing: Easing.out(Easing.quad),\n useNativeDriver: false,\n }).start();\n }, [progress, reduceMotion, selected, theme]);\n\n const length = progress.interpolate({ inputRange: [0, 1], outputRange: [DOT, DOT_ACTIVE] });\n const backgroundColor = progress.interpolate({ inputRange: [0, 1], outputRange: [inactiveColor, activeColor] });\n\n return (\n <Animated.View\n testID={testID}\n style={[\n styles.dot,\n isHorizontal ? { width: length } : { height: length },\n { backgroundColor },\n overlay && { opacity: progress.interpolate({ inputRange: [0, 1], outputRange: [0.45, 1] }) },\n ]}\n />\n );\n});\nDot.displayName = 'SlidesDot';\n\n/**\n * Slides\n * Carousel with its own background (flat color or gradient) and free-form\n * content: `renderItem` decides whether an image, text or buttons go inside.\n * The state lives in `useSlides`; only the tree is here.\n *\n * Sliding is a `ScrollView` with `pagingEnabled` instead of hand-made\n * gestures: it is the only thing that gives native snapping on iOS and\n * Android without `react-native-gesture-handler`, across the whole supported\n * range of Expo SDKs.\n *\n * Design:\n * - Jakob's law: native paging swipe and dots under the slide, like the\n * system's own onboarding carousels.\n * - Similarity + Von Restorff: every indicator looks the same except the\n * current one.\n * - Continuity: the slides slide (a jump with Reduce Motion, and autoPlay\n * stops); infinite mode clones the edges so the swipe never rewinds.\n * - Fitts's law: indicators have padding around the dot as touch area.\n * - Screen readers: each indicator is \"Slide n of total\" (`labels.slideOf`),\n * and autoPlay can be paused from its own button.\n */\nexport function Slides<T>({\n data,\n keyExtractor,\n renderItem,\n background,\n gradientDirection = 'vertical',\n orientation = 'horizontal',\n indicators = 'dots',\n indicatorPlacement = 'overlay',\n loop = false,\n autoPlay = false,\n interval = DEFAULT_INTERVAL,\n showAutoPlayToggle = true,\n spacing = 0,\n index,\n defaultIndex = 0,\n onIndexChange,\n height = DEFAULT_HEIGHT,\n swipeEnabled = true,\n slideStyle,\n style,\n testID,\n}: SlidesProps<T>) {\n const colors = useColors();\n const labels = useLabels();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n\n const total = data.length;\n const {\n active,\n pages,\n isHorizontal,\n size,\n pageSize,\n scrollRef,\n autoPlayPaused,\n motionReduced,\n goTo,\n toggleAutoPlay,\n onLayout,\n onMomentumScrollEnd,\n onScrollBeginDrag,\n onScrollEndDrag,\n } = useSlides({ total, orientation, loop, autoPlay, interval, index, defaultIndex, onIndexChange });\n\n const isOverlay = indicatorPlacement === 'overlay';\n\n const renderBackground = (item: T, real: number) => {\n const value = background?.(item, real);\n if (!value) return null;\n if (Array.isArray(value)) {\n return <Gradient colors={value} direction={gradientDirection} style={StyleSheet.absoluteFill} />;\n }\n return <View style={[StyleSheet.absoluteFill, { backgroundColor: value }]} pointerEvents=\"none\" />;\n };\n\n const renderIndicator = (position: number) => {\n const selected = position === active;\n\n if (indicators === 'numbers') {\n const numberColors = isOverlay\n ? {\n background: selected ? colors.white : 'transparent',\n border: colors.white,\n text: selected ? colors.black : colors.white,\n }\n : {\n background: selected ? colors.primary : 'transparent',\n border: selected ? colors.primary : colors.border,\n text: selected ? colors.onPrimary : colors.textLight,\n };\n return (\n <View\n style={[\n styles.numberCircle,\n { backgroundColor: numberColors.background, borderColor: numberColors.border },\n ]}\n >\n <Text allowFontScaling={allowFontScaling} style={[styles.numberText, { color: numberColors.text }]}>\n {position + 1}\n </Text>\n </View>\n );\n }\n\n return (\n <Dot\n testID={`${testID ?? 'slides'}-dot-${position}`}\n selected={selected}\n isHorizontal={isHorizontal}\n overlay={isOverlay}\n activeColor={isOverlay ? colors.white : colors.primary}\n inactiveColor={isOverlay ? colors.white : colors.border}\n />\n );\n };\n\n // With reduce-motion autoPlay is already off: there is nothing to pause.\n const renderAutoPlayToggle = () => {\n if (!autoPlay || !showAutoPlayToggle || motionReduced || total < 2) return null;\n return (\n <TouchableOpacity\n testID={`${testID ?? 'slides'}-autoplay-toggle`}\n style={[styles.autoPlayToggle, { backgroundColor: colors.black, opacity: 0.55 }]}\n activeOpacity={0.7}\n onPress={toggleAutoPlay}\n accessibilityRole=\"button\"\n accessibilityLabel={autoPlayPaused ? labels.resumeSlides : labels.pauseSlides}\n accessibilityState={{ selected: !autoPlayPaused }}\n >\n {autoPlayPaused ? (\n <View style={[styles.playTriangle, { borderLeftColor: colors.white }]} />\n ) : (\n <>\n <View style={[styles.pauseBar, { backgroundColor: colors.white }]} />\n <View style={[styles.pauseBar, { backgroundColor: colors.white }]} />\n </>\n )}\n </TouchableOpacity>\n );\n };\n\n const renderIndicators = () => {\n if (indicators === 'none' || total < 2) return null;\n return (\n <View\n testID={`${testID ?? 'slides'}-indicators`}\n style={[\n isHorizontal ? styles.indicatorsRow : styles.indicatorsColumn,\n isOverlay\n ? isHorizontal\n ? styles.indicatorsOverlayHorizontal\n : styles.indicatorsOverlayVertical\n : isHorizontal\n ? styles.indicatorsOutsideHorizontal\n : styles.indicatorsOutsideVertical,\n ]}\n pointerEvents=\"box-none\"\n >\n {data.map((item, position) => (\n <TouchableOpacity\n key={`indicator-${keyExtractor(item, position)}`}\n testID={`${testID ?? 'slides'}-indicator-${position}`}\n style={styles.indicatorSlot}\n activeOpacity={0.7}\n onPress={() => goTo(position)}\n accessibilityRole=\"button\"\n accessibilityLabel={labels.slideOf.replace('{n}', String(position + 1)).replace('{total}', String(total))}\n accessibilityState={{ selected: position === active }}\n >\n {renderIndicator(position)}\n </TouchableOpacity>\n ))}\n </View>\n );\n };\n\n const viewport = (\n <View\n testID={testID}\n style={[styles.root, { height }, !isOverlay && !isHorizontal && styles.viewportInRow, isOverlay && style]}\n onLayout={onLayout}\n >\n <ScrollView\n ref={scrollRef}\n testID={`${testID ?? 'slides'}-scroll`}\n style={styles.viewport}\n horizontal={isHorizontal}\n pagingEnabled\n scrollEnabled={swipeEnabled && total > 1}\n showsHorizontalScrollIndicator={false}\n showsVerticalScrollIndicator={false}\n onMomentumScrollEnd={onMomentumScrollEnd}\n onScrollBeginDrag={onScrollBeginDrag}\n onScrollEndDrag={onScrollEndDrag}\n >\n {pageSize > 0 &&\n pages.map(({ real, clone }) => {\n const item = data[real] as T;\n return (\n // The gap is split on both sides of each page: that way the\n // separation between two slides is a full `spacing` and the page\n // still measures exactly what the ScrollView pages by.\n <View\n key={clone ? `slides-clone-${clone}` : keyExtractor(item, real)}\n style={[\n { width: size.width, height: size.height },\n isHorizontal ? { paddingHorizontal: spacing / 2 } : { paddingVertical: spacing / 2 },\n ]}\n >\n <View style={[styles.slide, slideStyle]}>\n {renderBackground(item, real)}\n <View style={styles.slideContent}>{renderItem(item, real)}</View>\n </View>\n </View>\n );\n })}\n </ScrollView>\n {renderAutoPlayToggle()}\n {isOverlay && renderIndicators()}\n </View>\n );\n\n if (isOverlay) return viewport;\n\n return (\n <View style={[isHorizontal ? styles.outsideHorizontal : styles.outsideVertical, style]}>\n {viewport}\n {renderIndicators()}\n </View>\n );\n}\n"
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
"path": "organisms/useSlides.ts",
|
|
16
|
-
"content": "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ScrollView } from 'react-native';\nimport { useReduceMotion } from '../atoms/Transition';\n\nexport type SlidesOrientation = 'horizontal' | 'vertical';\n\nexport interface UseSlidesOptions {\n total: number;\n orientation: SlidesOrientation;\n loop: boolean;\n autoPlay: boolean;\n interval: number;\n /**
|
|
16
|
+
"content": "import { useCallback, useEffect, useMemo, useRef, useState, type ComponentRef } from 'react';\nimport type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ScrollView } from 'react-native';\nimport { useReduceMotion } from '../atoms/Transition';\n\nexport type SlidesOrientation = 'horizontal' | 'vertical';\n\nexport interface UseSlidesOptions {\n total: number;\n orientation: SlidesOrientation;\n loop: boolean;\n autoPlay: boolean;\n interval: number;\n /** Controlled index; without it the internal state rules. */\n index?: number;\n defaultIndex: number;\n onIndexChange?: (index: number) => void;\n}\n\n/** A page of the scroll. The infinite mode's clones repeat a neighbor's `real`. */\nexport interface SlidesPage {\n real: number;\n clone: 'head' | 'tail' | null;\n}\n\nexport interface UseSlidesResult {\n active: number;\n pages: SlidesPage[];\n isHorizontal: boolean;\n isLoop: boolean;\n size: { width: number; height: number };\n /** Length of a page along the scroll axis. At 0 it hasn't been measured yet. */\n pageSize: number;\n scrollRef: React.RefObject<ComponentRef<typeof ScrollView> | null>;\n autoPlayPaused: boolean;\n /** The system asked to reduce motion: no autoPlay or transitions. */\n motionReduced: boolean;\n goTo: (index: number) => void;\n toggleAutoPlay: () => void;\n onLayout: (event: LayoutChangeEvent) => void;\n onMomentumScrollEnd: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;\n onScrollBeginDrag: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;\n onScrollEndDrag: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;\n}\n\nexport function clampIndex(value: number, total: number): number {\n if (total === 0) return 0;\n if (value < 0) return 0;\n if (value > total - 1) return total - 1;\n return value;\n}\n\n/**\n * useSlides\n * Everything in `Slides` that isn't visible: which page is framed, when to\n * move the scroll, how the infinite mode's clones map to real indexes, and the\n * autoplay clock.\n *\n * It works only with indexes, never with the items: that way it doesn't need\n * the component's generic `T`, and the UI resolves `pages[i].real` against its own `data`.\n */\n/** Points a finger has to move the slides for the drag to count as a swipe. */\nconst DRAG_SLOP = 10;\n\nexport function useSlides({\n total,\n orientation,\n loop,\n autoPlay,\n interval,\n index,\n defaultIndex,\n onIndexChange,\n}: UseSlidesOptions): UseSlidesResult {\n const motionReduced = useReduceMotion();\n const isHorizontal = orientation === 'horizontal';\n // A single element has nowhere to cycle to: it would be swipeable between\n // three copies of the same thing.\n const isLoop = loop && total > 1;\n\n const [uncontrolled, setUncontrolled] = useState(() => clampIndex(defaultIndex, total));\n const active = clampIndex(index ?? uncontrolled, total);\n const activeRef = useRef(active);\n activeRef.current = active;\n\n const [autoPlayPaused, setAutoPlayPaused] = useState(false);\n const [size, setSize] = useState({ width: 0, height: 0 });\n const pageSize = isHorizontal ? size.width : size.height;\n\n const scrollRef = useRef<ComponentRef<typeof ScrollView>>(null);\n /** The page actually visible (index within `pages`, clones included). */\n const offsetPageRef = useRef(isLoop ? 1 : 0);\n const didInitRef = useRef(false);\n const lastPageSizeRef = useRef(0);\n const draggingRef = useRef(false);\n /**\n * Page an animated `scrollTo` was sent to and hasn't reached yet. On iOS a\n * touch (tapping another indicator) stops that animation midway: the\n * scroll then ends on the wrong page, and committing it undid the tap.\n */\n const pendingPageRef = useRef<number | null>(null);\n const dragStartRef = useRef(0);\n\n const pages = useMemo<SlidesPage[]>(() => {\n const real = Array.from({ length: total }, (_, i) => ({ real: i, clone: null }));\n if (!isLoop) return real;\n return [{ real: total - 1, clone: 'head' }, ...real, { real: 0, clone: 'tail' }];\n }, [isLoop, total]);\n\n const scrollToPage = useCallback(\n (page: number, animated: boolean) => {\n const offset = page * pageSize;\n scrollRef.current?.scrollTo({\n x: isHorizontal ? offset : 0,\n y: isHorizontal ? 0 : offset,\n animated,\n });\n },\n [isHorizontal, pageSize]\n );\n\n const goTo = useCallback(\n (next: number) => {\n if (next === activeRef.current) return;\n activeRef.current = next;\n if (index == null) setUncontrolled(next);\n onIndexChange?.(next);\n },\n [index, onIndexChange]\n );\n\n // Only acts when the index and the visible page disagree: a change that\n // came from the gesture itself doesn't fire a second scroll on top of the one that stopped.\n useEffect(() => {\n if (pageSize === 0 || total === 0) return;\n const target = isLoop ? active + 1 : active;\n const resized = lastPageSizeRef.current !== pageSize;\n if (offsetPageRef.current === target && didInitRef.current && !resized) return;\n // Neither the first positioning nor the rearrangement on rotation is a\n // slide change: animating them would be a slide nobody asked for.\n const animated = didInitRef.current && !resized;\n didInitRef.current = true;\n lastPageSizeRef.current = pageSize;\n offsetPageRef.current = target;\n pendingPageRef.current = animated ? target : null;\n scrollToPage(target, animated);\n }, [active, isLoop, pageSize, scrollToPage, total]);\n\n const onLayout = useCallback((event: LayoutChangeEvent) => {\n const { width, height } = event.nativeEvent.layout;\n setSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height }));\n }, []);\n\n const onMomentumScrollEnd = useCallback(\n (event: NativeSyntheticEvent<NativeScrollEvent>) => {\n draggingRef.current = false;\n if (pageSize === 0) return;\n const { contentOffset } = event.nativeEvent;\n const page = Math.round((isHorizontal ? contentOffset.x : contentOffset.y) / pageSize);\n // A scrollTo still on its way: if it stopped short (a touch interrupted\n // it), finish the trip instead of committing where it stopped.\n const pending = pendingPageRef.current;\n if (pending !== null) {\n if (page !== pending) {\n scrollToPage(pending, true);\n return;\n }\n pendingPageRef.current = null;\n }\n\n if (isLoop) {\n if (page === 0) {\n offsetPageRef.current = total;\n scrollToPage(total, false);\n goTo(total - 1);\n return;\n }\n if (page === total + 1) {\n offsetPageRef.current = 1;\n scrollToPage(1, false);\n goTo(0);\n return;\n }\n offsetPageRef.current = page;\n goTo(page - 1);\n return;\n }\n\n offsetPageRef.current = page;\n goTo(clampIndex(page, total));\n },\n [goTo, isHorizontal, isLoop, pageSize, scrollToPage, total]\n );\n\n const onScrollBeginDrag = useCallback(\n (event: NativeSyntheticEvent<NativeScrollEvent>) => {\n draggingRef.current = true;\n const { contentOffset } = event.nativeEvent;\n dragStartRef.current = isHorizontal ? contentOffset.x : contentOffset.y;\n },\n [isHorizontal]\n );\n\n const onScrollEndDrag = useCallback(\n (event: NativeSyntheticEvent<NativeScrollEvent>) => {\n // A short drag can be released without momentum and not emit\n // momentumScrollEnd: without this autoPlay would stay paused forever.\n draggingRef.current = false;\n // A finger that really moved the slides takes over from a pending\n // scrollTo; a tap (no movement) doesn't.\n const { contentOffset } = event.nativeEvent;\n const moved = Math.abs((isHorizontal ? contentOffset.x : contentOffset.y) - dragStartRef.current);\n if (moved > DRAG_SLOP) pendingPageRef.current = null;\n },\n [isHorizontal]\n );\n\n const toggleAutoPlay = useCallback(() => setAutoPlayPaused((paused) => !paused), []);\n\n useEffect(() => {\n if (!autoPlay || autoPlayPaused || motionReduced || total < 2 || pageSize === 0) return;\n const timer = setInterval(() => {\n if (draggingRef.current) return;\n const from = activeRef.current;\n if (from < total - 1) {\n goTo(from + 1);\n return;\n }\n if (!isLoop) return;\n // Last one in infinite mode: advances to the clone of the first instead\n // of rewinding; momentumScrollEnd completes the jump to the original.\n offsetPageRef.current = total + 1;\n pendingPageRef.current = total + 1;\n scrollToPage(total + 1, true);\n }, interval);\n return () => clearInterval(timer);\n }, [autoPlay, autoPlayPaused, goTo, interval, isLoop, motionReduced, pageSize, scrollToPage, total]);\n\n return {\n active,\n pages,\n isHorizontal,\n isLoop,\n size,\n pageSize,\n scrollRef,\n autoPlayPaused,\n motionReduced,\n goTo,\n toggleAutoPlay,\n onLayout,\n onMomentumScrollEnd,\n onScrollBeginDrag,\n onScrollEndDrag,\n };\n}\n"
|
|
17
17
|
}
|
|
18
18
|
],
|
|
19
19
|
"registryDependencies": [
|
package/registry/spinner.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "atoms/Spinner.tsx",
|
|
12
|
-
"content": "import React, { memo, useEffect, useRef } from 'react';\nimport { ActivityIndicator, Animated, Easing, Text, View } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\n\nexport type SpinnerVariant = 'native' | 'dots' | 'ring';\n\nexport interface SpinnerProps {\n size?: 'small' | 'large';\n color?: string;\n text?: string;\n /** @default true. When false, doesn't take up flex:1 (useful inline, inside a button/row). */\n fullscreen?: boolean;\n /**\n * 'native' uses the system's ActivityIndicator; 'dots' and 'ring' are\n * custom indicators, identical on iOS and Android.\n * @default 'native'\n */\n variant?: SpinnerVariant;\n /** Fully custom indicator. Takes precedence over `variant`. */\n indicator?: React.ReactNode;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { justifyContent: 'center', alignItems: 'center', padding: theme.tokens.spacing.xl },\n text: { marginTop: theme.tokens.spacing.md, color: theme.colors.textLight, fontSize: theme.tokens.fontSize.md },\n dots: { flexDirection: 'row', gap: theme.tokens.spacing.sm },\n}));\n\n/**
|
|
12
|
+
"content": "import React, { memo, useEffect, useRef } from 'react';\nimport { ActivityIndicator, Animated, Easing, Text, View } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useLabels } from '../theme/UIProvider';\n\nexport type SpinnerVariant = 'native' | 'dots' | 'ring';\n\nexport interface SpinnerProps {\n /** Indicator size; also scales the `dots` and `ring` variants. @default 'large' */\n size?: 'small' | 'large';\n /** Indicator color. @default colors.primary */\n color?: string;\n /** Message under the indicator. Also what screen readers announce. */\n text?: string;\n /** @default true. When false, doesn't take up flex:1 (useful inline, inside a button/row). */\n fullscreen?: boolean;\n /**\n * 'native' uses the system's ActivityIndicator; 'dots' and 'ring' are\n * custom indicators, identical on iOS and Android.\n * @default 'native'\n */\n variant?: SpinnerVariant;\n /** Fully custom indicator. Takes precedence over `variant`. */\n indicator?: React.ReactNode;\n /**\n * Shown above the indicator — typically the app logo on a splash or\n * first-load screen (an `<Image>`, an `<Avatar>`, any node).\n */\n logo?: React.ReactNode;\n /** What screen readers announce. @default text ?? labels.loading */\n accessibilityLabel?: string;\n testID?: string;\n}\n\n// Rhythm of the looping indicators. They are not UI transitions (those use\n// theme.tokens.duration): they are the pulse of a continuous animation.\nconst DOT_PULSE_MS = 320;\nconst DOT_STAGGER_MS = 140;\nconst RING_TURN_MS = 900;\n\nconst useStyles = createStyles((theme) => ({\n container: { justifyContent: 'center', alignItems: 'center', padding: theme.tokens.spacing.xl },\n text: { marginTop: theme.tokens.spacing.md, color: theme.colors.textLight, fontSize: theme.tokens.fontSize.md },\n logo: { marginBottom: theme.tokens.spacing.xl },\n dots: { flexDirection: 'row', gap: theme.tokens.spacing.sm },\n}));\n\n/** Three dots that pulse in sequence. Animated + useNativeDriver: runs on the native thread. */\nconst DotsIndicator = memo(({ color, dotSize }: { color: string; dotSize: number }) => {\n const dots = [useRef(new Animated.Value(0)).current, useRef(new Animated.Value(0)).current, useRef(new Animated.Value(0)).current];\n\n useEffect(() => {\n const animations = dots.map((dot, i) =>\n Animated.loop(\n Animated.sequence([\n Animated.delay(i * DOT_STAGGER_MS),\n Animated.timing(dot, { toValue: 1, duration: DOT_PULSE_MS, useNativeDriver: true, easing: Easing.out(Easing.quad) }),\n Animated.timing(dot, { toValue: 0, duration: DOT_PULSE_MS, useNativeDriver: true, easing: Easing.in(Easing.quad) }),\n Animated.delay((2 - i) * DOT_STAGGER_MS),\n ])\n )\n );\n animations.forEach((a) => a.start());\n // Without stop() the loops keep running after unmount and keep the\n // Animated tree alive.\n return () => animations.forEach((a) => a.stop());\n }, []);\n\n return (\n <View style={{ flexDirection: 'row', gap: dotSize * 0.6 }}>\n {dots.map((dot, i) => (\n <Animated.View\n key={i}\n style={{\n width: dotSize,\n height: dotSize,\n borderRadius: dotSize / 2,\n backgroundColor: color,\n opacity: dot.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }),\n transform: [{ scale: dot.interpolate({ inputRange: [0, 1], outputRange: [0.75, 1] }) }],\n }}\n />\n ))}\n </View>\n );\n});\nDotsIndicator.displayName = 'DotsIndicator';\n\n/**\n * Spinning ring: a faint fixed track and, on top, an arc (a single side of\n * the border) that spins. The track uses `opacity` instead of appending alpha\n * to the color (`color + '33'`), which only worked with 6-digit hex and broke\n * with `rgb()` or color names.\n */\nconst RingIndicator = memo(({ color, diameter }: { color: string; diameter: number }) => {\n const spin = useRef(new Animated.Value(0)).current;\n\n useEffect(() => {\n const animation = Animated.loop(\n Animated.timing(spin, { toValue: 1, duration: RING_TURN_MS, useNativeDriver: true, easing: Easing.linear })\n );\n animation.start();\n return () => animation.stop();\n }, []);\n\n const ring = { width: diameter, height: diameter, borderRadius: diameter / 2, borderWidth: Math.max(2, diameter / 10) };\n return (\n <View style={{ width: diameter, height: diameter }}>\n <View style={[ring, { position: 'absolute', borderColor: color, opacity: 0.2 }]} />\n <Animated.View\n style={[\n ring,\n {\n borderColor: 'transparent',\n borderTopColor: color,\n transform: [{ rotate: spin.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] }) }],\n },\n ]}\n />\n </View>\n );\n});\nRingIndicator.displayName = 'RingIndicator';\n\n/**\n * Spinner\n * Indeterminate loading indicator, with optional text below.\n *\n * Gestalt:\n * - Common fate: the three `dots` pulse in sequence, at the same rhythm, and\n * read as a single indicator moving forward.\n * - Proximity: the text sits `spacing.md` from the indicator, so it reads as\n * its explanation and not as loose text on the screen. The `logo` sits\n * further away (`spacing.xl`): it is the screen's brand, not part of the indicator.\n * - Focal point: with `logo`, the logo is the first thing seen and the\n * indicator stays as a secondary signal that the app is loading.\n *\n * Accessibility: it is announced as a busy `progressbar`, with `text` (or\n * `labels.loading`) as its name. The animation doesn't stop with Reduce\n * Motion: it is the signal that something is happening, like the system's\n * ActivityIndicator.\n */\nexport const Spinner = memo(\n ({\n size = 'large',\n color,\n text,\n fullscreen = true,\n variant = 'native',\n indicator,\n logo,\n accessibilityLabel,\n testID,\n }: SpinnerProps) => {\n const colors = useColors();\n const labels = useLabels();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const resolvedColor = color || colors.primary;\n const scale = size === 'small' ? 8 : 12;\n\n const renderIndicator = () => {\n if (indicator) return indicator;\n if (variant === 'dots') return <DotsIndicator color={resolvedColor} dotSize={scale} />;\n if (variant === 'ring') return <RingIndicator color={resolvedColor} diameter={scale * 3} />;\n return <ActivityIndicator size={size} color={resolvedColor} />;\n };\n\n return (\n <View\n style={[styles.container, fullscreen && { flex: 1 }]}\n testID={testID}\n accessible\n accessibilityRole=\"progressbar\"\n accessibilityLabel={accessibilityLabel ?? (text || labels.loading)}\n accessibilityState={{ busy: true }}\n accessibilityLiveRegion=\"polite\"\n >\n {logo != null && <View style={styles.logo}>{logo}</View>}\n {renderIndicator()}\n {!!text && (\n <Text allowFontScaling={allowFontScaling} style={styles.text}>\n {text}\n </Text>\n )}\n </View>\n );\n }\n);\nSpinner.displayName = 'Spinner';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
package/registry/stepper.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
{
|
|
12
12
|
"path": "molecules/Stepper.tsx",
|
|
13
|
-
"content": "import React, { memo, useState } from 'react';\nimport {
|
|
13
|
+
"content": "import React, { memo, useRef, useState, type ComponentRef } from 'react';\nimport {\n TextInput,\n TouchableOpacity,\n View,\n type AccessibilityActionEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useLabels, useTheme } from '../theme/UIProvider';\nimport { useKeyboardField } from '../theme/useKeyboardField';\nimport { Icon } from '../icons/Icon';\n\nexport type StepperSize = 'sm' | 'md' | 'lg';\n\nexport interface StepperProps {\n /** Current value, as text (it may be mid-typing, e.g. \"\" or \"1.\"). */\n value: string;\n /** Receives the cleaned-up text while typing, and the clamped value on blur. */\n onChangeText: (text: string) => void;\n /** \"+\" pressed (or swipe up with a screen reader). Do the arithmetic here. */\n onIncrement: () => void;\n /** \"-\" pressed (or swipe down with a screen reader). */\n onDecrement: () => void;\n /** Scale of the control: height, buttons, icon and typography. @default 'md' */\n size?: StepperSize;\n /** Takes up all available width and the input absorbs the extra space. @default false */\n block?: boolean;\n /**\n * When false the value can only be changed with the buttons: the keyboard doesn't open and\n * the field loses the background that marks it as editable. @default true\n */\n editable?: boolean;\n /**\n * Minimum allowed value. Disables \"-\" when reached and clamps the typed value on\n * blur. Negative values are only accepted if `min` is negative.\n */\n min?: number;\n /** Maximum allowed value. Disables \"+\" when reached and clamps the typed value. */\n max?: number;\n /** Accepts a decimal separator, normalizing comma to dot. @default false */\n allowDecimals?: boolean;\n style?: StyleProp<ViewStyle>;\n /** Accessible name of the numeric field. @default labels.quantity */\n accessibilityLabel?: string;\n /** Accessible name of the \"+\" button. @default labels.increment */\n incrementAccessibilityLabel?: string;\n /** Accessible name of the \"-\" button. @default labels.decrement */\n decrementAccessibilityLabel?: string;\n testID?: string;\n}\n\nconst SIZES: Record<StepperSize, { height: number; icon: number; inputWidth: number }> = {\n sm: { height: 36, icon: 18, inputWidth: 48 },\n md: { height: 44, icon: 20, inputWidth: 56 },\n lg: { height: 52, icon: 22, inputWidth: 64 },\n};\n\n/** Minimum touch target (Fitts): smaller buttons reach it with hitSlop. */\nconst MIN_TOUCH = 44;\n\nconst ADJUST_ACTIONS = [\n { name: 'increment' as const },\n { name: 'decrement' as const },\n];\n\nconst useStyles = createStyles((theme) => ({\n control: {\n flexDirection: 'row',\n alignItems: 'center',\n alignSelf: 'flex-start',\n borderWidth: 1,\n borderColor: theme.colors.border,\n borderRadius: theme.tokens.radius.md,\n },\n controlBlock: { alignSelf: 'stretch' },\n btn: { height: '100%', justifyContent: 'center', alignItems: 'center' },\n input: {\n textAlign: 'center',\n height: '100%',\n color: theme.colors.text,\n borderLeftWidth: 1,\n borderRightWidth: 1,\n borderColor: theme.colors.border,\n },\n // The background tells the field apart from a read-only value: it is the\n // only hint that you can type there before touching it.\n editable: { backgroundColor: theme.colors.inputBackground },\n}));\n\n/** Keeps only what can be part of a number allowed by the range. */\nexport function sanitizeNumeric(text: string, { allowDecimals = false, allowNegative = false } = {}): string {\n const normalized = allowDecimals ? text.replace(',', '.') : text;\n const negative = allowNegative && normalized.trimStart().startsWith('-');\n const digits = normalized.replace(allowDecimals ? /[^0-9.]/g : /[^0-9]/g, '');\n const [whole = '', ...decimals] = digits.split('.');\n const body = decimals.length > 0 ? `${whole}.${decimals.join('')}` : whole;\n return negative ? `-${body}` : body;\n}\n\n/**\n * Stepper\n * \"-\" button + editable numeric input + \"+\" button. `size` controls only the\n * scale and `block` only the width, so both axes combine freely.\n *\n * The component doesn't do the buttons' arithmetic —that lives in\n * `onIncrement`/`onDecrement`—, but it does clean up what is typed and signals\n * when the value hit an end of the range by disabling the button that would pass it.\n *\n * Gestalt, common region: the three parts share one border, so they read as a\n * single control; the field is set apart by its fill (it can be typed into).\n * Fitts: every button reaches 44pt, `sm` through hitSlop.\n *\n * Accessibility: the field is an adjustable element — VoiceOver/TalkBack users\n * swipe up/down to step the value — and every name comes from `labels`. Like\n * Input, it stays visible above the keyboard while typing.\n */\nexport const Stepper = memo(\n ({\n value,\n onChangeText,\n onIncrement,\n onDecrement,\n size = 'md',\n block = false,\n editable = true,\n min,\n max,\n allowDecimals = false,\n style,\n accessibilityLabel,\n incrementAccessibilityLabel,\n decrementAccessibilityLabel,\n testID,\n }: StepperProps) => {\n const theme = useTheme();\n const colors = useColors();\n const styles = useStyles();\n const labels = useLabels();\n const allowFontScaling = useAllowFontScaling();\n const [focused, setFocused] = useState(false);\n const controlRef = useRef<ComponentRef<typeof View>>(null);\n const keyboard = useKeyboardField(controlRef);\n\n const { height, icon, inputWidth } = SIZES[size];\n const fontSize =\n size === 'sm' ? theme.tokens.fontSize.md : size === 'md' ? theme.tokens.fontSize.lg : theme.tokens.fontSize.xl;\n const slopV = Math.max(0, (MIN_TOUCH - height) / 2);\n const hitSlop = slopV > 0 ? { top: slopV, bottom: slopV, left: slopV, right: slopV } : undefined;\n\n const allowNegative = min !== undefined && min < 0;\n const numeric = Number(value);\n const isNumber = value.trim() !== '' && Number.isFinite(numeric);\n const atMin = isNumber && min !== undefined && numeric <= min;\n const atMax = isNumber && max !== undefined && numeric >= max;\n\n const handleChangeText = (text: string) => {\n onChangeText(sanitizeNumeric(text, { allowDecimals, allowNegative }));\n };\n\n // Clamping to the range waits for blur: doing it while typing makes it\n // impossible to reach \"12\" when the minimum is 5, because the \"1\" would\n // already be corrected.\n const handleBlur = () => {\n setFocused(false);\n keyboard.blur();\n if (!isNumber) return;\n const clamped = Math.min(max ?? numeric, Math.max(min ?? numeric, numeric));\n if (clamped !== numeric) onChangeText(String(clamped));\n };\n\n const handleAccessibilityAction = (e: AccessibilityActionEvent) => {\n if (e.nativeEvent.actionName === 'increment' && !atMax) onIncrement();\n if (e.nativeEvent.actionName === 'decrement' && !atMin) onDecrement();\n };\n\n const stepButton = (\n direction: 'decrement' | 'increment',\n onPress: () => void,\n disabled: boolean,\n label: string\n ) => (\n <TouchableOpacity\n onPress={onPress}\n disabled={disabled}\n hitSlop={hitSlop}\n style={[styles.btn, { width: height }]}\n accessibilityRole=\"button\"\n accessibilityLabel={label}\n accessibilityState={{ disabled }}\n testID={testID && `${testID}-${direction}`}\n >\n <Icon\n name={direction === 'increment' ? 'plus' : 'minus'}\n size={icon}\n color={disabled ? colors.textLight : colors.primary}\n />\n </TouchableOpacity>\n );\n\n return (\n <View\n ref={controlRef}\n testID={testID}\n style={[\n styles.control,\n { height },\n block && styles.controlBlock,\n focused && { borderColor: colors.primary },\n style,\n ]}\n >\n {stepButton('decrement', onDecrement, atMin, decrementAccessibilityLabel ?? labels.decrement)}\n <TextInput\n allowFontScaling={allowFontScaling}\n style={[\n styles.input,\n { fontSize },\n editable && styles.editable,\n focused && { borderColor: colors.primary },\n block ? { flex: 1 } : { width: inputWidth },\n ]}\n value={value}\n onChangeText={handleChangeText}\n onFocus={() => {\n setFocused(true);\n keyboard.focus();\n }}\n onBlur={handleBlur}\n editable={editable}\n keyboardType={allowDecimals ? 'decimal-pad' : 'number-pad'}\n selectTextOnFocus\n accessibilityLabel={accessibilityLabel ?? labels.quantity}\n accessibilityRole=\"adjustable\"\n accessibilityValue={{ text: value }}\n accessibilityActions={ADJUST_ACTIONS}\n onAccessibilityAction={handleAccessibilityAction}\n />\n {stepButton('increment', onIncrement, atMax, incrementAccessibilityLabel ?? labels.increment)}\n </View>\n );\n }\n);\nStepper.displayName = 'Stepper';\n"
|
|
14
14
|
}
|
|
15
15
|
],
|
|
16
16
|
"registryDependencies": [
|
package/registry/steps.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "molecules/Steps.tsx",
|
|
12
|
-
"content": "import React, { memo } from 'react';\nimport { ScrollView, Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\n\nexport interface StepItem {\n label: string;\n description?: string;\n}\n\nexport type StepStatus = 'completed' | 'current' | 'pending';\n\nexport interface StepsProps {\n steps: StepItem[];\n /** Index of the current step (0-based). Earlier steps become 'completed'. */\n current: number;\n /** @default 'horizontal' */\n orientation?: 'horizontal' | 'vertical';\n /**\n * What the circle of an already-completed step shows: a check, or its number.\n * @default 'check'\n */\n completedIndicator?: 'check' | 'number';\n /** If provided, each step is tappable and reports its index. */\n onStepPress?: (index: number) => void;\n style?: StyleProp<ViewStyle>;\n}\n\nconst CIRCLE = 32;\n/** Ancho mínimo de una columna horizontal: por debajo, las etiquetas se parten. */\nconst STEP_MIN_WIDTH = 76;\n\nconst useStyles = createStyles((theme) => ({\n rowContainer: { flexDirection: 'row', alignItems: 'flex-start' },\n columnContainer: { flexDirection: 'column' },\n\n // horizontal\n // ScrollView trae flexGrow: 1 de fábrica y se comía todo el alto disponible\n // del contenedor, dejando los pasos pegados arriba de una caja enorme.\n hScrollView: { flexGrow: 0, flexShrink: 0 },\n hScroll: { flexGrow: 1, flexDirection: 'row', alignItems: 'flex-start' },\n // `minWidth` es lo que evita que RN parta palabras a la mitad\n // (\"Almacé / n\"): antes que apretar la columna por debajo de una palabra\n // corriente, la fila se vuelve scrollable.\n hStep: { flex: 1, minWidth: STEP_MIN_WIDTH, alignItems: 'center', gap: theme.tokens.spacing.sm },\n hLabels: { alignItems: 'center', gap: 2, paddingHorizontal: 4 },\n // El conector toma una fracción chica del ancho: compitiendo de igual a\n // igual con los pasos (flex: 1) cada etiqueta se quedaba con 1/(2n-1) del\n // total.\n hConnector: { height: 2, flex: 0.3, minWidth: 12, marginTop: CIRCLE / 2 - 1 },\n hLabel: { fontSize: theme.tokens.fontSize.sm },\n hDescription: { fontSize: theme.tokens.fontSize.xs },\n\n // vertical\n vStep: { flexDirection: 'row', gap: theme.tokens.spacing.md },\n vIndicatorColumn: { alignItems: 'center' },\n vConnector: { width: 2, flex: 1, minHeight: 20, marginVertical: 4 },\n vLabels: { flex: 1, gap: 2, paddingBottom: theme.tokens.spacing.lg },\n\n circle: {\n width: CIRCLE,\n height: CIRCLE,\n borderRadius: CIRCLE / 2,\n borderWidth: 2,\n alignItems: 'center',\n justifyContent: 'center',\n },\n circleNumber: { fontSize: theme.tokens.fontSize.md, fontWeight: theme.tokens.fontWeight.semibold },\n label: { fontSize: theme.tokens.fontSize.md, fontWeight: theme.tokens.fontWeight.semibold, color: theme.colors.text },\n labelCentered: { textAlign: 'center' },\n description: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.textLight },\n descriptionCentered: { textAlign: 'center' },\n}));\n\nfunction statusOf(index: number, current: number): StepStatus {\n if (index < current) return 'completed';\n if (index === current) return 'current';\n return 'pending';\n}\n\n/**\n * Steps\n * Indicador de progreso multi-paso. Base visual de `Wizard`, pero utilizable\n * suelto para mostrar el avance de un flujo que se navega de otra forma.\n */\nexport const Steps = memo(\n ({ steps, current, orientation = 'horizontal', completedIndicator = 'check', onStepPress, style }: StepsProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const isVertical = orientation === 'vertical';\n\n const colorsFor = (status: StepStatus) => {\n if (status === 'completed') return { bg: colors.primary, border: colors.primary, fg: colors.onPrimary };\n if (status === 'current') return { bg: 'transparent', border: colors.primary, fg: colors.primaryText };\n return { bg: 'transparent', border: colors.border, fg: colors.textLight };\n };\n\n const renderCircle = (index: number, status: StepStatus) => {\n const c = colorsFor(status);\n const showCheck = status === 'completed' && completedIndicator === 'check';\n return (\n <View style={[styles.circle, { backgroundColor: c.bg, borderColor: c.border }]}>\n {showCheck ? (\n <Icon name=\"check\" size={18} color={c.fg} />\n ) : (\n <Text allowFontScaling={allowFontScaling} style={[styles.circleNumber, { color: c.fg }]}>\n {index + 1}\n </Text>\n )}\n </View>\n );\n };\n\n const renderLabels = (step: StepItem, status: StepStatus, centered: boolean) => (\n <View style={centered ? styles.hLabels : styles.vLabels}>\n <Text\n allowFontScaling={allowFontScaling}\n style={[\n styles.label,\n centered && styles.labelCentered,\n centered && styles.hLabel,\n status === 'pending' && { color: colors.textLight },\n ]}\n >\n {step.label}\n </Text>\n {!!step.description && (\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.description, centered && styles.descriptionCentered, centered && styles.hDescription]}\n >\n {step.description}\n </Text>\n )}\n </View>\n );\n\n const stepAccessibility = (index: number, step: StepItem, status: StepStatus) => ({\n accessibilityRole: (onStepPress ? 'button' : 'text') as 'button' | 'text',\n accessibilityLabel: `Step ${index + 1}: ${step.label}`,\n accessibilityState: { selected: status === 'current', disabled: false },\n });\n\n if (isVertical) {\n return (\n <View style={[styles.columnContainer, style]}>\n {steps.map((step, index) => {\n const status = statusOf(index, current);\n const isLast = index === steps.length - 1;\n const Wrapper = onStepPress ? TouchableOpacity : View;\n // Sólo tocable si hay onStepPress: pasarle onPress/activeOpacity\n // a un View plano no crashea, pero los deja como ruido silencioso.\n const interactiveProps = onStepPress\n ? { onPress: () => onStepPress(index), activeOpacity: 0.7 }\n : undefined;\n return (\n <Wrapper\n key={`${step.label}-${index}`}\n style={styles.vStep}\n {...interactiveProps}\n {...stepAccessibility(index, step, status)}\n >\n <View style={styles.vIndicatorColumn}>\n {renderCircle(index, status)}\n {!isLast && (\n <View\n style={[\n styles.vConnector,\n { backgroundColor: index < current ? colors.primary : colors.border },\n ]}\n />\n )}\n </View>\n {renderLabels(step, status, false)}\n </Wrapper>\n );\n })}\n </View>\n );\n }\n\n return (\n <ScrollView\n testID=\"steps-row\"\n horizontal\n showsHorizontalScrollIndicator={false}\n // Con espacio de sobra los pasos se reparten la fila igual que antes\n // (flexGrow); cuando no entran, se desplaza en vez de apretar el texto.\n style={[styles.hScrollView, style]}\n contentContainerStyle={styles.hScroll}\n >\n {steps.map((step, index) => {\n const status = statusOf(index, current);\n const isLast = index === steps.length - 1;\n const Wrapper = onStepPress ? TouchableOpacity : View;\n const interactiveProps = onStepPress\n ? { onPress: () => onStepPress(index), activeOpacity: 0.7 }\n : undefined;\n return (\n <React.Fragment key={`${step.label}-${index}`}>\n <Wrapper\n style={styles.hStep}\n {...interactiveProps}\n {...stepAccessibility(index, step, status)}\n >\n {renderCircle(index, status)}\n {renderLabels(step, status, true)}\n </Wrapper>\n {!isLast && (\n <View\n style={[styles.hConnector, { backgroundColor: index < current ? colors.primary : colors.border }]}\n />\n )}\n </React.Fragment>\n );\n })}\n </ScrollView>\n );\n }\n);\nSteps.displayName = 'Steps';\n"
|
|
12
|
+
"content": "import React, { memo, useEffect, useRef, useState, type ComponentRef } from 'react';\nimport {\n ScrollView,\n Text,\n TouchableOpacity,\n View,\n type LayoutChangeEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { useReduceMotion } from '../theme/useReduceMotion';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useLabels } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\n\nexport interface StepItem {\n /** Short name of the step (\"Payment\"). */\n label: string;\n /** Optional second line with a detail (\"Card or cash\"). */\n description?: string;\n}\n\nexport type StepStatus = 'completed' | 'current' | 'pending';\n\nexport interface StepsProps {\n /** The sequence, in order. */\n steps: StepItem[];\n /** Index of the current step (0-based). Earlier steps become 'completed'. */\n current: number;\n /** Lays the steps out in a row or in a column. @default 'horizontal' */\n orientation?: 'horizontal' | 'vertical';\n /**\n * What the circle of an already-completed step shows: a check, or its number.\n * @default 'check'\n */\n completedIndicator?: 'check' | 'number';\n /** If provided, each step is tappable and reports its index. */\n onStepPress?: (index: number) => void;\n style?: StyleProp<ViewStyle>;\n /** Root testID; each step gets `<testID>-<index>`. */\n testID?: string;\n}\n\nconst CIRCLE = 32;\nconst CIRCLE_BORDER = 2;\nconst CHECK_SIZE = 18;\nconst CONNECTOR = 2;\n/** Minimum width of a horizontal column: below it, labels break mid-word. */\nconst STEP_MIN_WIDTH = 76;\n/** Share of the row a horizontal connector takes next to a step (flex: 1). */\nconst CONNECTOR_FLEX = 0.3;\nconst PRESSED_OPACITY = 0.7;\n\nconst useStyles = createStyles((theme) => ({\n rowContainer: { flexDirection: 'row', alignItems: 'flex-start' },\n columnContainer: { flexDirection: 'column' },\n\n // horizontal\n // ScrollView ships with flexGrow: 1 and was eating all the available height\n // of the container, leaving the steps stuck to the top of a huge box.\n hScrollView: { flexGrow: 0, flexShrink: 0 },\n hScroll: { flexGrow: 1, flexDirection: 'row', alignItems: 'flex-start' },\n // `minWidth` is what keeps RN from breaking words in half\n // (\"Almacé / n\"): rather than squeezing the column below a regular\n // word, the row becomes scrollable.\n hStep: { flex: 1, minWidth: STEP_MIN_WIDTH, alignItems: 'center', gap: theme.tokens.spacing.sm },\n hLabels: { alignItems: 'center', gap: theme.tokens.spacing.xxs, paddingHorizontal: theme.tokens.spacing.xs },\n // The connector takes a small fraction of the width: competing on equal\n // terms with the steps (flex: 1), each label ended up with 1/(2n-1) of the\n // total.\n hConnector: {\n height: CONNECTOR,\n flex: CONNECTOR_FLEX,\n minWidth: theme.tokens.spacing.md,\n marginTop: CIRCLE / 2 - CONNECTOR / 2,\n },\n hLabel: { fontSize: theme.tokens.fontSize.sm, lineHeight: theme.tokens.lineHeight.sm },\n hDescription: { fontSize: theme.tokens.fontSize.xs, lineHeight: theme.tokens.lineHeight.sm },\n\n // vertical\n vStep: { flexDirection: 'row', gap: theme.tokens.spacing.md },\n vIndicatorColumn: { alignItems: 'center' },\n vConnector: { width: CONNECTOR, flex: 1, minHeight: theme.tokens.spacing.xl, marginVertical: theme.tokens.spacing.xs },\n vLabels: { flex: 1, gap: theme.tokens.spacing.xxs, paddingBottom: theme.tokens.spacing.lg },\n // The last step has no connector below: no trailing space either.\n vLabelsLast: { paddingBottom: 0 },\n\n circle: {\n width: CIRCLE,\n height: CIRCLE,\n borderRadius: CIRCLE / 2,\n borderWidth: CIRCLE_BORDER,\n alignItems: 'center',\n justifyContent: 'center',\n },\n circleNumber: { fontSize: theme.tokens.fontSize.md, fontWeight: theme.tokens.fontWeight.semibold },\n label: {\n fontSize: theme.tokens.fontSize.md,\n lineHeight: theme.tokens.lineHeight.md,\n fontWeight: theme.tokens.fontWeight.semibold,\n color: theme.colors.text,\n },\n labelCurrent: { color: theme.colors.primaryText },\n labelPending: { color: theme.colors.textLight, fontWeight: theme.tokens.fontWeight.medium },\n labelCentered: { textAlign: 'center' },\n description: { fontSize: theme.tokens.fontSize.sm, lineHeight: theme.tokens.lineHeight.sm, color: theme.colors.textLight },\n descriptionCentered: { textAlign: 'center' },\n}));\n\nfunction statusOf(index: number, current: number): StepStatus {\n if (index < current) return 'completed';\n if (index === current) return 'current';\n return 'pending';\n}\n\n/**\n * Steps\n * Multi-step progress indicator. The visual base of `Wizard`, but usable on\n * its own to show progress through a flow that is navigated some other way.\n *\n * Gestalt, continuity: a connector line runs through the circles, so the steps\n * read as one path; the stretch already walked is filled with the accent.\n * Similarity: every step is the same circle + label, and the state only\n * changes fill and color (done = filled, current = outlined accent, pending =\n * grey). Von Restorff: the current step is the only accent-colored label.\n *\n * Accessibility: each step announces \"Step 2 of 3: Payment, current\", with\n * every word from `labels`.\n */\nexport const Steps = memo(\n ({\n steps,\n current,\n orientation = 'horizontal',\n completedIndicator = 'check',\n onStepPress,\n style,\n testID,\n }: StepsProps) => {\n const colors = useColors();\n const labels = useLabels();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const isVertical = orientation === 'vertical';\n const reduceMotion = useReduceMotion();\n\n // Horizontal overflow: when the steps don't fit, the row scrolls. A hidden\n // step gave no hint of existing, so the current step is brought into view\n // and the scroll bar flashes once (visibility of system status).\n const scrollRef = useRef<ComponentRef<typeof ScrollView>>(null);\n const [viewportWidth, setViewportWidth] = useState(0);\n const [contentWidth, setContentWidth] = useState(0);\n const stepX = useRef<number[]>([]);\n const overflows = !isVertical && viewportWidth > 0 && contentWidth > viewportWidth + 1;\n\n useEffect(() => {\n if (!overflows) return;\n const x = stepX.current[current] ?? 0;\n // A quarter of the row of lead-in: the previous step stays partly visible.\n scrollRef.current?.scrollTo({ x: Math.max(0, x - viewportWidth / 4), animated: !reduceMotion });\n scrollRef.current?.flashScrollIndicators();\n }, [overflows, current, viewportWidth, reduceMotion]);\n\n const colorsFor = (status: StepStatus) => {\n if (status === 'completed') return { bg: colors.primary, border: colors.primary, fg: colors.onPrimary };\n if (status === 'current') return { bg: 'transparent', border: colors.primary, fg: colors.primaryText };\n return { bg: 'transparent', border: colors.border, fg: colors.textLight };\n };\n\n const renderCircle = (index: number, status: StepStatus) => {\n const c = colorsFor(status);\n const showCheck = status === 'completed' && completedIndicator === 'check';\n return (\n <View style={[styles.circle, { backgroundColor: c.bg, borderColor: c.border }]}>\n {showCheck ? (\n <Icon name=\"check\" size={CHECK_SIZE} color={c.fg} />\n ) : (\n <Text allowFontScaling={allowFontScaling} style={[styles.circleNumber, { color: c.fg }]}>\n {index + 1}\n </Text>\n )}\n </View>\n );\n };\n\n const renderLabels = (step: StepItem, status: StepStatus, centered: boolean, isLast = false) => (\n <View style={centered ? styles.hLabels : [styles.vLabels, isLast && styles.vLabelsLast]}>\n <Text\n allowFontScaling={allowFontScaling}\n style={[\n styles.label,\n centered && styles.labelCentered,\n centered && styles.hLabel,\n status === 'current' && styles.labelCurrent,\n status === 'pending' && styles.labelPending,\n ]}\n >\n {step.label}\n </Text>\n {!!step.description && (\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.description, centered && styles.descriptionCentered, centered && styles.hDescription]}\n >\n {step.description}\n </Text>\n )}\n </View>\n );\n\n const statusLabel = { completed: labels.stepCompleted, current: labels.stepCurrent, pending: labels.stepPending };\n const stepAccessibility = (index: number, step: StepItem, status: StepStatus) => ({\n accessibilityRole: (onStepPress ? 'button' : 'text') as 'button' | 'text',\n accessibilityLabel: `${labels.stepOf\n .replace('{n}', String(index + 1))\n .replace('{total}', String(steps.length))\n .replace('{label}', step.label)}, ${statusLabel[status]}`,\n accessibilityState: { selected: status === 'current', disabled: false },\n testID: testID && `${testID}-${index}`,\n });\n\n if (isVertical) {\n return (\n <View style={[styles.columnContainer, style]} testID={testID}>\n {steps.map((step, index) => {\n const status = statusOf(index, current);\n const isLast = index === steps.length - 1;\n const Wrapper = onStepPress ? TouchableOpacity : View;\n // Only touchable when there is onStepPress: passing onPress/activeOpacity\n // to a plain View doesn't crash, but leaves them as silent noise.\n const interactiveProps = onStepPress\n ? { onPress: () => onStepPress(index), activeOpacity: PRESSED_OPACITY }\n : undefined;\n return (\n <Wrapper\n key={`${step.label}-${index}`}\n style={styles.vStep}\n {...interactiveProps}\n {...stepAccessibility(index, step, status)}\n >\n <View style={styles.vIndicatorColumn}>\n {renderCircle(index, status)}\n {!isLast && (\n <View\n style={[\n styles.vConnector,\n { backgroundColor: index < current ? colors.primary : colors.border },\n ]}\n />\n )}\n </View>\n {renderLabels(step, status, false, isLast)}\n </Wrapper>\n );\n })}\n </View>\n );\n }\n\n return (\n <ScrollView\n ref={scrollRef}\n testID={testID}\n horizontal\n showsHorizontalScrollIndicator={overflows}\n onLayout={(e: LayoutChangeEvent) => setViewportWidth(e.nativeEvent.layout.width)}\n onContentSizeChange={(width: number) => setContentWidth(width)}\n // With room to spare the steps share the row just like before\n // (flexGrow); when they don't fit, it scrolls instead of squeezing the text.\n style={[styles.hScrollView, style]}\n contentContainerStyle={styles.hScroll}\n >\n {steps.map((step, index) => {\n const status = statusOf(index, current);\n const isLast = index === steps.length - 1;\n const Wrapper = onStepPress ? TouchableOpacity : View;\n const interactiveProps = onStepPress\n ? { onPress: () => onStepPress(index), activeOpacity: PRESSED_OPACITY }\n : undefined;\n return (\n <React.Fragment key={`${step.label}-${index}`}>\n <Wrapper\n style={styles.hStep}\n onLayout={(e: LayoutChangeEvent) => {\n stepX.current[index] = e.nativeEvent.layout.x;\n }}\n {...interactiveProps}\n {...stepAccessibility(index, step, status)}\n >\n {renderCircle(index, status)}\n {renderLabels(step, status, true)}\n </Wrapper>\n {!isLast && (\n <View\n style={[styles.hConnector, { backgroundColor: index < current ? colors.primary : colors.border }]}\n />\n )}\n </React.Fragment>\n );\n })}\n </ScrollView>\n );\n }\n);\nSteps.displayName = 'Steps';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "molecules/SymmetricGrid.tsx",
|
|
12
|
-
"content": "import React, { useMemo } from 'react';\nimport { View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\n\nexport interface SymmetricGridProps<T> {\n data: T[];\n renderItem: (item: T, index: number) => React.ReactElement;\n keyExtractor: (item: T, index: number) => string;\n /** Columns per row. @default 2 */\n columns?: number;\n /** Spacing between cells, on both axes. @default theme.tokens.spacing.md */\n gap?: number;\n /**\n * Incomplete last row: centered (symmetric) instead of stuck to the\n * left with a gap on the right. @default true\n */\n balanceLastRow?: boolean;\n style?: StyleProp<ViewStyle>;\n}\n\nconst useStyles = createStyles((
|
|
12
|
+
"content": "import React, { useMemo } from 'react';\nimport { View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useTheme } from '../theme/UIProvider';\n\nexport interface SymmetricGridProps<T> {\n /** Items to lay out, in reading order. */\n data: T[];\n /** Renders one cell; it gets the full cell width. */\n renderItem: (item: T, index: number) => React.ReactElement;\n /** Stable key per item. */\n keyExtractor: (item: T, index: number) => string;\n /** Columns per row. @default 2 */\n columns?: number;\n /** Spacing between cells, on both axes. @default theme.tokens.spacing.md */\n gap?: number;\n /**\n * Incomplete last row: centered (symmetric) instead of stuck to the\n * left with a gap on the right. @default true\n */\n balanceLastRow?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst useStyles = createStyles(() => ({\n row: { flexDirection: 'row' },\n rowBalanced: { justifyContent: 'center' },\n rowFull: { justifyContent: 'flex-start' },\n cell: { flexGrow: 0, flexShrink: 0 },\n}));\n\nfunction chunk<T>(data: T[], size: number): T[][] {\n const rows: T[][] = [];\n for (let i = 0; i < data.length; i += size) rows.push(data.slice(i, i + size));\n return rows;\n}\n\n/**\n * SymmetricGrid\n * Law of Prägnanz (good form): groups items into even rows of N columns.\n * The last row, when incomplete, is centered by default instead of sticking\n * to one edge — that is what makes it read as a \"short row\" and not as a\n * broken layout with a gap on one side.\n *\n * Gestalt, similarity: every cell has exactly the same width, also in the\n * centered short row, so items read as peers. The horizontal gap is padding\n * inside each cell (and a negative margin on the row), not flex `gap`: a\n * `100 / columns %` width plus a gap made every row wider than the grid and\n * pushed the right column out of the container.\n */\nexport function SymmetricGrid<T>({\n data,\n renderItem,\n keyExtractor,\n columns = 2,\n gap,\n balanceLastRow = true,\n style,\n testID,\n}: SymmetricGridProps<T>) {\n const theme = useTheme();\n const styles = useStyles();\n const rows = useMemo(() => chunk(data, columns), [data, columns]);\n const space = gap ?? theme.tokens.spacing.md;\n const cellWidth = `${100 / columns}%` as const;\n\n return (\n <View style={[{ gap: space }, style]} testID={testID}>\n {rows.map((row, rowIndex) => {\n const isLastRow = rowIndex === rows.length - 1;\n const isIncomplete = row.length < columns;\n const centered = isLastRow && isIncomplete && balanceLastRow;\n\n return (\n <View\n // `columns` in the key: if it changes, rows don't reuse DOM nodes\n // from a row built with a different number of columns.\n key={`row-${columns}-${rowIndex}`}\n style={[styles.row, centered ? styles.rowBalanced : styles.rowFull, { marginHorizontal: -space / 2 }]}\n >\n {row.map((item, colIndex) => {\n const index = rowIndex * columns + colIndex;\n return (\n <View\n key={keyExtractor(item, index)}\n style={[styles.cell, { width: cellWidth, paddingHorizontal: space / 2 }]}\n >\n {renderItem(item, index)}\n </View>\n );\n })}\n </View>\n );\n })}\n </View>\n );\n}\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/ThemeToggle.tsx",
|
|
12
|
-
"content": "import React, { memo, useMemo } from 'react';\nimport { type StyleProp, type ViewStyle } from 'react-native';\nimport { useThemeMode, type ThemeMode } from '../theme/UIProvider';\nimport { SegmentedControl } from '../molecules/SegmentedControl';\n\nexport interface ThemeToggleProps {\n style?: StyleProp<ViewStyle>;\n labels?: Partial<Record<ThemeMode, string>>;\n testID?: string;\n}\n\nconst MODES: ThemeMode[] = ['system', 'light', 'dark'];\
|
|
12
|
+
"content": "import React, { memo, useMemo } from 'react';\nimport { type StyleProp, type ViewStyle } from 'react-native';\nimport { useLabels, useThemeMode, type ThemeMode } from '../theme/UIProvider';\nimport { SegmentedControl } from '../molecules/SegmentedControl';\n\nexport interface ThemeToggleProps {\n style?: StyleProp<ViewStyle>;\n /** Text of each segment; any mode left out keeps its default (`labels.themeSystem`, `themeLight`, `themeDark`). */\n labels?: Partial<Record<ThemeMode, string>>;\n testID?: string;\n}\n\nconst MODES: ThemeMode[] = ['system', 'light', 'dark'];\n\n/**\n * ThemeToggle\n * Connects a SegmentedControl to UIProvider.setMode. No persistence — the\n * consumer handles it via onModeChange on <UIProvider>.\n *\n * Design: Jakob's law — the same Auto / Light / Dark choice as the system\n * settings, in that order; Hick's law — three options, nothing to configure.\n */\nexport const ThemeToggle = memo(({ style, labels: labelOverrides, testID }: ThemeToggleProps) => {\n const { mode, setMode } = useThemeMode();\n const providerLabels = useLabels();\n\n const options = useMemo(() => {\n const labels: Record<ThemeMode, string> = {\n system: providerLabels.themeSystem,\n light: providerLabels.themeLight,\n dark: providerLabels.themeDark,\n ...labelOverrides,\n };\n return MODES.map((value) => ({ value, label: labels[value] }));\n }, [labelOverrides, providerLabels]);\n\n return <SegmentedControl options={options} value={mode} onChange={setMode} style={style} testID={testID} />;\n});\nThemeToggle.displayName = 'ThemeToggle';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
package/registry/timeline.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "organisms/Timeline.tsx",
|
|
12
|
-
"content": "import React, { memo, useEffect, useMemo, useRef } from 'react';\nimport { Animated, Easing, Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { PressableScale } from '../atoms/PressableScale';\nimport { useReduceMotion } from '../atoms/Transition';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport type TimelineStatus = 'done' | 'pending';\n\n/**\n * Qué puede hacer un toque.\n * - 'free': cualquier hito responde y la línea sigue a `items` para donde\n * vaya, adelante o atrás. Es el que sirve cuando el recorrido no es un\n * progreso, o cuando lo maneja otra cosa.\n * - 'sequential': sólo responde el que sigue al alcanzado, y la línea no\n * retrocede nunca. Avance estricto.\n * - 'revisit': responden el que sigue y todos los ya alcanzados, y la línea\n * tampoco retrocede. Es el caso de \"puedo volver a mirar lo anterior sin\n * perder lo que llevo\".\n */\nexport type TimelineAdvance = 'free' | 'sequential' | 'revisit';\n\nexport interface TimelineItem {\n label: string;\n /** Glyph from the library's set, to the left of the label. */\n iconName?: IconName;\n /** Emoji to the left of the label. Wins over `iconName`. */\n emoji?: string;\n /** @default 'done' */\n status?: TimelineStatus;\n}\n\nexport interface TimelineProps {\n items: TimelineItem[];\n /** Pixels the line bows away from the straight path. 0 draws it straight. @default 28 */\n curve?: number;\n /** Vertical distance between one node and the next. @default 84 */\n spacing?: number;\n /** Side the first pill goes to; the rest alternate. @default 'right' */\n startSide?: 'left' | 'right';\n /** Halo behind each pill. @default true */\n glow?: boolean;\n /** Makes each pill tappable and reports its index. */\n onItemPress?: (index: number) => void;\n /**\n * Which taps are allowed, and whether the line may go back. @default 'free'\n *\n * In 'sequential' and 'revisit' the line keeps the furthest point it has\n * been asked to draw, so a parent that hands back a shorter `items` does not\n * undo the progress. Swapping in a different list keeps it too — reset it by\n * remounting (a `key`).\n */\n advance?: TimelineAdvance;\n /**\n * The milestone being looked at right now, highlighted. It is not the\n * progress: it can sit behind the line without pulling it back.\n */\n selectedIndex?: number;\n /**\n * Milliseconds the line takes to travel one gap when a milestone is\n * reached. @default 420\n */\n duration?: number;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst DEFAULT_CURVE = 28;\nconst DEFAULT_SPACING = 84;\nconst NODE = 14;\nconst LINE = 2;\n/** Tramos rectos por cada vano: con menos, la curva se ve poligonal. */\nconst SEGMENTS = 14;\nconst DOT = 3;\n/** Un punto cada cuántos tramos: el camino entero va punteado por debajo. */\nconst DOT_EVERY = 2;\nconst DEFAULT_DURATION = 420;\n/**\n * Cuánto tarda un tramo en encenderse, medido en hitos. Chico para que el\n * frente de la línea se lea como un borde y no como un degradado largo.\n */\nconst FADE = 0.06;\n/**\n * Capas del halo, de afuera hacia adentro. Son muchas y muy tenues a\n * propósito: con tres capas se ven los anillos, que es justo lo contrario de\n * un difuminado. Cuantos más escalones y más chico el salto de opacidad, más\n * cerca queda de un degradado.\n */\n/** Lo que el halo sobresale de la píldora: es lo que hay que dejarle de aire. */\nconst GLOW_BLEED = 20;\nconst GLOW_LAYERS = [\n { inset: -GLOW_BLEED, opacity: 0.03 },\n { inset: -16, opacity: 0.035 },\n { inset: -12, opacity: 0.04 },\n { inset: -8, opacity: 0.045 },\n { inset: -5, opacity: 0.05 },\n { inset: -2, opacity: 0.055 },\n];\n\nconst useStyles = createStyles((theme) => ({\n root: { alignSelf: 'stretch' },\n row: { flexDirection: 'row', alignItems: 'center' },\n side: { flex: 1, justifyContent: 'center' },\n sideLeft: { alignItems: 'flex-end', paddingRight: theme.tokens.spacing.md },\n sideRight: { alignItems: 'flex-start', paddingLeft: theme.tokens.spacing.md },\n node: {\n position: 'absolute',\n width: NODE,\n height: NODE,\n borderRadius: NODE / 2,\n borderWidth: LINE,\n },\n nodePending: { backgroundColor: 'transparent', borderColor: theme.colors.border },\n // Ocupa el círculo entero menos el borde, así el relleno no lo tapa.\n nodeFill: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, borderRadius: NODE / 2 },\n pill: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.sm,\n paddingVertical: theme.tokens.spacing.sm,\n paddingHorizontal: theme.tokens.spacing.lg,\n borderRadius: theme.tokens.radius.full,\n borderWidth: 1,\n borderColor: theme.colors.border,\n backgroundColor: theme.colors.surface,\n },\n // El foco se marca con el borde, no con el relleno: el relleno ya está\n // diciendo otra cosa (si el hito está alcanzado o no).\n pillSelected: { borderColor: theme.colors.primary, borderWidth: 2 },\n label: { fontSize: theme.tokens.fontSize.md, color: theme.colors.text },\n labelPending: { color: theme.colors.textLight },\n emoji: { fontSize: theme.tokens.fontSize.md },\n glow: {\n position: 'absolute',\n borderRadius: theme.tokens.radius.full,\n backgroundColor: theme.colors.text,\n },\n // La línea y los nodos viven en la misma capa y se posicionan con las mismas\n // coordenadas. Es lo único que garantiza que el círculo caiga sobre la\n // curva: en el flujo de la fila el nodo lo corre el flex —y los paddings que\n // cada lado necesita para no cortar su píldora—, así que terminaba a un\n // costado de la línea en vez de encima.\n lineLayer: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, alignItems: 'center' },\n // Hueco del ancho del nodo en la fila: la píldora se separa del centro lo\n // mismo que antes, pero el círculo ya no se dibuja acá.\n nodeSlot: { width: NODE },\n segment: { position: 'absolute', width: LINE },\n dot: { position: 'absolute', width: DOT, height: DOT, borderRadius: DOT / 2 },\n}));\n\n/**\n * Desvío horizontal del nodo `index` respecto del eje. Una onda entera a lo\n * largo de la lista: el primero y el último caen sobre el eje, y los del medio\n * se van a un lado y al otro. Es lo que convierte una columna en un camino.\n */\nfunction offsetAt(index: number, count: number, curve: number): number {\n if (count < 2) return 0;\n return curve * Math.sin((2 * Math.PI * index) / (count - 1));\n}\n\nexport const Timeline = memo(\n ({\n items,\n curve = DEFAULT_CURVE,\n spacing = DEFAULT_SPACING,\n startSide = 'right',\n glow = true,\n onItemPress,\n advance = 'free',\n selectedIndex,\n duration = DEFAULT_DURATION,\n style,\n testID,\n }: TimelineProps) => {\n const styles = useStyles();\n const colors = useColors();\n const allowFontScaling = useAllowFontScaling();\n const reduceMotion = useReduceMotion();\n\n const count = items.length;\n\n /**\n * Hasta dónde llegó el recorrido, en unidades de hito. Se cuenta desde el\n * principio y se corta en el primer pendiente: un hito cumplido después de\n * uno que no lo está no adelanta la línea, porque el camino es un camino.\n */\n const reached = useMemo(() => {\n let last = -1;\n for (let i = 0; i < items.length; i += 1) {\n if (items[i]?.status === 'pending') break;\n last = i;\n }\n return last;\n }, [items]);\n\n /**\n * Marca de agua: salvo en 'free', la línea se queda en lo más lejos que\n * llegó. El progreso es del usuario, no del render — que el padre mande\n * una lista más corta no puede desandar lo que ya recorrió.\n */\n const furthest = useRef(reached);\n if (advance === 'free') furthest.current = reached;\n else furthest.current = Math.max(furthest.current, reached);\n const lineTo = furthest.current;\n\n const progress = useRef(new Animated.Value(lineTo)).current;\n\n useEffect(() => {\n const animation = Animated.timing(progress, {\n toValue: lineTo,\n // Un hito por `duration`: avanzar tres de una vez tarda tres veces\n // más, así se ve el recorrido y no un salto.\n duration: reduceMotion ? 0 : duration,\n easing: Easing.inOut(Easing.cubic),\n useNativeDriver: true,\n });\n animation.start();\n return () => animation.stop();\n }, [lineTo, duration, reduceMotion, progress]);\n\n /**\n * Cada vano partido en tramos rectos cortos, cada uno con su largo y su\n * ángulo. Es la misma idea que `Gradient`: la plataforma no tiene curvas\n * sin dependencia nativa, así que la curva se compone con pedazos rectos\n * lo bastante chicos como para que el ojo no vea el quiebre.\n */\n const pieces = useMemo(() => {\n const out: {\n key: string;\n x: number;\n y: number;\n length: number;\n angle: string;\n at: number;\n dotted: boolean;\n }[] = [];\n\n for (let i = 0; i < count - 1; i += 1) {\n for (let step = 0; step < SEGMENTS; step += 1) {\n const from = i + step / SEGMENTS;\n const to = i + (step + 1) / SEGMENTS;\n const x1 = offsetAt(from, count, curve);\n const x2 = offsetAt(to, count, curve);\n const y1 = from * spacing;\n const y2 = to * spacing;\n const dx = x2 - x1;\n const dy = y2 - y1;\n const length = Math.sqrt(dx * dx + dy * dy);\n\n out.push({\n key: `${i}-${step}`,\n x: (x1 + x2) / 2,\n y: (y1 + y2) / 2,\n length,\n // El tramo se dibuja vertical y se gira hasta la pendiente: el\n // ángulo se mide desde el eje Y, no desde el X.\n angle: `${Math.atan2(dx, dy) * (180 / Math.PI) * -1}deg`,\n // Posición del tramo medida en hitos: es la misma escala que\n // `progress`, así que comparar una cosa con la otra es directo.\n at: to,\n dotted: step % DOT_EVERY === 0,\n });\n }\n }\n\n return out;\n }, [count, curve, spacing]);\n\n if (count === 0) return null;\n\n return (\n <View\n style={[\n styles.root,\n { height: count * spacing },\n style,\n ]}\n testID={testID}\n >\n <View style={styles.lineLayer} pointerEvents=\"none\">\n {/* El camino entero, punteado: es lo que falta recorrer. */}\n {pieces.map(\n (piece) =>\n piece.dotted && (\n <View\n key={`dot-${piece.key}`}\n style={[\n styles.dot,\n {\n backgroundColor: colors.border,\n top: spacing / 2 + piece.y - DOT / 2,\n transform: [{ translateX: piece.x }],\n },\n ]}\n />\n )\n )}\n\n {/* Y encima la línea llena, que se revela tramo a tramo a medida que\n `progress` avanza. Lo único que cambia por frame es `opacity`, o\n sea que el recorrido se dibuja en el hilo nativo. */}\n {pieces.map((piece) => (\n <Animated.View\n key={`line-${piece.key}`}\n style={[\n styles.segment,\n {\n backgroundColor: colors.textLight,\n height: piece.length,\n top: spacing / 2 + piece.y - piece.length / 2,\n opacity: progress.interpolate({\n inputRange: [piece.at - FADE, piece.at],\n outputRange: [0, 1],\n extrapolate: 'clamp',\n }),\n transform: [{ translateX: piece.x }, { rotate: piece.angle }],\n },\n ]}\n />\n ))}\n\n {items.map((item, index) => (\n <View\n key={`node-${item.label}-${index}`}\n style={[\n styles.node,\n styles.nodePending,\n {\n top: spacing / 2 + index * spacing - NODE / 2,\n transform: [{ translateX: offsetAt(index, count, curve) }],\n },\n ]}\n >\n {/* El relleno es un nodo aparte encima del hueco: el color no se\n puede animar en el hilo nativo, la opacidad sí. */}\n <Animated.View\n style={[\n styles.nodeFill,\n {\n backgroundColor: colors.textLight,\n opacity: progress.interpolate({\n inputRange: [index - FADE, index],\n outputRange: [0, 1],\n extrapolate: 'clamp',\n }),\n transform: [\n {\n scale: progress.interpolate({\n inputRange: [index - FADE, index],\n outputRange: [0.4, 1],\n extrapolate: 'clamp',\n }),\n },\n ],\n },\n ]}\n />\n </View>\n ))}\n </View>\n\n {items.map((item, index) => {\n const pending = item.status === 'pending';\n const onRight = (index % 2 === 0) === (startSide === 'right');\n const offset = offsetAt(index, count, curve);\n // La fila entera se corre con la curva, píldora incluida, así que el\n // lado hacia el que se fue pierde ese espacio contra el borde. Se lo\n // devolvemos como padding, más lo que sobresale el halo: sin esto la\n // píldora de más afuera queda cortada al ras.\n const bleed = glow ? GLOW_BLEED : 0;\n const roomLeft = Math.max(0, -offset) + bleed;\n const roomRight = Math.max(0, offset) + bleed;\n\n const pill = (\n <View>\n {glow &&\n GLOW_LAYERS.map((layer) => (\n <View\n key={layer.inset}\n pointerEvents=\"none\"\n style={[\n styles.glow,\n {\n top: layer.inset,\n bottom: layer.inset,\n left: layer.inset,\n right: layer.inset,\n opacity: pending ? layer.opacity / 2 : layer.opacity,\n },\n ]}\n />\n ))}\n <View style={[styles.pill, index === selectedIndex && styles.pillSelected]}>\n {item.emoji ? (\n <Text allowFontScaling={allowFontScaling} style={styles.emoji}>\n {item.emoji}\n </Text>\n ) : (\n !!item.iconName && (\n <Icon name={item.iconName} size={16} color={pending ? colors.textLight : colors.text} />\n )\n )}\n <Text allowFontScaling={allowFontScaling} style={[styles.label, pending && styles.labelPending]}>\n {item.label}\n </Text>\n </View>\n </View>\n );\n\n // 'sequential' deja tocar sólo el que sigue; 'revisit' también los\n // ya recorridos. El que no responde se marca como deshabilitado en\n // vez de quedarse mudo: un botón que no hace nada y no lo dice es\n // peor que un botón apagado.\n const reachable =\n advance === 'sequential'\n ? index === lineTo + 1\n : advance === 'revisit'\n ? index <= lineTo + 1\n : true;\n\n const pressable = onItemPress ? (\n <PressableScale\n onPress={() => onItemPress(index)}\n disabled={!reachable}\n accessibilityRole=\"button\"\n accessibilityLabel={item.label}\n accessibilityState={{ checked: !pending, disabled: !reachable, selected: index === selectedIndex }}\n testID={testID && `${testID}-press-${index}`}\n >\n {pill}\n </PressableScale>\n ) : (\n pill\n );\n\n return (\n <View\n key={`${item.label}-${index}`}\n testID={testID && `${testID}-item-${index}`}\n accessibilityRole={onItemPress ? undefined : 'text'}\n accessibilityLabel={onItemPress ? undefined : item.label}\n accessibilityState={onItemPress ? undefined : { checked: !pending }}\n style={[styles.row, { height: spacing, transform: [{ translateX: offset }] }]}\n >\n <View\n testID={testID && `${testID}-item-${index}-left`}\n style={[styles.side, styles.sideLeft, { paddingLeft: roomLeft }]}\n >\n {!onRight && pressable}\n </View>\n <View style={styles.nodeSlot} />\n <View\n testID={testID && `${testID}-item-${index}-right`}\n style={[styles.side, styles.sideRight, { paddingRight: roomRight }]}\n >\n {onRight && pressable}\n </View>\n </View>\n );\n })}\n </View>\n );\n }\n);\nTimeline.displayName = 'Timeline';\n"
|
|
12
|
+
"content": "import React, { memo, useEffect, useMemo, useRef } from 'react';\nimport { Animated, Easing, Text, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { PressableScale } from '../atoms/PressableScale';\nimport { useReduceMotion } from '../atoms/Transition';\nimport { Icon } from '../icons/Icon';\nimport type { IconName } from '../icons/types';\n\nexport type TimelineStatus = 'done' | 'pending';\n\n/**\n * What a tap can do.\n * - 'free': any milestone responds and the line follows `items` wherever it\n * goes, forward or back. It is the one to use when the path isn't\n * progress, or when something else drives it.\n * - 'sequential': only the one after the reached milestone responds, and the\n * line never goes back. Strict progression.\n * - 'revisit': the next one and all those already reached respond, and the\n * line doesn't go back either. It is the \"I can look at what came before\n * without losing my progress\" case.\n */\nexport type TimelineAdvance = 'free' | 'sequential' | 'revisit';\n\nexport interface TimelineItem {\n label: string;\n /** Glyph from the library's set, to the left of the label. */\n iconName?: IconName;\n /** Emoji to the left of the label. Wins over `iconName`. */\n emoji?: string;\n /** @default 'done' */\n status?: TimelineStatus;\n}\n\nexport interface TimelineProps {\n /** The milestones, in order. Each has a label, optionally an icon or emoji, and its status. */\n items: TimelineItem[];\n /** Pixels the line bows away from the straight path. 0 draws it straight. @default 28 */\n curve?: number;\n /** Vertical distance between one node and the next. @default 84 */\n spacing?: number;\n /** Side the first pill goes to; the rest alternate. @default 'right' */\n startSide?: 'left' | 'right';\n /** Halo behind each pill. @default true */\n glow?: boolean;\n /** Makes each pill tappable and reports its index. */\n onItemPress?: (index: number) => void;\n /**\n * Which taps are allowed, and whether the line may go back. @default 'free'\n *\n * In 'sequential' and 'revisit' the line keeps the furthest point it has\n * been asked to draw, so a parent that hands back a shorter `items` does not\n * undo the progress. Swapping in a different list keeps it too — reset it by\n * remounting (a `key`).\n */\n advance?: TimelineAdvance;\n /**\n * The milestone being looked at right now, highlighted. It is not the\n * progress: it can sit behind the line without pulling it back.\n */\n selectedIndex?: number;\n /**\n * Milliseconds the line takes to travel one gap when a milestone is\n * reached. @default 420\n */\n duration?: number;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst DEFAULT_CURVE = 28;\nconst DEFAULT_SPACING = 84;\nconst NODE = 14;\nconst LINE = 2;\n/** Straight segments per span: with fewer, the curve looks polygonal. */\nconst SEGMENTS = 14;\nconst DOT = 3;\n/** One dot every how many segments: the whole path is dotted underneath. */\nconst DOT_EVERY = 2;\nconst DEFAULT_DURATION = 420;\n/**\n * How long a segment takes to light up, measured in milestones. Small so the\n * front of the line reads as an edge and not as a long gradient.\n */\nconst FADE = 0.06;\n/**\n * Halo layers, from the outside in. There are many and very faint on\n * purpose: with three layers you see the rings, which is exactly the opposite\n * of a blur. The more steps and the smaller the opacity jump, the closer it\n * gets to a gradient.\n */\n/** How far the halo sticks out of the pill: that is the room it needs. */\nconst GLOW_BLEED = 20;\nconst GLOW_LAYERS = [\n { inset: -GLOW_BLEED, opacity: 0.03 },\n { inset: -16, opacity: 0.035 },\n { inset: -12, opacity: 0.04 },\n { inset: -8, opacity: 0.045 },\n { inset: -5, opacity: 0.05 },\n { inset: -2, opacity: 0.055 },\n];\n\nconst useStyles = createStyles((theme) => ({\n root: { alignSelf: 'stretch' },\n row: { flexDirection: 'row', alignItems: 'center' },\n side: { flex: 1, justifyContent: 'center' },\n sideLeft: { alignItems: 'flex-end', paddingRight: theme.tokens.spacing.md },\n sideRight: { alignItems: 'flex-start', paddingLeft: theme.tokens.spacing.md },\n node: {\n position: 'absolute',\n width: NODE,\n height: NODE,\n borderRadius: NODE / 2,\n borderWidth: LINE,\n },\n nodePending: { backgroundColor: 'transparent', borderColor: theme.colors.border },\n // Takes the whole circle minus the border, so the fill doesn't cover it.\n nodeFill: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, borderRadius: NODE / 2 },\n pill: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.sm,\n paddingVertical: theme.tokens.spacing.sm,\n paddingHorizontal: theme.tokens.spacing.lg,\n borderRadius: theme.tokens.radius.full,\n borderWidth: 1,\n borderColor: theme.colors.border,\n backgroundColor: theme.colors.surfaceRaised,\n },\n // Focus is marked with the border, not the fill: the fill is already\n // saying something else (whether the milestone has been reached or not).\n pillSelected: { borderColor: theme.colors.primary, borderWidth: 2 },\n label: { fontSize: theme.tokens.fontSize.md, color: theme.colors.text },\n labelPending: { color: theme.colors.textLight },\n emoji: { fontSize: theme.tokens.fontSize.md },\n glow: {\n position: 'absolute',\n borderRadius: theme.tokens.radius.full,\n backgroundColor: theme.colors.text,\n },\n // The line and the nodes live in the same layer and are positioned with the\n // same coordinates. It is the only thing that guarantees the circle lands on\n // the curve: in the row's flow the node gets shifted by flex —and by the\n // paddings each side needs so its pill isn't clipped—, so it ended up to one\n // side of the line instead of on top of it.\n lineLayer: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, alignItems: 'center' },\n // Gap the width of the node in the row: the pill is set apart from the\n // center the same as before, but the circle is no longer drawn here.\n nodeSlot: { width: NODE },\n segment: { position: 'absolute', width: LINE },\n dot: { position: 'absolute', width: DOT, height: DOT, borderRadius: DOT / 2 },\n}));\n\n/**\n * Horizontal offset of node `index` from the axis. One full wave along the\n * list: the first and the last fall on the axis, and the ones in the middle\n * swing to one side and the other. It is what turns a column into a path.\n */\nfunction offsetAt(index: number, count: number, curve: number): number {\n if (count < 2) return 0;\n return curve * Math.sin((2 * Math.PI * index) / (count - 1));\n}\n\n/**\n * Timeline\n * Milestones on a path: a line that bows from one to the next and fills up to\n * the last one reached, with each label in a pill on alternating sides.\n *\n * Design:\n * - Continuity (Gestalt): one curved line joins the milestones, so they read\n * as a single path; the reached part of it is filled.\n * - Similarity + figure/ground: reached and pending milestones differ only by\n * fill, so status reads at a glance.\n * - Common fate: when a milestone is reached the line travels there, gap by\n * gap (in place with Reduce Motion).\n * - Fitts's law: with `onItemPress` the whole pill is the target.\n * - Screen readers: each milestone is checked or not; with `onItemPress` it\n * is a button, disabled when `advance` doesn't let it respond.\n */\nexport const Timeline = memo(\n ({\n items,\n curve = DEFAULT_CURVE,\n spacing = DEFAULT_SPACING,\n startSide = 'right',\n glow = true,\n onItemPress,\n advance = 'free',\n selectedIndex,\n duration = DEFAULT_DURATION,\n style,\n testID,\n }: TimelineProps) => {\n const styles = useStyles();\n const colors = useColors();\n const allowFontScaling = useAllowFontScaling();\n const reduceMotion = useReduceMotion();\n\n const count = items.length;\n\n /**\n * How far the path has gotten, in milestone units. It is counted from the\n * start and cut at the first pending one: a milestone completed after one\n * that isn't doesn't move the line forward, because a path is a path.\n */\n const reached = useMemo(() => {\n let last = -1;\n for (let i = 0; i < items.length; i += 1) {\n if (items[i]?.status === 'pending') break;\n last = i;\n }\n return last;\n }, [items]);\n\n /**\n * High-water mark: except in 'free', the line stays at the furthest point\n * it reached. Progress belongs to the user, not the render — the parent\n * sending a shorter list can't undo the distance already covered.\n */\n const furthest = useRef(reached);\n if (advance === 'free') furthest.current = reached;\n else furthest.current = Math.max(furthest.current, reached);\n const lineTo = furthest.current;\n\n const progress = useRef(new Animated.Value(lineTo)).current;\n\n useEffect(() => {\n const animation = Animated.timing(progress, {\n toValue: lineTo,\n // One milestone per `duration`: advancing three at once takes three\n // times longer, so you see the path and not a jump.\n duration: reduceMotion ? 0 : duration,\n easing: Easing.inOut(Easing.cubic),\n useNativeDriver: true,\n });\n animation.start();\n return () => animation.stop();\n }, [lineTo, duration, reduceMotion, progress]);\n\n /**\n * Each span split into short straight segments, each with its own length\n * and angle. It is the same idea as `Gradient`: the platform has no curves\n * without a native dependency, so the curve is composed from straight\n * pieces small enough for the eye not to see the break.\n */\n const pieces = useMemo(() => {\n const out: {\n key: string;\n x: number;\n y: number;\n length: number;\n angle: string;\n at: number;\n dotted: boolean;\n }[] = [];\n\n for (let i = 0; i < count - 1; i += 1) {\n for (let step = 0; step < SEGMENTS; step += 1) {\n const from = i + step / SEGMENTS;\n const to = i + (step + 1) / SEGMENTS;\n const x1 = offsetAt(from, count, curve);\n const x2 = offsetAt(to, count, curve);\n const y1 = from * spacing;\n const y2 = to * spacing;\n const dx = x2 - x1;\n const dy = y2 - y1;\n const length = Math.sqrt(dx * dx + dy * dy);\n\n out.push({\n key: `${i}-${step}`,\n x: (x1 + x2) / 2,\n y: (y1 + y2) / 2,\n length,\n // The segment is drawn vertically and rotated to the slope: the\n // angle is measured from the Y axis, not the X.\n angle: `${Math.atan2(dx, dy) * (180 / Math.PI) * -1}deg`,\n // Segment position measured in milestones: it is the same scale as\n // `progress`, so comparing one with the other is direct.\n at: to,\n dotted: step % DOT_EVERY === 0,\n });\n }\n }\n\n return out;\n }, [count, curve, spacing]);\n\n if (count === 0) return null;\n\n return (\n <View\n style={[\n styles.root,\n { height: count * spacing },\n style,\n ]}\n testID={testID}\n >\n <View style={styles.lineLayer} pointerEvents=\"none\">\n {/* The whole path, dotted: it is what is left to travel. */}\n {pieces.map(\n (piece) =>\n piece.dotted && (\n <View\n key={`dot-${piece.key}`}\n style={[\n styles.dot,\n {\n backgroundColor: colors.border,\n top: spacing / 2 + piece.y - DOT / 2,\n transform: [{ translateX: piece.x }],\n },\n ]}\n />\n )\n )}\n\n {/* And on top the solid line, revealed segment by segment as\n `progress` advances. The only thing that changes per frame is\n `opacity`, which means the path is drawn on the native thread. */}\n {pieces.map((piece) => (\n <Animated.View\n key={`line-${piece.key}`}\n style={[\n styles.segment,\n {\n backgroundColor: colors.textLight,\n height: piece.length,\n top: spacing / 2 + piece.y - piece.length / 2,\n opacity: progress.interpolate({\n inputRange: [piece.at - FADE, piece.at],\n outputRange: [0, 1],\n extrapolate: 'clamp',\n }),\n transform: [{ translateX: piece.x }, { rotate: piece.angle }],\n },\n ]}\n />\n ))}\n\n {items.map((item, index) => (\n <View\n key={`node-${item.label}-${index}`}\n style={[\n styles.node,\n styles.nodePending,\n {\n top: spacing / 2 + index * spacing - NODE / 2,\n transform: [{ translateX: offsetAt(index, count, curve) }],\n },\n ]}\n >\n {/* The fill is a separate node on top of the hollow: color can't\n be animated on the native thread, opacity can. */}\n <Animated.View\n style={[\n styles.nodeFill,\n {\n backgroundColor: colors.textLight,\n opacity: progress.interpolate({\n inputRange: [index - FADE, index],\n outputRange: [0, 1],\n extrapolate: 'clamp',\n }),\n transform: [\n {\n scale: progress.interpolate({\n inputRange: [index - FADE, index],\n outputRange: [0.4, 1],\n extrapolate: 'clamp',\n }),\n },\n ],\n },\n ]}\n />\n </View>\n ))}\n </View>\n\n {items.map((item, index) => {\n const pending = item.status === 'pending';\n const onRight = (index % 2 === 0) === (startSide === 'right');\n const offset = offsetAt(index, count, curve);\n // The whole row shifts with the curve, pill included, so the side it\n // moved toward loses that space against the edge. We give it back as\n // padding, plus what the halo sticks out: without this the outermost\n // pill gets clipped flush.\n const bleed = glow ? GLOW_BLEED : 0;\n const roomLeft = Math.max(0, -offset) + bleed;\n const roomRight = Math.max(0, offset) + bleed;\n\n const pill = (\n <View>\n {glow &&\n GLOW_LAYERS.map((layer) => (\n <View\n key={layer.inset}\n pointerEvents=\"none\"\n style={[\n styles.glow,\n {\n top: layer.inset,\n bottom: layer.inset,\n left: layer.inset,\n right: layer.inset,\n opacity: pending ? layer.opacity / 2 : layer.opacity,\n },\n ]}\n />\n ))}\n <View style={[styles.pill, index === selectedIndex && styles.pillSelected]}>\n {item.emoji ? (\n <Text allowFontScaling={allowFontScaling} style={styles.emoji}>\n {item.emoji}\n </Text>\n ) : (\n !!item.iconName && (\n <Icon name={item.iconName} size={16} color={pending ? colors.textLight : colors.text} />\n )\n )}\n <Text allowFontScaling={allowFontScaling} style={[styles.label, pending && styles.labelPending]}>\n {item.label}\n </Text>\n </View>\n </View>\n );\n\n // 'sequential' only allows tapping the next one; 'revisit' also the\n // ones already covered. The one that doesn't respond is marked as\n // disabled instead of staying silent: a button that does nothing\n // and doesn't say so is worse than a disabled button.\n const reachable =\n advance === 'sequential'\n ? index === lineTo + 1\n : advance === 'revisit'\n ? index <= lineTo + 1\n : true;\n\n const pressable = onItemPress ? (\n <PressableScale\n onPress={() => onItemPress(index)}\n disabled={!reachable}\n accessibilityRole=\"button\"\n accessibilityLabel={item.label}\n accessibilityState={{ checked: !pending, disabled: !reachable, selected: index === selectedIndex }}\n testID={testID && `${testID}-press-${index}`}\n >\n {pill}\n </PressableScale>\n ) : (\n pill\n );\n\n return (\n <View\n key={`${item.label}-${index}`}\n testID={testID && `${testID}-item-${index}`}\n accessibilityRole={onItemPress ? undefined : 'text'}\n accessibilityLabel={onItemPress ? undefined : item.label}\n accessibilityState={onItemPress ? undefined : { checked: !pending }}\n style={[styles.row, { height: spacing, transform: [{ translateX: offset }] }]}\n >\n <View\n testID={testID && `${testID}-item-${index}-left`}\n style={[styles.side, styles.sideLeft, { paddingLeft: roomLeft }]}\n >\n {!onRight && pressable}\n </View>\n <View style={styles.nodeSlot} />\n <View\n testID={testID && `${testID}-item-${index}-right`}\n style={[styles.side, styles.sideRight, { paddingRight: roomRight }]}\n >\n {onRight && pressable}\n </View>\n </View>\n );\n })}\n </View>\n );\n }\n);\nTimeline.displayName = 'Timeline';\n"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
package/registry/title.json
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
{
|
|
14
14
|
"path": "atoms/Text.tsx",
|
|
15
|
-
"content": "import React, { memo } from 'react';\nimport {
|
|
15
|
+
"content": "import React, { memo } from 'react';\nimport {\n Text as RNText,\n type AccessibilityRole,\n type StyleProp,\n type TextProps,\n type TextStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\n\nexport interface TypographyProps extends TextProps {\n children?: React.ReactNode;\n style?: StyleProp<TextStyle>;\n color?: string;\n align?: 'left' | 'center' | 'right';\n}\n\nconst useStyles = createStyles((theme) => ({\n title: {\n fontSize: theme.tokens.fontSize.xxl,\n lineHeight: theme.tokens.lineHeight.xl,\n fontWeight: theme.tokens.fontWeight.bold,\n letterSpacing: theme.tokens.letterSpacing.tight,\n color: theme.colors.text,\n },\n subtitle: {\n fontSize: theme.tokens.fontSize.lg,\n lineHeight: theme.tokens.lineHeight.lg,\n fontWeight: theme.tokens.fontWeight.semibold,\n color: theme.colors.text,\n },\n body: {\n fontSize: theme.tokens.fontSize.md,\n lineHeight: theme.tokens.lineHeight.md,\n color: theme.colors.text,\n },\n caption: {\n fontSize: theme.tokens.fontSize.sm,\n lineHeight: theme.tokens.lineHeight.sm,\n color: theme.colors.textLight,\n },\n}));\n\nfunction makeVariant(\n variant: keyof ReturnType<typeof useStyles>,\n defaultColor: 'text' | 'textLight',\n defaultRole?: AccessibilityRole,\n) {\n const Component = memo(({ children, style, color, align = 'left', ...props }: TypographyProps) => {\n const styles = useStyles();\n const colors = useColors();\n const allowFontScaling = useAllowFontScaling();\n return (\n <RNText\n allowFontScaling={allowFontScaling}\n accessibilityRole={defaultRole}\n style={[styles[variant], { color: color || colors[defaultColor], textAlign: align }, style]}\n {...props}\n >\n {children}\n </RNText>\n );\n });\n Component.displayName = String(variant);\n return Component;\n}\n\n/**\n * Screen or section title: the highest step of the typographic hierarchy.\n *\n * Gestalt, focal point (Von Restorff): it is the only text in its block with\n * size `xxl` and weight `bold`, so it breaks the series of Body/Caption and is\n * the first thing read. That is why there should be only one per block: two\n * Titles together cancel each other out. The `tight` tracking offsets the room\n * the large size opens between letters so the word reads as a single shape\n * (Prägnanz), and `lineHeight.xl` keeps the block compact when the title wraps.\n *\n * Accessibility: it is announced with `accessibilityRole=\"header\"`, so\n * VoiceOver/TalkBack can jump from title to title. It can be overridden by\n * passing a different `accessibilityRole`.\n */\nexport const Title = makeVariant('title', 'text', 'header');\n/**\n * Second step of the hierarchy: heads a group within the screen or\n * accompanies a Title with a short piece of data.\n *\n * Gestalt, similarity: it shares its color with Body but steps up size (`lg`)\n * and weight (`semibold`), so it is perceived as part of the same family and,\n * at the same time, as the label of the block that follows. Proximity: it\n * should sit closer to the content it heads than to the previous block. It is\n * not an accessibility heading: if it marks a navigable section, use Title (or\n * pass `accessibilityRole=\"header\"`).\n * `lineHeight.lg` (1.5×) keeps the text legible when it spans several lines.\n */\nexport const Subtitle = makeVariant('subtitle', 'text');\n/**\n * Running text: the default style for paragraphs, descriptions and values.\n *\n * Gestalt, similarity: all reading content shares size (`md`), regular weight\n * and the `text` color, so it is perceived as a single level and the steps\n * above (Title, Subtitle) and below (Caption) stand out by contrast.\n * `lineHeight.md` (≈1.43×) gives the room between lines to read long\n * paragraphs. If `color` is passed, the color/background pair must meet WCAG\n * AA (4.5:1); the theme colors already guarantee it.\n */\nexport const Body = makeVariant('body', 'text');\n/**\n * Secondary text: hints, metadata, dates, help below a field.\n *\n * Gestalt, figure and ground: size `sm` and color `textLight` bring it one\n * step below Body, so it reads as context and doesn't compete with the\n * content. Proximity: it sits right next to the element it explains.\n * `textLight` meets WCAG AA over `surface` and `background` in light and dark\n * (palettes tests); don't lighten it further. `lineHeight.sm` (≈1.33×) keeps\n * the lines together when the hint spans several.\n */\nexport const Caption = makeVariant('caption', 'textLight');\n"
|
|
16
16
|
}
|
|
17
17
|
],
|
|
18
18
|
"registryDependencies": [
|