From c7d89cdc6091f5c5e17c342f73a42a80329f1b84 Mon Sep 17 00:00:00 2001 From: Rajat Sandeep <93584596+rajatsandeepsen@users.noreply.github.com> Date: Fri, 26 Apr 2024 12:53:28 +0530 Subject: [PATCH] fit: lint & typo --- apps/native/components.json | 7 + apps/native/package.json | 11 +- apps/native/src/components/DrawerToggle.tsx | 9 +- apps/native/src/components/Icons.tsx | 38 + apps/native/src/components/ThemeToggle.tsx | 2 +- .../components/primitives/accordion/index.tsx | 216 + .../components/primitives/accordion/types.ts | 52 + .../primitives/alert-dialog/index.tsx | 237 + .../primitives/alert-dialog/types.ts | 48 + .../components/primitives/avatar/index.tsx | 95 + .../primitives}/avatar/types.ts | 6 +- .../components/primitives/checkbox/index.tsx | 101 + .../components/primitives/checkbox/types.ts | 11 + .../primitives/collapsible/index.tsx | 119 + .../primitives/collapsible/types.ts | 18 + .../primitives/context-menu/index.tsx | 626 ++ .../primitives/context-menu/types.ts | 82 + .../components/primitives/dialog/index.tsx | 211 + .../src/components/primitives/dialog/types.ts | 60 + .../primitives/dropdown-menu/index.tsx | 584 ++ .../primitives/dropdown-menu/types.ts | 71 + .../src/components/primitives/hooks/index.ts | 3 + .../primitives/hooks/useAugmentedRef.tsx | 29 + .../primitives/hooks/useControllableState.tsx | 75 + .../primitives/hooks/useRelativePosition.tsx | 227 + .../primitives/hover-card/index.tsx | 271 + .../components/primitives/hover-card/types.ts | 42 + .../src/components/primitives/label/index.tsx | 31 + .../src/components/primitives/label/types.ts | 15 + .../components/primitives/menubar/index.tsx | 624 ++ .../components/primitives/menubar/types.ts | 76 + .../primitives/navigation-menu/index.tsx | 315 + .../primitives/navigation-menu/types.ts | 49 + .../components/primitives/popover/index.tsx | 286 + .../components/primitives/popover/types.ts | 30 + .../src/components/primitives/portal.tsx | 67 + .../primitives/radio-group/index.tsx | 116 + .../primitives/radio-group/types.ts | 15 + .../components/primitives/select/index.tsx | 455 ++ .../src/components/primitives/select/types.ts | 87 + .../components/primitives/separator/index.tsx | 23 + .../primitives}/separator/types.ts | 4 +- .../native/src/components/primitives/slot.tsx | 187 + .../components/primitives/switch/index.tsx | 65 + .../src/components/primitives/switch/types.ts | 11 + .../src/components/primitives/table.tsx | 55 + .../src/components/primitives/tabs/index.tsx | 133 + .../src/components/primitives/tabs/types.ts | 24 + .../primitives/toggle-group/index.tsx | 125 + .../primitives/toggle-group/types.ts | 37 + .../components/primitives/toggle/index.tsx | 37 + .../src/components/primitives/toggle/types.ts | 7 + .../components/primitives/tooltip/index.tsx | 271 + .../components/primitives/tooltip/types.ts | 44 + .../native/src/components/primitives/types.ts | 105 + .../native/src/components/primitives/utils.ts | 61 + apps/native/src/components/ui/accordion.tsx | 300 +- .../native/src/components/ui/alert-dialog.tsx | 421 +- apps/native/src/components/ui/alert.tsx | 129 - apps/native/src/components/ui/avatar.tsx | 93 +- apps/native/src/components/ui/badge.tsx | 101 +- .../src/components/ui/bottom-sheet.native.tsx | 356 -- .../native/src/components/ui/bottom-sheet.tsx | 257 - apps/native/src/components/ui/button.tsx | 209 +- apps/native/src/components/ui/calendar.tsx | 147 - apps/native/src/components/ui/card.tsx | 179 +- apps/native/src/components/ui/checkbox.tsx | 73 +- apps/native/src/components/ui/collapsible.tsx | 150 +- apps/native/src/components/ui/combobox.tsx | 229 - apps/native/src/components/ui/command.tsx | 446 -- .../native/src/components/ui/context-menu.tsx | 261 +- apps/native/src/components/ui/data-table.tsx | 152 - apps/native/src/components/ui/dialog.tsx | 386 +- .../src/components/ui/dropdown-menu.tsx | 263 +- apps/native/src/components/ui/form.tsx | 684 --- apps/native/src/components/ui/hover-card.tsx | 45 + apps/native/src/components/ui/input.tsx | 35 +- apps/native/src/components/ui/label.tsx | 51 +- apps/native/src/components/ui/menubar.tsx | 263 +- .../src/components/ui/navigation-menu.tsx | 177 + apps/native/src/components/ui/popover.tsx | 343 +- apps/native/src/components/ui/progress.tsx | 56 - apps/native/src/components/ui/radio-group.tsx | 156 +- .../native/src/components/ui/section-list.tsx | 125 - apps/native/src/components/ui/select.tsx | 369 +- apps/native/src/components/ui/separator.tsx | 36 +- apps/native/src/components/ui/skeleton.tsx | 69 +- apps/native/src/components/ui/slider.tsx | 31 - apps/native/src/components/ui/switch.tsx | 122 +- apps/native/src/components/ui/table.tsx | 259 +- apps/native/src/components/ui/tabs.tsx | 228 +- apps/native/src/components/ui/text.tsx | 24 + apps/native/src/components/ui/textarea.tsx | 59 +- apps/native/src/components/ui/toast.tsx | 51 - .../native/src/components/ui/toggle-group.tsx | 247 +- apps/native/src/components/ui/toggle.tsx | 173 +- apps/native/src/components/ui/tooltip.tsx | 36 + apps/native/src/components/ui/typography.tsx | 204 + .../src/components/universal-ui/accordion.tsx | 130 - .../components/universal-ui/alert-dialog.tsx | 191 - .../src/components/universal-ui/alert.tsx | 105 - .../components/universal-ui/aspect-ratio.tsx | 5 - .../src/components/universal-ui/avatar.tsx | 48 - .../src/components/universal-ui/badge.tsx | 58 - .../src/components/universal-ui/button.tsx | 96 - .../src/components/universal-ui/card.tsx | 92 - .../src/components/universal-ui/checkbox.tsx | 36 - .../components/universal-ui/collapsible.tsx | 9 - .../components/universal-ui/context-menu.tsx | 258 - .../src/components/universal-ui/dialog.tsx | 172 - .../components/universal-ui/dropdown-menu.tsx | 260 - .../components/universal-ui/hover-card.tsx | 45 - .../src/components/universal-ui/input.tsx | 26 - .../src/components/universal-ui/label.tsx | 35 - .../src/components/universal-ui/menubar.tsx | 269 - .../universal-ui/navigation-menu.tsx | 186 - .../src/components/universal-ui/popover.tsx | 41 - .../src/components/universal-ui/progress.tsx | 65 - .../components/universal-ui/radio-group.tsx | 42 - .../src/components/universal-ui/select.tsx | 194 - .../src/components/universal-ui/separator.tsx | 28 - .../src/components/universal-ui/tabs.tsx | 65 - .../src/components/universal-ui/textarea.tsx | 40 - .../components/universal-ui/toggle-group.tsx | 97 - .../src/components/universal-ui/toggle.tsx | 95 - .../src/components/universal-ui/tooltip.tsx | 38 - .../components/universal-ui/typography.tsx | 257 - apps/native/src/lib/rn-primitives/README.md | 15 - .../accordion/accordion-native.tsx | 240 - .../rn-primitives/accordion/accordion-web.tsx | 282 - .../src/lib/rn-primitives/accordion/index.ts | 38 - .../src/lib/rn-primitives/accordion/types.ts | 44 - .../alert-dialog/alert-dialog-native.tsx | 261 - .../alert-dialog/alert-dialog-web.tsx | 277 - .../lib/rn-primitives/alert-dialog/index.ts | 53 - .../lib/rn-primitives/alert-dialog/types.ts | 42 - .../aspect-ratio/aspect-ratio-native.tsx | 19 - .../lib/rn-primitives/aspect-ratio/index.ts | 1 - .../lib/rn-primitives/aspect-ratio/types.ts | 8 - .../rn-primitives/avatar/avatar-native.tsx | 140 - .../src/lib/rn-primitives/avatar/index.ts | 1 - .../checkbox/checkbox-native.tsx | 112 - .../rn-primitives/checkbox/checkbox-web.tsx | 131 - .../src/lib/rn-primitives/checkbox/index.ts | 13 - .../src/lib/rn-primitives/checkbox/types.ts | 11 - .../collapsible/collapsible-native.tsx | 111 - .../collapsible/collapsible-web.tsx | 152 - .../lib/rn-primitives/collapsible/index.ts | 18 - .../lib/rn-primitives/collapsible/types.ts | 11 - .../context-menu/context-menu-native.tsx | 745 --- .../context-menu/context-menu-web.tsx | 546 -- .../lib/rn-primitives/context-menu/index.ts | 93 - .../lib/rn-primitives/context-menu/types.ts | 79 - .../rn-primitives/dialog/dialog-native.tsx | 244 - .../lib/rn-primitives/dialog/dialog-web.tsx | 225 - .../src/lib/rn-primitives/dialog/index.ts | 48 - .../src/lib/rn-primitives/dialog/types.ts | 53 - .../dropdown-menu/dropdown-menu-native.tsx | 706 --- .../dropdown-menu/dropdown-menu-web.tsx | 555 -- .../lib/rn-primitives/dropdown-menu/index.ts | 93 - .../lib/rn-primitives/dropdown-menu/types.ts | 71 - .../rn-primitives/hooks/useAugmentedRef.tsx | 30 - .../hooks/useRelativePosition.tsx | 235 - .../lib/rn-primitives/hooks/useTrigger.tsx | 59 - .../hover-card/hover-card-native.tsx | 10 - .../hover-card/hover-card-web.tsx | 131 - .../src/lib/rn-primitives/hover-card/index.ts | 33 - .../src/lib/rn-primitives/hover-card/types.ts | 24 - .../src/lib/rn-primitives/label/index.ts | 13 - .../lib/rn-primitives/label/label-native.tsx | 31 - .../src/lib/rn-primitives/label/label-web.tsx | 36 - .../src/lib/rn-primitives/label/types.ts | 15 - .../src/lib/rn-primitives/menubar/index.ts | 102 - .../rn-primitives/menubar/menubar-native.tsx | 757 --- .../lib/rn-primitives/menubar/menubar-web.tsx | 577 -- .../src/lib/rn-primitives/menubar/types.ts | 76 - .../rn-primitives/navigation-menu/index.ts | 58 - .../navigation-menu-native.tsx | 369 -- .../navigation-menu/navigation-menu-web.tsx | 297 - .../rn-primitives/navigation-menu/types.ts | 49 - .../src/lib/rn-primitives/popover/index.ts | 38 - .../rn-primitives/popover/popover-native.tsx | 352 -- .../lib/rn-primitives/popover/popover-web.tsx | 187 - .../src/lib/rn-primitives/popover/types.ts | 24 - .../src/lib/rn-primitives/portal/index.ts | 1 - .../rn-primitives/portal/portal-native.tsx | 67 - .../src/lib/rn-primitives/progress/index.ts | 13 - .../progress/progress-native.tsx | 72 - .../rn-primitives/progress/progress-web.tsx | 43 - .../src/lib/rn-primitives/progress/types.ts | 7 - .../lib/rn-primitives/radio-group/index.ts | 17 - .../radio-group/radio-group-native.tsx | 127 - .../radio-group/radio-group-web.tsx | 86 - .../lib/rn-primitives/radio-group/types.ts | 15 - .../src/lib/rn-primitives/select/index.ts | 90 - .../rn-primitives/select/select-native.tsx | 526 -- .../lib/rn-primitives/select/select-web.tsx | 317 - .../src/lib/rn-primitives/select/types.ts | 76 - .../src/lib/rn-primitives/separator/index.ts | 1 - .../separator/separator-native.tsx | 23 - .../src/lib/rn-primitives/slider/index.ts | 24 - .../rn-primitives/slider/slider-native.tsx | 86 - .../lib/rn-primitives/slider/slider-web.tsx | 84 - .../src/lib/rn-primitives/slider/types.ts | 24 - .../src/lib/rn-primitives/slot/index.ts | 1 - .../lib/rn-primitives/slot/slot-native.tsx | 205 - .../src/lib/rn-primitives/switch/index.ts | 14 - .../rn-primitives/switch/switch-native.tsx | 66 - .../lib/rn-primitives/switch/switch-web.tsx | 77 - .../src/lib/rn-primitives/switch/types.ts | 11 - .../src/lib/rn-primitives/table/index.ts | 1 - .../lib/rn-primitives/table/table-native.tsx | 67 - .../src/lib/rn-primitives/tabs/index.ts | 34 - .../lib/rn-primitives/tabs/tabs-native.tsx | 147 - .../src/lib/rn-primitives/tabs/tabs-web.tsx | 117 - .../src/lib/rn-primitives/tabs/types.ts | 24 - .../src/lib/rn-primitives/toast/index.ts | 1 - .../lib/rn-primitives/toast/toast-native.tsx | 156 - .../src/lib/rn-primitives/toast/types.ts | 8 - .../lib/rn-primitives/toggle-group/index.ts | 27 - .../toggle-group/toggle-group-native.tsx | 141 - .../toggle-group/toggle-group-web.tsx | 136 - .../lib/rn-primitives/toggle-group/types.ts | 37 - .../src/lib/rn-primitives/toggle/index.ts | 9 - .../rn-primitives/toggle/toggle-native.tsx | 50 - .../lib/rn-primitives/toggle/toggle-web.tsx | 50 - .../src/lib/rn-primitives/toggle/types.ts | 7 - .../src/lib/rn-primitives/toolbar/index.ts | 30 - .../rn-primitives/toolbar/toolbar-native.tsx | 155 - .../lib/rn-primitives/toolbar/toolbar-web.tsx | 152 - .../src/lib/rn-primitives/toolbar/types.ts | 39 - .../src/lib/rn-primitives/tooltip/index.ts | 28 - .../rn-primitives/tooltip/tooltip-native.tsx | 329 - .../lib/rn-primitives/tooltip/tooltip-web.tsx | 172 - .../src/lib/rn-primitives/tooltip/types.ts | 38 - apps/native/src/lib/rn-primitives/types.ts | 109 - .../src/lib/rn-primitives/utils/index.ts | 67 - apps/native/src/lib/useColorScheme.tsx | 11 + apps/web/tsconfig.json | 4 +- biome.json | 5 +- pnpm-lock.yaml | 5379 ++++++++++------- 241 files changed, 12510 insertions(+), 23618 deletions(-) create mode 100644 apps/native/components.json create mode 100644 apps/native/src/components/Icons.tsx create mode 100644 apps/native/src/components/primitives/accordion/index.tsx create mode 100644 apps/native/src/components/primitives/accordion/types.ts create mode 100644 apps/native/src/components/primitives/alert-dialog/index.tsx create mode 100644 apps/native/src/components/primitives/alert-dialog/types.ts create mode 100644 apps/native/src/components/primitives/avatar/index.tsx rename apps/native/src/{lib/rn-primitives => components/primitives}/avatar/types.ts (51%) create mode 100644 apps/native/src/components/primitives/checkbox/index.tsx create mode 100644 apps/native/src/components/primitives/checkbox/types.ts create mode 100644 apps/native/src/components/primitives/collapsible/index.tsx create mode 100644 apps/native/src/components/primitives/collapsible/types.ts create mode 100644 apps/native/src/components/primitives/context-menu/index.tsx create mode 100644 apps/native/src/components/primitives/context-menu/types.ts create mode 100644 apps/native/src/components/primitives/dialog/index.tsx create mode 100644 apps/native/src/components/primitives/dialog/types.ts create mode 100644 apps/native/src/components/primitives/dropdown-menu/index.tsx create mode 100644 apps/native/src/components/primitives/dropdown-menu/types.ts create mode 100644 apps/native/src/components/primitives/hooks/index.ts create mode 100644 apps/native/src/components/primitives/hooks/useAugmentedRef.tsx create mode 100644 apps/native/src/components/primitives/hooks/useControllableState.tsx create mode 100644 apps/native/src/components/primitives/hooks/useRelativePosition.tsx create mode 100644 apps/native/src/components/primitives/hover-card/index.tsx create mode 100644 apps/native/src/components/primitives/hover-card/types.ts create mode 100644 apps/native/src/components/primitives/label/index.tsx create mode 100644 apps/native/src/components/primitives/label/types.ts create mode 100644 apps/native/src/components/primitives/menubar/index.tsx create mode 100644 apps/native/src/components/primitives/menubar/types.ts create mode 100644 apps/native/src/components/primitives/navigation-menu/index.tsx create mode 100644 apps/native/src/components/primitives/navigation-menu/types.ts create mode 100644 apps/native/src/components/primitives/popover/index.tsx create mode 100644 apps/native/src/components/primitives/popover/types.ts create mode 100644 apps/native/src/components/primitives/portal.tsx create mode 100644 apps/native/src/components/primitives/radio-group/index.tsx create mode 100644 apps/native/src/components/primitives/radio-group/types.ts create mode 100644 apps/native/src/components/primitives/select/index.tsx create mode 100644 apps/native/src/components/primitives/select/types.ts create mode 100644 apps/native/src/components/primitives/separator/index.tsx rename apps/native/src/{lib/rn-primitives => components/primitives}/separator/types.ts (51%) create mode 100644 apps/native/src/components/primitives/slot.tsx create mode 100644 apps/native/src/components/primitives/switch/index.tsx create mode 100644 apps/native/src/components/primitives/switch/types.ts create mode 100644 apps/native/src/components/primitives/table.tsx create mode 100644 apps/native/src/components/primitives/tabs/index.tsx create mode 100644 apps/native/src/components/primitives/tabs/types.ts create mode 100644 apps/native/src/components/primitives/toggle-group/index.tsx create mode 100644 apps/native/src/components/primitives/toggle-group/types.ts create mode 100644 apps/native/src/components/primitives/toggle/index.tsx create mode 100644 apps/native/src/components/primitives/toggle/types.ts create mode 100644 apps/native/src/components/primitives/tooltip/index.tsx create mode 100644 apps/native/src/components/primitives/tooltip/types.ts create mode 100644 apps/native/src/components/primitives/types.ts create mode 100644 apps/native/src/components/primitives/utils.ts delete mode 100644 apps/native/src/components/ui/alert.tsx delete mode 100644 apps/native/src/components/ui/bottom-sheet.native.tsx delete mode 100644 apps/native/src/components/ui/bottom-sheet.tsx delete mode 100644 apps/native/src/components/ui/calendar.tsx delete mode 100644 apps/native/src/components/ui/combobox.tsx delete mode 100644 apps/native/src/components/ui/command.tsx delete mode 100644 apps/native/src/components/ui/data-table.tsx delete mode 100644 apps/native/src/components/ui/form.tsx create mode 100644 apps/native/src/components/ui/hover-card.tsx create mode 100644 apps/native/src/components/ui/navigation-menu.tsx delete mode 100644 apps/native/src/components/ui/progress.tsx delete mode 100644 apps/native/src/components/ui/section-list.tsx delete mode 100644 apps/native/src/components/ui/slider.tsx create mode 100644 apps/native/src/components/ui/text.tsx delete mode 100644 apps/native/src/components/ui/toast.tsx create mode 100644 apps/native/src/components/ui/tooltip.tsx create mode 100644 apps/native/src/components/ui/typography.tsx delete mode 100644 apps/native/src/components/universal-ui/accordion.tsx delete mode 100644 apps/native/src/components/universal-ui/alert-dialog.tsx delete mode 100644 apps/native/src/components/universal-ui/alert.tsx delete mode 100644 apps/native/src/components/universal-ui/aspect-ratio.tsx delete mode 100644 apps/native/src/components/universal-ui/avatar.tsx delete mode 100644 apps/native/src/components/universal-ui/badge.tsx delete mode 100644 apps/native/src/components/universal-ui/button.tsx delete mode 100644 apps/native/src/components/universal-ui/card.tsx delete mode 100644 apps/native/src/components/universal-ui/checkbox.tsx delete mode 100644 apps/native/src/components/universal-ui/collapsible.tsx delete mode 100644 apps/native/src/components/universal-ui/context-menu.tsx delete mode 100644 apps/native/src/components/universal-ui/dialog.tsx delete mode 100644 apps/native/src/components/universal-ui/dropdown-menu.tsx delete mode 100644 apps/native/src/components/universal-ui/hover-card.tsx delete mode 100644 apps/native/src/components/universal-ui/input.tsx delete mode 100644 apps/native/src/components/universal-ui/label.tsx delete mode 100644 apps/native/src/components/universal-ui/menubar.tsx delete mode 100644 apps/native/src/components/universal-ui/navigation-menu.tsx delete mode 100644 apps/native/src/components/universal-ui/popover.tsx delete mode 100644 apps/native/src/components/universal-ui/progress.tsx delete mode 100644 apps/native/src/components/universal-ui/radio-group.tsx delete mode 100644 apps/native/src/components/universal-ui/select.tsx delete mode 100644 apps/native/src/components/universal-ui/separator.tsx delete mode 100644 apps/native/src/components/universal-ui/tabs.tsx delete mode 100644 apps/native/src/components/universal-ui/textarea.tsx delete mode 100644 apps/native/src/components/universal-ui/toggle-group.tsx delete mode 100644 apps/native/src/components/universal-ui/toggle.tsx delete mode 100644 apps/native/src/components/universal-ui/tooltip.tsx delete mode 100644 apps/native/src/components/universal-ui/typography.tsx delete mode 100644 apps/native/src/lib/rn-primitives/README.md delete mode 100644 apps/native/src/lib/rn-primitives/accordion/accordion-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/accordion/accordion-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/accordion/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/accordion/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/alert-dialog/alert-dialog-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/alert-dialog/alert-dialog-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/alert-dialog/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/alert-dialog/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/aspect-ratio/aspect-ratio-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/aspect-ratio/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/aspect-ratio/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/avatar/avatar-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/avatar/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/checkbox/checkbox-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/checkbox/checkbox-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/checkbox/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/checkbox/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/collapsible/collapsible-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/collapsible/collapsible-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/collapsible/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/collapsible/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/context-menu/context-menu-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/context-menu/context-menu-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/context-menu/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/context-menu/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/dialog/dialog-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/dialog/dialog-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/dialog/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/dialog/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/dropdown-menu/dropdown-menu-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/dropdown-menu/dropdown-menu-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/dropdown-menu/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/dropdown-menu/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/hooks/useAugmentedRef.tsx delete mode 100644 apps/native/src/lib/rn-primitives/hooks/useRelativePosition.tsx delete mode 100644 apps/native/src/lib/rn-primitives/hooks/useTrigger.tsx delete mode 100644 apps/native/src/lib/rn-primitives/hover-card/hover-card-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/hover-card/hover-card-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/hover-card/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/hover-card/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/label/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/label/label-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/label/label-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/label/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/menubar/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/menubar/menubar-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/menubar/menubar-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/menubar/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/navigation-menu/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/navigation-menu/navigation-menu-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/navigation-menu/navigation-menu-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/navigation-menu/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/popover/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/popover/popover-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/popover/popover-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/popover/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/portal/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/portal/portal-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/progress/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/progress/progress-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/progress/progress-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/progress/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/radio-group/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/radio-group/radio-group-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/radio-group/radio-group-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/radio-group/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/select/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/select/select-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/select/select-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/select/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/separator/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/separator/separator-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/slider/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/slider/slider-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/slider/slider-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/slider/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/slot/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/slot/slot-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/switch/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/switch/switch-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/switch/switch-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/switch/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/table/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/table/table-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/tabs/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/tabs/tabs-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/tabs/tabs-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/tabs/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/toast/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/toast/toast-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/toast/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/toggle-group/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/toggle-group/toggle-group-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/toggle-group/toggle-group-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/toggle-group/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/toggle/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/toggle/toggle-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/toggle/toggle-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/toggle/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/toolbar/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/toolbar/toolbar-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/toolbar/toolbar-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/toolbar/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/tooltip/index.ts delete mode 100644 apps/native/src/lib/rn-primitives/tooltip/tooltip-native.tsx delete mode 100644 apps/native/src/lib/rn-primitives/tooltip/tooltip-web.tsx delete mode 100644 apps/native/src/lib/rn-primitives/tooltip/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/types.ts delete mode 100644 apps/native/src/lib/rn-primitives/utils/index.ts create mode 100644 apps/native/src/lib/useColorScheme.tsx diff --git a/apps/native/components.json b/apps/native/components.json new file mode 100644 index 0000000..8eca636 --- /dev/null +++ b/apps/native/components.json @@ -0,0 +1,7 @@ +{ + "platforms": "native-only", + "aliases": { + "components": "~/components", + "lib": "~/lib" + } +} \ No newline at end of file diff --git a/apps/native/package.json b/apps/native/package.json index c97f8b0..c4abd3c 100644 --- a/apps/native/package.json +++ b/apps/native/package.json @@ -16,6 +16,8 @@ }, "dependencies": { "@expo/metro-config": "^0.10.7", + "@react-native-async-storage/async-storage": "^1.23.1", + "@react-navigation/drawer": "^6.6.15", "@shopify/flash-list": "1.4.3", "@tanstack/react-query": "^5.8.1", "@trpc/client": "next", @@ -26,28 +28,31 @@ "expo": "^49.0.18", "expo-constants": "~14.4.2", "expo-linking": "~5.0.2", + "expo-navigation-bar": "^2.8.1", "expo-router": "2.0.12", "expo-splash-screen": "~0.22.0", "expo-status-bar": "~1.7.1", "nativewind": "^4.0.13", + "lucide-react-native": "^0.373.0", "react": "18.2.0", "react-dom": "18.2.0", "react-native": "0.72.7", "react-native-gesture-handler": "~2.12.0", - "react-native-reanimated": "~3.3.0", + "react-native-reanimated": "3.8.0", "react-native-safe-area-context": "4.6.3", "react-native-screens": "~3.22.1", "superjson": "2.2.0", - "tailwind-merge": "^2.2.1" + "tailwind-merge": "^2.2.1", + "zustand": "^4.5.2" }, "devDependencies": { - "@biomejs/biome": "^1.5.3", "@acme/api": "workspace:^0.1.0", "@acme/tailwind-config": "workspace:^0.1.0", "@acme/tsconfig": "workspace:^0.1.0", "@babel/core": "^7.23.2", "@babel/preset-env": "^7.23.2", "@babel/runtime": "^7.23.2", + "@biomejs/biome": "^1.5.3", "@expo/config-plugins": "^7.2.5", "@types/babel__core": "^7.20.4", "@types/react": "^18.2.37", diff --git a/apps/native/src/components/DrawerToggle.tsx b/apps/native/src/components/DrawerToggle.tsx index 4197b3b..3385ab5 100644 --- a/apps/native/src/components/DrawerToggle.tsx +++ b/apps/native/src/components/DrawerToggle.tsx @@ -1,11 +1,14 @@ -import { DrawerNavigationProp } from "@react-navigation/drawer"; +import type { DrawerNavigationProp } from "@react-navigation/drawer"; import { useNavigation } from "expo-router"; -import { AlignJustify } from "lucide-react-native"; import { Pressable, View } from "react-native"; +import { AlignJustify } from "~/components/Icons"; import { cn } from "~/lib/utils"; export function DrawerToggle() { - const navigation = useNavigation>(); + const navigation = useNavigation>(); return ( (null); + +const Root = React.forwardRef( + ( + { + asChild, + type, + disabled, + collapsible = true, + value: valueProp, + onValueChange: onValueChangeProps, + defaultValue, + ...viewProps + }, + ref + ) => { + const [value = type === 'multiple' ? [] : undefined, onValueChange] = useControllableState< + (string | undefined) | string[] + >({ + prop: valueProp, + defaultProp: defaultValue, + onChange: onValueChangeProps as (state: string | string[] | undefined) => void, + }); + + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Root.displayName = 'RootNativeAccordion'; + +function useRootContext() { + const context = React.useContext(AccordionContext); + if (!context) { + throw new Error( + 'Accordion compound components cannot be rendered outside the Accordion component' + ); + } + return context; +} + +type AccordionItemContext = AccordionItemProps & { + nativeID: string; + isExpanded: boolean; +}; + +const AccordionItemContext = React.createContext(null); + +const Item = React.forwardRef( + ({ asChild, value, disabled, ...viewProps }, ref) => { + const { value: rootValue } = useRootContext(); + const nativeID = React.useId(); + + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Item.displayName = 'ItemNativeAccordion'; + +function useItemContext() { + const context = React.useContext(AccordionItemContext); + if (!context) { + throw new Error( + 'AccordionItem compound components cannot be rendered outside the AccordionItem component' + ); + } + return context; +} + +const Header = React.forwardRef(({ asChild, ...props }, ref) => { + const { disabled: rootDisabled } = useRootContext(); + const { disabled: itemDisabled, isExpanded } = useItemContext(); + + const Component = asChild ? Slot.View : View; + return ( + + ); +}); + +Header.displayName = 'HeaderNativeAccordion'; + +const Trigger = React.forwardRef( + ({ asChild, onPress: onPressProp, disabled: disabledProp, ...props }, ref) => { + const { + disabled: rootDisabled, + type, + onValueChange, + value: rootValue, + collapsible, + } = useRootContext(); + const { nativeID, disabled: itemDisabled, value, isExpanded } = useItemContext(); + + function onPress(ev: GestureResponderEvent) { + if (rootDisabled || itemDisabled) return; + if (type === 'single') { + const newValue = collapsible ? (value === rootValue ? undefined : value) : value; + onValueChange(newValue); + } + if (type === 'multiple') { + const rootToArray = toStringArray(rootValue); + const newValue = collapsible + ? rootToArray.includes(value) + ? rootToArray.filter((val) => val !== value) + : rootToArray.concat(value) + : [...new Set(rootToArray.concat(value))]; + // @ts-ignore - `newValue` is of type `string[]` which is OK + onValueChange(newValue); + } + onPressProp?.(ev); + } + + const isDisabled = disabledProp || rootDisabled || itemDisabled; + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Trigger.displayName = 'TriggerNativeAccordion'; + +const Content = React.forwardRef( + ({ asChild, forceMount, ...props }, ref) => { + const { type } = useRootContext(); + const { nativeID, isExpanded } = useItemContext(); + + if (!forceMount) { + if (!isExpanded) { + return null; + } + } + + const Component = asChild ? Slot.View : View; + return ( + + ); + } +); + +Content.displayName = 'ContentNativeAccordion'; + +export { Content, Header, Item, Root, Trigger, useRootContext, useItemContext }; + +function toStringArray(value?: string | string[]) { + return Array.isArray(value) ? value : value ? [value] : []; +} + +function isItemExpanded(rootValue: string | string[] | undefined, value: string) { + return Array.isArray(rootValue) ? rootValue.includes(value) : rootValue === value; +} diff --git a/apps/native/src/components/primitives/accordion/types.ts b/apps/native/src/components/primitives/accordion/types.ts new file mode 100644 index 0000000..1fc7e7f --- /dev/null +++ b/apps/native/src/components/primitives/accordion/types.ts @@ -0,0 +1,52 @@ +import type { ForceMountable } from "~/components/primitives/types"; + +type RootContext = { + type: "single" | "multiple"; + value: (string | undefined) | string[]; + onValueChange: + | ((value: string | undefined) => void) + | ((value: string[]) => void); + collapsible: boolean; + disabled?: boolean; +}; + +type SingleRootProps = { + type: "single"; + defaultValue?: string | undefined; + value?: string | undefined; + onValueChange?: (value: string | undefined) => void; +}; + +type MultipleRootProps = { + type: "multiple"; + defaultValue?: string[]; + value?: string[]; + onValueChange?: (value: string[]) => void; +}; + +type AccordionRootProps = (SingleRootProps | MultipleRootProps) & { + defaultValue?: string | string[]; + disabled?: boolean; + collapsible?: boolean; + /** + * Platform: WEB ONLY + */ + dir?: "ltr" | "rtl"; + /** + * Platform: WEB ONLY + */ + orientation?: "vertical" | "horizontal"; +}; + +type AccordionItemProps = { + value: string; + disabled?: boolean; +}; +type AccordionContentProps = ForceMountable; + +export type { + AccordionContentProps, + AccordionItemProps, + AccordionRootProps, + RootContext, +}; diff --git a/apps/native/src/components/primitives/alert-dialog/index.tsx b/apps/native/src/components/primitives/alert-dialog/index.tsx new file mode 100644 index 0000000..8dbae4f --- /dev/null +++ b/apps/native/src/components/primitives/alert-dialog/index.tsx @@ -0,0 +1,237 @@ +import { useControllableState } from '~/components/primitives/hooks'; +import { Portal as RNPPortal } from '~/components/primitives/portal'; +import * as Slot from '~/components/primitives/slot'; +import type { + PressableRef, + SlottablePressableProps, + SlottableTextProps, + SlottableViewProps, + TextRef, + ViewRef, +} from '~/components/primitives/types'; +import * as React from 'react'; +import { BackHandler, Pressable, Text, View, type GestureResponderEvent } from 'react-native'; +import type { + AlertDialogContentProps, + AlertDialogOverlayProps, + AlertDialogPortalProps, + AlertDialogRootProps, + RootContext, +} from './types'; + +const AlertDialogContext = React.createContext<(RootContext & { nativeID: string }) | null>(null); + +const Root = React.forwardRef( + ({ asChild, open: openProp, defaultOpen, onOpenChange: onOpenChangeProp, ...viewProps }, ref) => { + const nativeID = React.useId(); + const [open = false, onOpenChange] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen, + onChange: onOpenChangeProp, + }); + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Root.displayName = 'RootNativeAlertDialog'; + +function useRootContext() { + const context = React.useContext(AlertDialogContext); + if (!context) { + throw new Error( + 'AlertDialog compound components cannot be rendered outside the AlertDialog component' + ); + } + return context; +} + +const Trigger = React.forwardRef( + ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { + const { open: value, onOpenChange } = useRootContext(); + + function onPress(ev: GestureResponderEvent) { + onOpenChange(!value); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Trigger.displayName = 'TriggerNativeAlertDialog'; + +/** + * @warning when using a custom ``, you might have to adjust the Content's sideOffset to account for nav elements like headers. + */ +function Portal({ forceMount, hostName, children }: AlertDialogPortalProps) { + const value = useRootContext(); + + if (!forceMount) { + if (!value.open) { + return null; + } + } + + return ( + + {children} + + ); +} + +const Overlay = React.forwardRef( + ({ asChild, forceMount, ...props }, ref) => { + const { open: value } = useRootContext(); + + if (!forceMount) { + if (!value) { + return null; + } + } + + const Component = asChild ? Slot.View : View; + return ; + } +); + +Overlay.displayName = 'OverlayNativeAlertDialog'; + +const Content = React.forwardRef( + ({ asChild, forceMount, ...props }, ref) => { + const { open: value, nativeID, onOpenChange } = useRootContext(); + + React.useEffect(() => { + const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { + onOpenChange(false); + return true; + }); + + return () => { + backHandler.remove(); + }; + }, []); + + if (!forceMount) { + if (!value) { + return null; + } + } + + const Component = asChild ? Slot.View : View; + return ( + + ); + } +); + +Content.displayName = 'ContentNativeAlertDialog'; + +const Cancel = React.forwardRef( + ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { + const { onOpenChange } = useRootContext(); + + function onPress(ev: GestureResponderEvent) { + if (disabled) return; + onOpenChange(false); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Cancel.displayName = 'CloseNativeAlertDialog'; + +const Action = React.forwardRef( + ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { + const { onOpenChange } = useRootContext(); + + function onPress(ev: GestureResponderEvent) { + if (disabled) return; + onOpenChange(false); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Action.displayName = 'ActionNativeAlertDialog'; + +const Title = React.forwardRef(({ asChild, ...props }, ref) => { + const { nativeID } = useRootContext(); + const Component = asChild ? Slot.Text : Text; + return ; +}); + +Title.displayName = 'TitleNativeAlertDialog'; + +const Description = React.forwardRef(({ asChild, ...props }, ref) => { + const { nativeID } = useRootContext(); + const Component = asChild ? Slot.Text : Text; + return ; +}); + +Description.displayName = 'DescriptionNativeAlertDialog'; + +export { + Action, + Cancel, + Content, + Description, + Overlay, + Portal, + Root, + Title, + Trigger, + useRootContext, +}; diff --git a/apps/native/src/components/primitives/alert-dialog/types.ts b/apps/native/src/components/primitives/alert-dialog/types.ts new file mode 100644 index 0000000..b60fcc1 --- /dev/null +++ b/apps/native/src/components/primitives/alert-dialog/types.ts @@ -0,0 +1,48 @@ +import type { ForceMountable } from '~/components/primitives/types'; + +type AlertDialogRootProps = { + open?: boolean; + onOpenChange?: (value: boolean) => void; + defaultOpen?: boolean; +}; + +interface RootContext { + open: boolean; + onOpenChange: (value: boolean) => void; +} + +interface AlertDialogPortalProps extends ForceMountable { + children: React.ReactNode; + /** + * Platform: NATIVE ONLY + */ + hostName?: string; + /** + * Platform: WEB ONLY + */ + container?: HTMLElement | null | undefined; +} +type AlertDialogOverlayProps = ForceMountable; + +type AlertDialogContentProps = ForceMountable & { + /** + * Platform: WEB ONLY + */ + onOpenAutoFocus?: (ev: Event) => void; + /** + * Platform: WEB ONLY + */ + onCloseAutoFocus?: (ev: Event) => void; + /** + * Platform: WEB ONLY + */ + onEscapeKeyDown?: (ev: Event) => void; +}; + +export type { + AlertDialogRootProps, + AlertDialogPortalProps, + AlertDialogOverlayProps, + AlertDialogContentProps, + RootContext, +}; diff --git a/apps/native/src/components/primitives/avatar/index.tsx b/apps/native/src/components/primitives/avatar/index.tsx new file mode 100644 index 0000000..41a3892 --- /dev/null +++ b/apps/native/src/components/primitives/avatar/index.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import { + ImageErrorEventData, + ImageLoadEventData, + NativeSyntheticEvent, + Image as RNImage, + View, +} from 'react-native'; +import * as Slot from '~/components/primitives/slot'; +import { ComponentPropsWithAsChild, SlottableViewProps, ViewRef } from '~/components/primitives/types'; +import { AvatarImageProps, AvatarRootProps } from './types'; + +type AvatarState = 'loading' | 'error' | 'loaded'; + +interface IRootContext extends AvatarRootProps { + status: AvatarState; + setStatus: (status: AvatarState) => void; +} + +const RootContext = React.createContext(null); + +const Root = React.forwardRef( + ({ asChild, alt, ...viewProps }, ref) => { + const [status, setStatus] = React.useState('loading'); + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Root.displayName = 'RootAvatar'; + +function useRootContext() { + const context = React.useContext(RootContext); + if (!context) { + throw new Error('Avatar compound components cannot be rendered outside the Avatar component'); + } + return context; +} + +const Image = React.forwardRef< + React.ElementRef, + Omit, 'alt'> & AvatarImageProps +>( + ( + { asChild, onLoad: onLoadProps, onError: onErrorProps, onLoadingStatusChange, ...props }, + ref + ) => { + const { alt, setStatus, status } = useRootContext(); + + const onLoad = React.useCallback( + (e: NativeSyntheticEvent) => { + setStatus('loaded'); + onLoadingStatusChange?.('loaded'); + onLoadProps?.(e); + }, + [onLoadProps] + ); + + const onError = React.useCallback( + (e: NativeSyntheticEvent) => { + setStatus('error'); + onLoadingStatusChange?.('error'); + onErrorProps?.(e); + }, + [onErrorProps] + ); + + if (status === 'error') { + return null; + } + + const Component = asChild ? Slot.Image : RNImage; + return ; + } +); + +Image.displayName = 'ImageAvatar'; + +const Fallback = React.forwardRef(({ asChild, ...props }, ref) => { + const { alt, status } = useRootContext(); + + if (status !== 'error') { + return null; + } + const Component = asChild ? Slot.View : View; + return ; +}); + +Fallback.displayName = 'FallbackAvatar'; + +export { Fallback, Image, Root }; diff --git a/apps/native/src/lib/rn-primitives/avatar/types.ts b/apps/native/src/components/primitives/avatar/types.ts similarity index 51% rename from apps/native/src/lib/rn-primitives/avatar/types.ts rename to apps/native/src/components/primitives/avatar/types.ts index d9c3cbf..4d6d9c0 100644 --- a/apps/native/src/lib/rn-primitives/avatar/types.ts +++ b/apps/native/src/components/primitives/avatar/types.ts @@ -1,10 +1,10 @@ interface AvatarRootProps { - alt: string; + alt: string; } interface AvatarImageProps { - children?: React.ReactNode; - onLoadingStatusChange?: (status: "error" | "loaded") => void; + children?: React.ReactNode; + onLoadingStatusChange?: (status: 'error' | 'loaded') => void; } export type { AvatarRootProps, AvatarImageProps }; diff --git a/apps/native/src/components/primitives/checkbox/index.tsx b/apps/native/src/components/primitives/checkbox/index.tsx new file mode 100644 index 0000000..8240558 --- /dev/null +++ b/apps/native/src/components/primitives/checkbox/index.tsx @@ -0,0 +1,101 @@ +import * as React from 'react'; +import { GestureResponderEvent, Pressable, View } from 'react-native'; +import * as Slot from '~/components/primitives/slot'; +import type { ComponentPropsWithAsChild, PressableRef, SlottablePressableProps } from '~/components/primitives/types'; +import type { CheckboxIndicator, CheckboxRootProps } from './types'; + +interface RootContext extends CheckboxRootProps { + nativeID?: string; +} + +const CheckboxContext = React.createContext(null); + +const Root = React.forwardRef( + ({ asChild, disabled = false, checked, onCheckedChange, nativeID, ...props }, ref) => { + return ( + + + + ); + } +); + +Root.displayName = 'RootNativeCheckbox'; + +function useCheckboxContext() { + const context = React.useContext(CheckboxContext); + if (!context) { + throw new Error( + 'Checkbox compound components cannot be rendered outside the Checkbox component' + ); + } + return context; +} + +const Trigger = React.forwardRef( + ({ asChild, onPress: onPressProp, ...props }, ref) => { + const { disabled, checked, onCheckedChange, nativeID } = useCheckboxContext(); + + function onPress(ev: GestureResponderEvent) { + if (disabled) return; + const newValue = !checked; + onCheckedChange(newValue); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Trigger.displayName = 'TriggerNativeCheckbox'; + +const Indicator = React.forwardRef< + React.ElementRef, + ComponentPropsWithAsChild & CheckboxIndicator +>(({ asChild, forceMount, ...props }, ref) => { + const { checked, disabled } = useCheckboxContext(); + + if (!forceMount) { + if (!checked) { + return null; + } + } + + const Component = asChild ? Slot.View : View; + return ( + + ); +}); + +Indicator.displayName = 'IndicatorNativeCheckbox'; + +export { Indicator, Root }; diff --git a/apps/native/src/components/primitives/checkbox/types.ts b/apps/native/src/components/primitives/checkbox/types.ts new file mode 100644 index 0000000..c3bf2bc --- /dev/null +++ b/apps/native/src/components/primitives/checkbox/types.ts @@ -0,0 +1,11 @@ +import type { ForceMountable } from '~/components/primitives/types'; + +interface CheckboxRootProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled?: boolean; +} + +type CheckboxIndicator = ForceMountable; + +export type { CheckboxRootProps, CheckboxIndicator }; diff --git a/apps/native/src/components/primitives/collapsible/index.tsx b/apps/native/src/components/primitives/collapsible/index.tsx new file mode 100644 index 0000000..6243120 --- /dev/null +++ b/apps/native/src/components/primitives/collapsible/index.tsx @@ -0,0 +1,119 @@ +import { useControllableState } from '~/components/primitives/hooks'; +import * as Slot from '~/components/primitives/slot'; +import type { + PressableRef, + SlottablePressableProps, + SlottableViewProps, + ViewRef, +} from '~/components/primitives/types'; +import * as React from 'react'; +import { Pressable, View, type GestureResponderEvent } from 'react-native'; +import type { CollapsibleContentProps, CollapsibleRootProps, RootContext } from './types'; + +const CollapsibleContext = React.createContext<(RootContext & { nativeID: string }) | null>(null); + +const Root = React.forwardRef( + ( + { + asChild, + disabled = false, + open: openProp, + defaultOpen, + onOpenChange: onOpenChangeProp, + ...viewProps + }, + ref + ) => { + const nativeID = React.useId(); + const [open = false, onOpenChange] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen, + onChange: onOpenChangeProp, + }); + + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Root.displayName = 'RootNativeCollapsible'; + +function useCollapsibleContext() { + const context = React.useContext(CollapsibleContext); + if (!context) { + throw new Error( + 'Collapsible compound components cannot be rendered outside the Collapsible component' + ); + } + return context; +} + +const Trigger = React.forwardRef( + ({ asChild, onPress: onPressProp, disabled: disabledProp = false, ...props }, ref) => { + const { disabled, open, onOpenChange, nativeID } = useCollapsibleContext(); + + function onPress(ev: GestureResponderEvent) { + if (disabled || disabledProp) return; + onOpenChange(!open); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Trigger.displayName = 'TriggerNativeCollapsible'; + +const Content = React.forwardRef( + ({ asChild, forceMount, ...props }, ref) => { + const { nativeID, open } = useCollapsibleContext(); + + if (!forceMount) { + if (!open) { + return null; + } + } + + const Component = asChild ? Slot.View : View; + return ( + + ); + } +); + +Content.displayName = 'ContentNativeCollapsible'; + +export { Content, Root, Trigger }; diff --git a/apps/native/src/components/primitives/collapsible/types.ts b/apps/native/src/components/primitives/collapsible/types.ts new file mode 100644 index 0000000..2d2d7d5 --- /dev/null +++ b/apps/native/src/components/primitives/collapsible/types.ts @@ -0,0 +1,18 @@ +import type { ForceMountable } from '~/components/primitives/types'; + +interface RootContext { + open: boolean; + onOpenChange: (open: boolean) => void; + disabled: boolean; +} + +interface CollapsibleRootProps { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + disabled?: boolean; +} + +type CollapsibleContentProps = ForceMountable; + +export type { CollapsibleContentProps, CollapsibleRootProps, RootContext }; diff --git a/apps/native/src/components/primitives/context-menu/index.tsx b/apps/native/src/components/primitives/context-menu/index.tsx new file mode 100644 index 0000000..756c01f --- /dev/null +++ b/apps/native/src/components/primitives/context-menu/index.tsx @@ -0,0 +1,626 @@ +import * as React from 'react'; +import { + BackHandler, + Pressable, + Text, + View, + type AccessibilityActionEvent, + type GestureResponderEvent, + type LayoutChangeEvent, + type LayoutRectangle, +} from 'react-native'; +import { useRelativePosition, type LayoutPosition } from '~/components/primitives/hooks'; +import { Portal as RNPPortal } from '~/components/primitives/portal'; +import * as Slot from '~/components/primitives/slot'; +import type { + ForceMountable, + PositionedContentProps, + PressableRef, + SlottablePressableProps, + SlottableTextProps, + SlottableViewProps, + TextRef, + ViewRef, +} from '~/components/primitives/types'; +import type { + ContextMenuCheckboxItemProps, + ContextMenuItemProps, + ContextMenuOverlayProps, + ContextMenuPortalProps, + ContextMenuRadioGroupProps, + ContextMenuRadioItemProps, + ContextMenuRootProps, + ContextMenuSeparatorProps, + ContextMenuSubProps, + ContextMenuSubTriggerProps, +} from './types'; + +interface IRootContext extends ContextMenuRootProps { + pressPosition: LayoutPosition | null; + setPressPosition: (pressPosition: LayoutPosition | null) => void; + contentLayout: LayoutRectangle | null; + setContentLayout: (contentLayout: LayoutRectangle | null) => void; + nativeID: string; +} + +const RootContext = React.createContext(null); + +const Root = React.forwardRef( + ({ asChild, open, onOpenChange, relativeTo = 'longPress', ...viewProps }, ref) => { + const nativeID = React.useId(); + const [pressPosition, setPressPosition] = React.useState(null); + const [contentLayout, setContentLayout] = React.useState(null); + + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Root.displayName = 'RootNativeContextMenu'; + +function useRootContext() { + const context = React.useContext(RootContext); + if (!context) { + throw new Error( + 'ContextMenu compound components cannot be rendered outside the ContextMenu component' + ); + } + return context; +} + +const accessibilityActions = [{ name: 'longpress' }]; + +const Trigger = React.forwardRef( + ( + { + asChild, + onLongPress: onLongPressProp, + disabled = false, + onAccessibilityAction: onAccessibilityActionProp, + ...props + }, + ref + ) => { + const triggerRef = React.useRef(null); + const { open, onOpenChange, relativeTo, setPressPosition } = useRootContext(); + + React.useImperativeHandle( + ref, + () => { + if (!triggerRef.current) { + return new View({}); + } + return triggerRef.current; + }, + [triggerRef.current] + ); + + function onLongPress(ev: GestureResponderEvent) { + if (disabled) return; + if (relativeTo === 'longPress') { + setPressPosition({ + width: 0, + pageX: ev.nativeEvent.pageX, + pageY: ev.nativeEvent.pageY, + height: 0, + }); + } + if (relativeTo === 'trigger') { + triggerRef.current?.measure((_x, _y, width, height, pageX, pageY) => { + setPressPosition({ width, pageX, pageY: pageY, height }); + }); + } + onOpenChange(!open); + onLongPressProp?.(ev); + } + + function onAccessibilityAction(event: AccessibilityActionEvent) { + if (disabled) return; + if (event.nativeEvent.actionName === 'longpress') { + setPressPosition({ + width: 0, + pageX: 0, + pageY: 0, + height: 0, + }); + const newValue = !open; + onOpenChange(newValue); + } + onAccessibilityActionProp?.(event); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Trigger.displayName = 'TriggerNativeContextMenu'; + +/** + * @warning when using a custom ``, you will have to adjust the Content's sideOffset to account for nav elements like headers. + */ +function Portal({ forceMount, hostName, children }: ContextMenuPortalProps) { + const value = useRootContext(); + + if (!value.pressPosition) { + return null; + } + + if (!forceMount) { + if (!value.open) { + return null; + } + } + + return ( + + {children} + + ); +} + +const Overlay = React.forwardRef( + ({ asChild, forceMount, onPress: OnPressProp, closeOnPress = true, ...props }, ref) => { + const { open, onOpenChange, setContentLayout, setPressPosition } = useRootContext(); + + function onPress(ev: GestureResponderEvent) { + if (closeOnPress) { + setPressPosition(null); + setContentLayout(null); + onOpenChange(false); + } + OnPressProp?.(ev); + } + + if (!forceMount) { + if (!open) { + return null; + } + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ; + } +); + +Overlay.displayName = 'OverlayNativeContextMenu'; + +const Content = React.forwardRef( + ( + { + asChild = false, + forceMount, + align = 'start', + side = 'bottom', + sideOffset = 0, + alignOffset = 0, + avoidCollisions = true, + onLayout: onLayoutProp, + insets, + style, + disablePositioningStyle, + ...props + }, + ref + ) => { + const { + open, + onOpenChange, + contentLayout, + nativeID, + pressPosition, + setContentLayout, + setPressPosition, + } = useRootContext(); + + React.useEffect(() => { + const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { + setPressPosition(null); + setContentLayout(null); + onOpenChange(false); + return true; + }); + + return () => { + setContentLayout(null); + backHandler.remove(); + }; + }, []); + + const positionStyle = useRelativePosition({ + align, + avoidCollisions, + triggerPosition: pressPosition, + contentLayout, + alignOffset, + insets, + sideOffset, + side, + disablePositioningStyle, + }); + + function onLayout(event: LayoutChangeEvent) { + setContentLayout(event.nativeEvent.layout); + onLayoutProp?.(event); + } + + if (!forceMount) { + if (!open) { + return null; + } + } + + const Component = asChild ? Slot.View : View; + return ( + + ); + } +); + +Content.displayName = 'ContentNativeContextMenu'; + +const Item = React.forwardRef( + ( + { asChild, textValue, onPress: onPressProp, disabled = false, closeOnPress = true, ...props }, + ref + ) => { + const { onOpenChange, setContentLayout, setPressPosition } = useRootContext(); + + function onPress(ev: GestureResponderEvent) { + if (closeOnPress) { + setPressPosition(null); + setContentLayout(null); + onOpenChange(false); + } + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Item.displayName = 'ItemNativeContextMenu'; + +const Group = React.forwardRef(({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ; +}); + +Group.displayName = 'GroupNativeContextMenu'; + +const Label = React.forwardRef(({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.Text : Text; + return ; +}); + +Label.displayName = 'LabelNativeContextMenu'; + +type FormItemContext = + | { checked: boolean } + | { + value: string | undefined; + onValueChange: (value: string) => void; + }; + +const FormItemContext = React.createContext(null); + +const CheckboxItem = React.forwardRef< + PressableRef, + SlottablePressableProps & ContextMenuCheckboxItemProps +>( + ( + { + asChild, + checked, + onCheckedChange, + textValue, + onPress: onPressProp, + closeOnPress = true, + disabled = false, + ...props + }, + ref + ) => { + const { onOpenChange, setContentLayout, setPressPosition, nativeID } = useRootContext(); + + function onPress(ev: GestureResponderEvent) { + onCheckedChange(!checked); + if (closeOnPress) { + setPressPosition(null); + setContentLayout(null); + onOpenChange(false); + } + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + + + ); + } +); + +CheckboxItem.displayName = 'CheckboxItemNativeContextMenu'; + +function useFormItemContext() { + const context = React.useContext(FormItemContext); + if (!context) { + throw new Error( + 'CheckboxItem or RadioItem compound components cannot be rendered outside of a CheckboxItem or RadioItem component' + ); + } + return context; +} + +const RadioGroup = React.forwardRef( + ({ asChild, value, onValueChange, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +RadioGroup.displayName = 'RadioGroupNativeContextMenu'; + +type BothFormItemContext = Exclude & { + checked: boolean; +}; + +const RadioItemContext = React.createContext({} as { itemValue: string }); + +const RadioItem = React.forwardRef< + PressableRef, + SlottablePressableProps & ContextMenuRadioItemProps +>( + ( + { + asChild, + value: itemValue, + textValue, + onPress: onPressProp, + disabled = false, + closeOnPress = true, + ...props + }, + ref + ) => { + const { onOpenChange, setContentLayout, setPressPosition } = useRootContext(); + + const { value, onValueChange } = useFormItemContext() as BothFormItemContext; + function onPress(ev: GestureResponderEvent) { + onValueChange(itemValue); + if (closeOnPress) { + setPressPosition(null); + setContentLayout(null); + onOpenChange(false); + } + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + + + ); + } +); + +RadioItem.displayName = 'RadioItemNativeContextMenu'; + +function useItemIndicatorContext() { + return React.useContext(RadioItemContext); +} + +const ItemIndicator = React.forwardRef( + ({ asChild, forceMount, ...props }, ref) => { + const { itemValue } = useItemIndicatorContext(); + const { checked, value } = useFormItemContext() as BothFormItemContext; + + if (!forceMount) { + if (itemValue == null && !checked) { + return null; + } + if (value !== itemValue) { + return null; + } + } + const Component = asChild ? Slot.View : View; + return ; + } +); + +ItemIndicator.displayName = 'ItemIndicatorNativeContextMenu'; + +const Separator = React.forwardRef( + ({ asChild, decorative, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ; + } +); + +Separator.displayName = 'SeparatorNativeContextMenu'; + +const SubContext = React.createContext<{ + nativeID: string; + open: boolean; + onOpenChange: (value: boolean) => void; +} | null>(null); + +const Sub = React.forwardRef( + ({ asChild, open, onOpenChange, ...props }, ref) => { + const nativeID = React.useId(); + + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Sub.displayName = 'SubNativeContextMenu'; + +function useSubContext() { + const context = React.useContext(SubContext); + if (!context) { + throw new Error('Sub compound components cannot be rendered outside of a Sub component'); + } + return context; +} + +const SubTrigger = React.forwardRef< + PressableRef, + SlottablePressableProps & ContextMenuSubTriggerProps +>(({ asChild, textValue, onPress: onPressProp, disabled = false, ...props }, ref) => { + const { nativeID, open, onOpenChange } = useSubContext(); + + function onPress(ev: GestureResponderEvent) { + onOpenChange(!open); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); +}); + +SubTrigger.displayName = 'SubTriggerNativeContextMenu'; + +const SubContent = React.forwardRef( + ({ asChild = false, forceMount, ...props }, ref) => { + const { open, nativeID } = useSubContext(); + + if (!forceMount) { + if (!open) { + return null; + } + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ; + } +); + +Content.displayName = 'ContentNativeContextMenu'; + +export { + CheckboxItem, + Content, + Group, + Item, + ItemIndicator, + Label, + Overlay, + Portal, + RadioGroup, + RadioItem, + Root, + Separator, + Sub, + SubContent, + SubTrigger, + Trigger, + useRootContext, + useSubContext, +}; + +function onStartShouldSetResponder() { + return true; +} diff --git a/apps/native/src/components/primitives/context-menu/types.ts b/apps/native/src/components/primitives/context-menu/types.ts new file mode 100644 index 0000000..fce7e95 --- /dev/null +++ b/apps/native/src/components/primitives/context-menu/types.ts @@ -0,0 +1,82 @@ +import { ForceMountable } from '~/components/primitives/types'; + +interface ContextMenuRootProps { + /** + * Platform: NATIVE ONLY + */ + open: boolean; + onOpenChange: (value: boolean) => void; + + /** + * Platform: NATIVE ONLY + */ + relativeTo?: 'longPress' | 'trigger'; +} + +interface ContextMenuPortalProps extends ForceMountable { + children: React.ReactNode; + /** + * Platform: NATIVE ONLY + */ + hostName?: string; + /** + * Platform: WEB ONLY + */ + container?: HTMLElement | null | undefined; +} + +interface ContextMenuOverlayProps extends ForceMountable { + /** + * Platform: NATIVE ONLY + */ + closeOnPress?: boolean; +} + +interface ContextMenuItemProps { + textValue?: string; + closeOnPress?: boolean; +} + +interface ContextMenuCheckboxItemProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + closeOnPress?: boolean; + textValue?: string; +} + +interface ContextMenuRadioGroupProps { + value: string | undefined; + onValueChange: (value: string) => void; +} + +interface ContextMenuRadioItemProps { + value: string; + textValue?: string; + closeOnPress?: boolean; +} + +interface ContextMenuSeparatorProps { + decorative?: boolean; +} + +interface ContextMenuSubProps { + open: boolean; + onOpenChange: (value: boolean) => void; +} + +interface ContextMenuSubTriggerProps { + textValue?: string; +} + +export type { + ContextMenuCheckboxItemProps, + ContextMenuItemProps, + ContextMenuOverlayProps, + ContextMenuPortalProps, + ContextMenuRadioGroupProps, + ContextMenuRadioItemProps, + ContextMenuRootProps, + ContextMenuSeparatorProps, + ContextMenuSubProps, + ContextMenuSubTriggerProps, +}; diff --git a/apps/native/src/components/primitives/dialog/index.tsx b/apps/native/src/components/primitives/dialog/index.tsx new file mode 100644 index 0000000..f8e82ca --- /dev/null +++ b/apps/native/src/components/primitives/dialog/index.tsx @@ -0,0 +1,211 @@ +import { useControllableState } from '~/components/primitives/hooks'; +import { Portal as RNPPortal } from '~/components/primitives/portal'; +import * as Slot from '~/components/primitives/slot'; +import type { + PressableRef, + SlottablePressableProps, + SlottableTextProps, + SlottableViewProps, + TextRef, + ViewRef, +} from '~/components/primitives/types'; +import * as React from 'react'; +import { BackHandler, GestureResponderEvent, Pressable, Text, View } from 'react-native'; +import type { + DialogContentProps, + DialogOverlayProps, + DialogPortalProps, + DialogRootProps, + RootContext, +} from './types'; + +const DialogContext = React.createContext<(RootContext & { nativeID: string }) | null>(null); + +const Root = React.forwardRef( + ({ asChild, open: openProp, defaultOpen, onOpenChange: onOpenChangeProp, ...viewProps }, ref) => { + const nativeID = React.useId(); + const [open = false, onOpenChange] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen, + onChange: onOpenChangeProp, + }); + + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Root.displayName = 'RootNativeDialog'; + +function useRootContext() { + const context = React.useContext(DialogContext); + if (!context) { + throw new Error('Dialog compound components cannot be rendered outside the Dialog component'); + } + return context; +} + +const Trigger = React.forwardRef( + ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { + const { open, onOpenChange } = useRootContext(); + + function onPress(ev: GestureResponderEvent) { + if (disabled) return; + const newValue = !open; + onOpenChange(newValue); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Trigger.displayName = 'TriggerNativeDialog'; + +/** + * @warning when using a custom ``, you might have to adjust the Content's sideOffset to account for nav elements like headers. + */ +function Portal({ forceMount, hostName, children }: DialogPortalProps) { + const value = useRootContext(); + + if (!forceMount) { + if (!value.open) { + return null; + } + } + + return ( + + {children} + + ); +} + +const Overlay = React.forwardRef( + ({ asChild, forceMount, closeOnPress = true, onPress: OnPressProp, ...props }, ref) => { + const { open, onOpenChange } = useRootContext(); + + function onPress(ev: GestureResponderEvent) { + if (closeOnPress) { + onOpenChange(!open); + } + OnPressProp?.(ev); + } + + if (!forceMount) { + if (!open) { + return null; + } + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ; + } +); + +Overlay.displayName = 'OverlayNativeDialog'; + +const Content = React.forwardRef( + ({ asChild, forceMount, ...props }, ref) => { + const { open, nativeID, onOpenChange } = useRootContext(); + + React.useEffect(() => { + const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { + onOpenChange(false); + return true; + }); + + return () => { + backHandler.remove(); + }; + }, []); + + if (!forceMount) { + if (!open) { + return null; + } + } + + const Component = asChild ? Slot.View : View; + return ( + + ); + } +); + +Content.displayName = 'ContentNativeDialog'; + +const Close = React.forwardRef( + ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { + const { onOpenChange } = useRootContext(); + + function onPress(ev: GestureResponderEvent) { + if (disabled) return; + onOpenChange(false); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Close.displayName = 'CloseNativeDialog'; + +const Title = React.forwardRef((props, ref) => { + const { nativeID } = useRootContext(); + return ; +}); + +Title.displayName = 'TitleNativeDialog'; + +const Description = React.forwardRef((props, ref) => { + const { nativeID } = useRootContext(); + return ; +}); + +Description.displayName = 'DescriptionNativeDialog'; + +export { Close, Content, Description, Overlay, Portal, Root, Title, Trigger, useRootContext }; + +function onStartShouldSetResponder() { + return true; +} diff --git a/apps/native/src/components/primitives/dialog/types.ts b/apps/native/src/components/primitives/dialog/types.ts new file mode 100644 index 0000000..439a868 --- /dev/null +++ b/apps/native/src/components/primitives/dialog/types.ts @@ -0,0 +1,60 @@ +import type { ForceMountable } from '~/components/primitives/types'; + +type RootContext = { + open: boolean; + onOpenChange: (value: boolean) => void; +}; + +type DialogRootProps = { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (value: boolean) => void; +}; + +interface DialogPortalProps extends ForceMountable { + children: React.ReactNode; + /** + * Platform: NATIVE ONLY + */ + hostName?: string; + /** + * Platform: WEB ONLY + */ + container?: HTMLElement | null | undefined; +} +type DialogOverlayProps = ForceMountable & { + /** + * Platform: NATIVE ONLY - default: true + */ + closeOnPress?: boolean; +}; +type DialogContentProps = ForceMountable & { + /** + * Platform: WEB ONLY + */ + onOpenAutoFocus?: (ev: Event) => void; + /** + * Platform: WEB ONLY + */ + onCloseAutoFocus?: (ev: Event) => void; + /** + * Platform: WEB ONLY + */ + onEscapeKeyDown?: (ev: Event) => void; + /** + * Platform: WEB ONLY + */ + onInteractOutside?: (ev: Event) => void; + /** + * Platform: WEB ONLY + */ + onPointerDownOutside?: (ev: Event) => void; +}; + +export type { + DialogContentProps, + DialogOverlayProps, + DialogPortalProps, + DialogRootProps, + RootContext, +}; diff --git a/apps/native/src/components/primitives/dropdown-menu/index.tsx b/apps/native/src/components/primitives/dropdown-menu/index.tsx new file mode 100644 index 0000000..279fbfb --- /dev/null +++ b/apps/native/src/components/primitives/dropdown-menu/index.tsx @@ -0,0 +1,584 @@ +import * as React from 'react'; +import { + BackHandler, + Pressable, + Text, + View, + type GestureResponderEvent, + type LayoutChangeEvent, + type LayoutRectangle, +} from 'react-native'; +import { useRelativePosition, type LayoutPosition } from '~/components/primitives/hooks'; +import { Portal as RNPPortal } from '~/components/primitives/portal'; +import * as Slot from '~/components/primitives/slot'; +import type { + ForceMountable, + PositionedContentProps, + PressableRef, + SlottablePressableProps, + SlottableTextProps, + SlottableViewProps, + TextRef, + ViewRef, +} from '~/components/primitives/types'; +import type { + DropdownMenuCheckboxItemProps, + DropdownMenuItemProps, + DropdownMenuOverlayProps, + DropdownMenuPortalProps, + DropdownMenuRadioGroupProps, + DropdownMenuRadioItemProps, + DropdownMenuRootProps, + DropdownMenuSeparatorProps, + DropdownMenuSubProps, + DropdownMenuSubTriggerProps, +} from './types'; + +interface IRootContext extends DropdownMenuRootProps { + triggerPosition: LayoutPosition | null; + setTriggerPosition: (triggerPosition: LayoutPosition | null) => void; + contentLayout: LayoutRectangle | null; + setContentLayout: (contentLayout: LayoutRectangle | null) => void; + nativeID: string; +} + +const RootContext = React.createContext(null); + +const Root = React.forwardRef( + ({ asChild, open, onOpenChange, ...viewProps }, ref) => { + const nativeID = React.useId(); + const [triggerPosition, setTriggerPosition] = React.useState(null); + const [contentLayout, setContentLayout] = React.useState(null); + + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Root.displayName = 'RootNativeDropdownMenu'; + +function useRootContext() { + const context = React.useContext(RootContext); + if (!context) { + throw new Error( + 'DropdownMenu compound components cannot be rendered outside the DropdownMenu component' + ); + } + return context; +} + +const Trigger = React.forwardRef( + ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { + const triggerRef = React.useRef(null); + const { open, onOpenChange, setTriggerPosition } = useRootContext(); + + React.useImperativeHandle( + ref, + () => { + if (!triggerRef.current) { + return new View({}); + } + return triggerRef.current; + }, + [triggerRef.current] + ); + + function onPress(ev: GestureResponderEvent) { + if (disabled) return; + triggerRef.current?.measure((_x, _y, width, height, pageX, pageY) => { + setTriggerPosition({ width, pageX, pageY: pageY, height }); + }); + const newValue = !open; + onOpenChange(newValue); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Trigger.displayName = 'TriggerNativeDropdownMenu'; + +/** + * @warning when using a custom ``, you might have to adjust the Content's sideOffset to account for nav elements like headers. + */ +function Portal({ forceMount, hostName, children }: DropdownMenuPortalProps) { + const value = useRootContext(); + + if (!value.triggerPosition) { + return null; + } + + if (!forceMount) { + if (!value.open) { + return null; + } + } + + return ( + + {children} + + ); +} + +const Overlay = React.forwardRef( + ({ asChild, forceMount, onPress: OnPressProp, closeOnPress = true, ...props }, ref) => { + const { open, onOpenChange, setContentLayout, setTriggerPosition } = useRootContext(); + + function onPress(ev: GestureResponderEvent) { + if (closeOnPress) { + setTriggerPosition(null); + setContentLayout(null); + onOpenChange(false); + } + OnPressProp?.(ev); + } + + if (!forceMount) { + if (!open) { + return null; + } + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ; + } +); + +Overlay.displayName = 'OverlayNativeDropdownMenu'; + +/** + * @info `position`, `top`, `left`, and `maxWidth` style properties are controlled internally. Opt out of this behavior by setting `disablePositioningStyle` to `true`. + */ +const Content = React.forwardRef( + ( + { + asChild = false, + forceMount, + align = 'start', + side = 'bottom', + sideOffset = 0, + alignOffset = 0, + avoidCollisions = true, + onLayout: onLayoutProp, + insets, + style, + disablePositioningStyle, + ...props + }, + ref + ) => { + const { + open, + onOpenChange, + nativeID, + triggerPosition, + setTriggerPosition, + contentLayout, + setContentLayout, + } = useRootContext(); + + React.useEffect(() => { + const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { + setTriggerPosition(null); + setContentLayout(null); + onOpenChange(false); + return true; + }); + + return () => { + setContentLayout(null); + backHandler.remove(); + }; + }, []); + + const positionStyle = useRelativePosition({ + align, + avoidCollisions, + triggerPosition, + contentLayout, + alignOffset, + insets, + sideOffset, + side, + disablePositioningStyle, + }); + + function onLayout(event: LayoutChangeEvent) { + setContentLayout(event.nativeEvent.layout); + onLayoutProp?.(event); + } + + if (!forceMount) { + if (!open) { + return null; + } + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Content.displayName = 'ContentNativeDropdownMenu'; + +const Item = React.forwardRef( + ( + { asChild, textValue, onPress: onPressProp, disabled = false, closeOnPress = true, ...props }, + ref + ) => { + const { onOpenChange, setTriggerPosition, setContentLayout } = useRootContext(); + + function onPress(ev: GestureResponderEvent) { + if (closeOnPress) { + setTriggerPosition(null); + setContentLayout(null); + onOpenChange(false); + } + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Item.displayName = 'ItemNativeDropdownMenu'; + +const Group = React.forwardRef(({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ; +}); + +Group.displayName = 'GroupNativeDropdownMenu'; + +const Label = React.forwardRef(({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.Text : Text; + return ; +}); + +Label.displayName = 'LabelNativeDropdownMenu'; + +type FormItemContext = + | { checked: boolean } + | { + value: string | undefined; + onValueChange: (value: string) => void; + }; + +const FormItemContext = React.createContext(null); + +const CheckboxItem = React.forwardRef< + PressableRef, + SlottablePressableProps & DropdownMenuCheckboxItemProps +>( + ( + { + asChild, + checked, + onCheckedChange, + textValue, + onPress: onPressProp, + closeOnPress = true, + disabled = false, + ...props + }, + ref + ) => { + const { onOpenChange, setContentLayout, setTriggerPosition, nativeID } = useRootContext(); + + function onPress(ev: GestureResponderEvent) { + onCheckedChange(!checked); + if (closeOnPress) { + setTriggerPosition(null); + setContentLayout(null); + onOpenChange(false); + } + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + + + ); + } +); + +CheckboxItem.displayName = 'CheckboxItemNativeDropdownMenu'; + +function useFormItemContext() { + const context = React.useContext(FormItemContext); + if (!context) { + throw new Error( + 'CheckboxItem or RadioItem compound components cannot be rendered outside of a CheckboxItem or RadioItem component' + ); + } + return context; +} + +const RadioGroup = React.forwardRef( + ({ asChild, value, onValueChange, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +RadioGroup.displayName = 'RadioGroupNativeDropdownMenu'; + +type BothFormItemContext = Exclude & { + checked: boolean; +}; + +const RadioItemContext = React.createContext({} as { itemValue: string }); + +const RadioItem = React.forwardRef< + PressableRef, + SlottablePressableProps & DropdownMenuRadioItemProps +>( + ( + { + asChild, + value: itemValue, + textValue, + onPress: onPressProp, + disabled = false, + closeOnPress = true, + ...props + }, + ref + ) => { + const { onOpenChange, setContentLayout, setTriggerPosition } = useRootContext(); + + const { value, onValueChange } = useFormItemContext() as BothFormItemContext; + function onPress(ev: GestureResponderEvent) { + onValueChange(itemValue); + if (closeOnPress) { + setTriggerPosition(null); + setContentLayout(null); + onOpenChange(false); + } + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + + + ); + } +); + +RadioItem.displayName = 'RadioItemNativeDropdownMenu'; + +function useItemIndicatorContext() { + return React.useContext(RadioItemContext); +} + +const ItemIndicator = React.forwardRef( + ({ asChild, forceMount, ...props }, ref) => { + const { itemValue } = useItemIndicatorContext(); + const { checked, value } = useFormItemContext() as BothFormItemContext; + + if (!forceMount) { + if (itemValue == null && !checked) { + return null; + } + if (value !== itemValue) { + return null; + } + } + const Component = asChild ? Slot.View : View; + return ; + } +); + +ItemIndicator.displayName = 'ItemIndicatorNativeDropdownMenu'; + +const Separator = React.forwardRef( + ({ asChild, decorative, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ; + } +); + +Separator.displayName = 'SeparatorNativeDropdownMenu'; + +const SubContext = React.createContext<{ + nativeID: string; + open: boolean; + onOpenChange: (value: boolean) => void; +} | null>(null); + +const Sub = React.forwardRef( + ({ asChild, open, onOpenChange, ...props }, ref) => { + const nativeID = React.useId(); + + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Sub.displayName = 'SubNativeDropdownMenu'; + +function useSubContext() { + const context = React.useContext(SubContext); + if (!context) { + throw new Error('Sub compound components cannot be rendered outside of a Sub component'); + } + return context; +} + +const SubTrigger = React.forwardRef< + PressableRef, + SlottablePressableProps & DropdownMenuSubTriggerProps +>(({ asChild, textValue, onPress: onPressProp, disabled = false, ...props }, ref) => { + const { nativeID, open, onOpenChange } = useSubContext(); + + function onPress(ev: GestureResponderEvent) { + onOpenChange(!open); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); +}); + +SubTrigger.displayName = 'SubTriggerNativeDropdownMenu'; + +const SubContent = React.forwardRef( + ({ asChild = false, forceMount, ...props }, ref) => { + const { open, nativeID } = useSubContext(); + + if (!forceMount) { + if (!open) { + return null; + } + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ; + } +); + +Content.displayName = 'ContentNativeDropdownMenu'; + +export { + CheckboxItem, + Content, + Group, + Item, + ItemIndicator, + Label, + Overlay, + Portal, + RadioGroup, + RadioItem, + Root, + Separator, + Sub, + SubContent, + SubTrigger, + Trigger, + useRootContext, + useSubContext, +}; diff --git a/apps/native/src/components/primitives/dropdown-menu/types.ts b/apps/native/src/components/primitives/dropdown-menu/types.ts new file mode 100644 index 0000000..d6cf9a1 --- /dev/null +++ b/apps/native/src/components/primitives/dropdown-menu/types.ts @@ -0,0 +1,71 @@ +import type { ForceMountable } from '~/components/primitives/types'; + +interface DropdownMenuRootProps { + open: boolean; + onOpenChange: (value: boolean) => void; +} + +interface DropdownMenuPortalProps extends ForceMountable { + children: React.ReactNode; + /** + * Platform: NATIVE ONLY + */ + hostName?: string; + /** + * Platform: WEB ONLY + */ + container?: HTMLElement | null | undefined; +} + +interface DropdownMenuOverlayProps extends ForceMountable { + closeOnPress?: boolean; +} + +interface DropdownMenuItemProps { + textValue?: string; + closeOnPress?: boolean; +} + +interface DropdownMenuCheckboxItemProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + closeOnPress?: boolean; + textValue?: string; +} + +interface DropdownMenuRadioGroupProps { + value: string | undefined; + onValueChange: (value: string) => void; +} + +interface DropdownMenuRadioItemProps { + value: string; + textValue?: string; + closeOnPress?: boolean; +} + +interface DropdownMenuSeparatorProps { + decorative?: boolean; +} + +interface DropdownMenuSubProps { + open: boolean; + onOpenChange: (value: boolean) => void; +} + +interface DropdownMenuSubTriggerProps { + textValue?: string; +} + +export type { + DropdownMenuCheckboxItemProps, + DropdownMenuItemProps, + DropdownMenuOverlayProps, + DropdownMenuPortalProps, + DropdownMenuRadioGroupProps, + DropdownMenuRadioItemProps, + DropdownMenuRootProps, + DropdownMenuSeparatorProps, + DropdownMenuSubProps, + DropdownMenuSubTriggerProps, +}; diff --git a/apps/native/src/components/primitives/hooks/index.ts b/apps/native/src/components/primitives/hooks/index.ts new file mode 100644 index 0000000..58c0d1b --- /dev/null +++ b/apps/native/src/components/primitives/hooks/index.ts @@ -0,0 +1,3 @@ +export { useAugmentedRef } from './useAugmentedRef'; +export { useRelativePosition, type LayoutPosition } from './useRelativePosition'; +export { useControllableState } from './useControllableState'; diff --git a/apps/native/src/components/primitives/hooks/useAugmentedRef.tsx b/apps/native/src/components/primitives/hooks/useAugmentedRef.tsx new file mode 100644 index 0000000..13a5669 --- /dev/null +++ b/apps/native/src/components/primitives/hooks/useAugmentedRef.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; + +interface AugmentRefProps { + ref: React.Ref; + methods?: Record any>; + deps?: any[]; +} + +export function useAugmentedRef({ + ref, + methods, + deps = [], +}: AugmentRefProps) { + const augmentedRef = React.useRef(null); + React.useImperativeHandle( + ref, + () => { + if (typeof augmentedRef === 'function' || !augmentedRef?.current) { + return {} as T; + } + return { + ...augmentedRef.current, + ...methods, + }; + }, + deps + ); + return augmentedRef; +} diff --git a/apps/native/src/components/primitives/hooks/useControllableState.tsx b/apps/native/src/components/primitives/hooks/useControllableState.tsx new file mode 100644 index 0000000..3b42b53 --- /dev/null +++ b/apps/native/src/components/primitives/hooks/useControllableState.tsx @@ -0,0 +1,75 @@ +// This project uses code from WorkOS/Radix Primitives. +// The code is licensed under the MIT License. +// https://github.com/radix-ui/primitives/tree/main + +import * as React from 'react'; + +type UseControllableStateParams = { + prop?: T | undefined; + defaultProp?: T | undefined; + onChange?: (state: T) => void; +}; + +type SetStateFn = (prevState?: T) => T; + +function useControllableState({ + prop, + defaultProp, + onChange = () => {}, +}: UseControllableStateParams) { + const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({ defaultProp, onChange }); + const isControlled = prop !== undefined; + const value = isControlled ? prop : uncontrolledProp; + const handleChange = useCallbackRef(onChange); + + const setValue: React.Dispatch> = React.useCallback( + (nextValue) => { + if (isControlled) { + const setter = nextValue as SetStateFn; + const value = typeof nextValue === 'function' ? setter(prop) : nextValue; + if (value !== prop) handleChange(value as T); + } else { + setUncontrolledProp(nextValue); + } + }, + [isControlled, prop, setUncontrolledProp, handleChange] + ); + + return [value, setValue] as const; +} + +function useUncontrolledState({ + defaultProp, + onChange, +}: Omit, 'prop'>) { + const uncontrolledState = React.useState(defaultProp); + const [value] = uncontrolledState; + const prevValueRef = React.useRef(value); + const handleChange = useCallbackRef(onChange); + + React.useEffect(() => { + if (prevValueRef.current !== value) { + handleChange(value as T); + prevValueRef.current = value; + } + }, [value, prevValueRef, handleChange]); + + return uncontrolledState; +} + +/** + * A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a + * prop or avoid re-executing effects when passed as a dependency + */ +function useCallbackRef any>(callback: T | undefined): T { + const callbackRef = React.useRef(callback); + + React.useEffect(() => { + callbackRef.current = callback; + }); + + // https://github.com/facebook/react/issues/19240 + return React.useMemo(() => ((...args) => callbackRef.current?.(...args)) as T, []); +} + +export { useControllableState }; diff --git a/apps/native/src/components/primitives/hooks/useRelativePosition.tsx b/apps/native/src/components/primitives/hooks/useRelativePosition.tsx new file mode 100644 index 0000000..f1544be --- /dev/null +++ b/apps/native/src/components/primitives/hooks/useRelativePosition.tsx @@ -0,0 +1,227 @@ +import * as React from 'react'; +import { + useWindowDimensions, + type LayoutRectangle, + type ScaledSize, + type ViewStyle, +} from 'react-native'; +import type { Insets } from '~/components/primitives/types'; + +const POSITION_ABSOLUTE: ViewStyle = { + position: 'absolute', +}; + +const HIDDEN_CONTENT: ViewStyle = { + position: 'absolute', + opacity: 0, + zIndex: -9999999, +}; + +type UseRelativePositionArgs = Omit< + GetContentStyleArgs, + 'triggerPosition' | 'contentLayout' | 'dimensions' +> & { + triggerPosition: LayoutPosition | null; + contentLayout: LayoutRectangle | null; + disablePositioningStyle?: boolean; +}; + +export function useRelativePosition({ + align, + avoidCollisions, + triggerPosition, + contentLayout, + alignOffset, + insets, + sideOffset, + side, + disablePositioningStyle, +}: UseRelativePositionArgs) { + const dimensions = useWindowDimensions(); + return React.useMemo(() => { + if (disablePositioningStyle) { + return {}; + } + if (!triggerPosition || !contentLayout) { + return HIDDEN_CONTENT; + } + return getContentStyle({ + align, + avoidCollisions, + contentLayout, + side, + triggerPosition, + alignOffset, + insets, + sideOffset, + dimensions, + }); + }, [triggerPosition, contentLayout, dimensions.width, dimensions.height]); +} + +export interface LayoutPosition { + pageY: number; + pageX: number; + width: number; + height: number; +} + +interface GetPositionArgs { + dimensions: ScaledSize; + avoidCollisions: boolean; + triggerPosition: LayoutPosition; + contentLayout: LayoutRectangle; + insets?: Insets; +} + +interface GetSidePositionArgs extends GetPositionArgs { + side: 'top' | 'bottom'; + sideOffset: number; +} + +function getSidePosition({ + side, + triggerPosition, + contentLayout, + sideOffset, + insets, + avoidCollisions, + dimensions, +}: GetSidePositionArgs) { + const insetTop = insets?.top ?? 0; + const insetBottom = insets?.bottom ?? 0; + const positionTop = triggerPosition?.pageY - sideOffset - contentLayout.height; + const positionBottom = triggerPosition.pageY + triggerPosition.height + sideOffset; + + if (!avoidCollisions) { + return { + top: side === 'top' ? positionTop : positionBottom, + }; + } + + if (side === 'top') { + return { + top: Math.max(insetTop, positionTop), + }; + } + + return { + top: Math.min(dimensions.height - insetBottom - contentLayout.height, positionBottom), + }; +} + +interface GetAlignPositionArgs extends GetPositionArgs { + align: 'start' | 'center' | 'end'; + alignOffset: number; +} + +function getAlignPosition({ + align, + avoidCollisions, + contentLayout, + triggerPosition, + alignOffset, + insets, + dimensions, +}: GetAlignPositionArgs) { + const insetLeft = insets?.left ?? 0; + const insetRight = insets?.right ?? 0; + const maxContentWidth = dimensions.width - insetLeft - insetRight; + + const contentWidth = Math.min(contentLayout.width, maxContentWidth); + + let left = getLeftPosition( + align, + triggerPosition.pageX, + triggerPosition.width, + contentWidth, + alignOffset, + insetLeft, + insetRight, + dimensions + ); + + if (avoidCollisions) { + const doesCollide = left < insetLeft || left + contentWidth > dimensions.width - insetRight; + if (doesCollide) { + const spaceLeft = left - insetLeft; + const spaceRight = dimensions.width - insetRight - (left + contentWidth); + + if (spaceLeft > spaceRight && spaceLeft >= contentWidth) { + left = insetLeft; + } else if (spaceRight >= contentWidth) { + left = dimensions.width - insetRight - contentWidth; + } else { + const centeredPosition = Math.max( + insetLeft, + (dimensions.width - contentWidth - insetRight) / 2 + ); + left = centeredPosition; + } + } + } + + return { left, maxWidth: maxContentWidth }; +} + +function getLeftPosition( + align: 'start' | 'center' | 'end', + triggerPageX: number, + triggerWidth: number, + contentWidth: number, + alignOffset: number, + insetLeft: number, + insetRight: number, + dimensions: ScaledSize +) { + let left = 0; + if (align === 'start') { + left = triggerPageX; + } + if (align === 'center') { + left = triggerPageX + triggerWidth / 2 - contentWidth / 2; + } + if (align === 'end') { + left = triggerPageX + triggerWidth - contentWidth; + } + return Math.max( + insetLeft, + Math.min(left + alignOffset, dimensions.width - contentWidth - insetRight) + ); +} + +type GetContentStyleArgs = GetPositionArgs & GetSidePositionArgs & GetAlignPositionArgs; + +function getContentStyle({ + align, + avoidCollisions, + contentLayout, + side, + triggerPosition, + alignOffset, + insets, + sideOffset, + dimensions, +}: GetContentStyleArgs) { + return Object.assign( + POSITION_ABSOLUTE, + getSidePosition({ + side, + triggerPosition, + contentLayout, + sideOffset, + insets, + avoidCollisions, + dimensions, + }), + getAlignPosition({ + align, + avoidCollisions, + triggerPosition, + contentLayout, + alignOffset, + insets, + dimensions, + }) + ); +} diff --git a/apps/native/src/components/primitives/hover-card/index.tsx b/apps/native/src/components/primitives/hover-card/index.tsx new file mode 100644 index 0000000..2a44987 --- /dev/null +++ b/apps/native/src/components/primitives/hover-card/index.tsx @@ -0,0 +1,271 @@ +import * as React from 'react'; +import { + BackHandler, + type GestureResponderEvent, + type LayoutChangeEvent, + type LayoutRectangle, + Pressable, + View, +} from 'react-native'; +import { type LayoutPosition, useControllableState, useRelativePosition } from '~/components/primitives/hooks'; +import { Portal as RNPPortal } from '~/components/primitives/portal'; +import * as Slot from '~/components/primitives/slot'; +import type { + PositionedContentProps, + PressableRef, + SlottablePressableProps, + SlottableViewProps, + ViewRef, +} from '~/components/primitives/types'; +import type { + HoverCardOverlayProps, + HoverCardPortalProps, + HoverCardRootProps, + RootContext as RootContextType, +} from './types'; + +interface IRootContext extends RootContextType { + triggerPosition: LayoutPosition | null; + setTriggerPosition: (triggerPosition: LayoutPosition | null) => void; + contentLayout: LayoutRectangle | null; + setContentLayout: (contentLayout: LayoutRectangle | null) => void; + nativeID: string; +} + +const RootContext = React.createContext(null); + +const Root = React.forwardRef( + ( + { + asChild, + open: openProp, + defaultOpen, + onOpenChange: onOpenChangeProp, + openDelay: _openDelay, + closeDelay: _closeDelay, + ...viewProps + }, + ref + ) => { + const nativeID = React.useId(); + const [open = false, onOpenChange] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen, + onChange: onOpenChangeProp, + }); + const [triggerPosition, setTriggerPosition] = React.useState(null); + const [contentLayout, setContentLayout] = React.useState(null); + + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Root.displayName = 'RootNativeHoverCard'; + +function useRootContext() { + const context = React.useContext(RootContext); + if (!context) { + throw new Error( + 'HoverCard compound components cannot be rendered outside the HoverCard component' + ); + } + return context; +} + +const Trigger = React.forwardRef( + ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { + const triggerRef = React.useRef(null); + const { open, onOpenChange, setTriggerPosition } = useRootContext(); + + React.useImperativeHandle( + ref, + () => { + if (!triggerRef.current) { + return new View({}); + } + return triggerRef.current; + }, + [triggerRef.current] + ); + + function onPress(ev: GestureResponderEvent) { + if (disabled) return; + triggerRef.current?.measure((_x, _y, width, height, pageX, pageY) => { + setTriggerPosition({ width, pageX, pageY: pageY, height }); + }); + const newValue = !open; + onOpenChange(newValue); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Trigger.displayName = 'TriggerNativeHoverCard'; + +/** + * @warning when using a custom ``, you might have to adjust the Content's sideOffset to account for nav elements like headers. + */ +function Portal({ forceMount, hostName, children }: HoverCardPortalProps) { + const value = useRootContext(); + + if (!value.triggerPosition) { + return null; + } + + if (!forceMount) { + if (!value.open) { + return null; + } + } + + return ( + + {children} + + ); +} + +const Overlay = React.forwardRef( + ({ asChild, forceMount, onPress: OnPressProp, closeOnPress = true, ...props }, ref) => { + const { open, onOpenChange, setTriggerPosition, setContentLayout } = useRootContext(); + + function onPress(ev: GestureResponderEvent) { + if (closeOnPress) { + setTriggerPosition(null); + setContentLayout(null); + onOpenChange(false); + } + OnPressProp?.(ev); + } + + if (!forceMount) { + if (!open) { + return null; + } + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ; + } +); + +Overlay.displayName = 'OverlayNativeHoverCard'; + +/** + * @info `position`, `top`, `left`, and `maxWidth` style properties are controlled internally. Opt out of this behavior by setting `disablePositioningStyle` to `true`. + */ +const Content = React.forwardRef( + ( + { + asChild = false, + forceMount, + align = 'start', + side = 'bottom', + sideOffset = 0, + alignOffset = 0, + avoidCollisions = true, + onLayout: onLayoutProp, + insets, + style, + disablePositioningStyle, + ...props + }, + ref + ) => { + const { + open, + onOpenChange, + contentLayout, + nativeID, + setContentLayout, + setTriggerPosition, + triggerPosition, + } = useRootContext(); + + React.useEffect(() => { + const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { + setTriggerPosition(null); + setContentLayout(null); + onOpenChange(false); + return true; + }); + + return () => { + setContentLayout(null); + backHandler.remove(); + }; + }, []); + + const positionStyle = useRelativePosition({ + align, + avoidCollisions, + triggerPosition, + contentLayout, + alignOffset, + insets, + sideOffset, + side, + disablePositioningStyle, + }); + + function onLayout(event: LayoutChangeEvent) { + setContentLayout(event.nativeEvent.layout); + onLayoutProp?.(event); + } + + if (!forceMount) { + if (!open) { + return null; + } + } + + const Component = asChild ? Slot.View : View; + return ( + + ); + } +); + +Content.displayName = 'ContentNativeHoverCard'; + +export { Content, Overlay, Portal, Root, Trigger, useRootContext }; + +function onStartShouldSetResponder() { + return true; +} diff --git a/apps/native/src/components/primitives/hover-card/types.ts b/apps/native/src/components/primitives/hover-card/types.ts new file mode 100644 index 0000000..6da90ad --- /dev/null +++ b/apps/native/src/components/primitives/hover-card/types.ts @@ -0,0 +1,42 @@ +import type { ForceMountable } from '~/components/primitives/types'; + +interface RootContext { + open: boolean; + onOpenChange: (value: boolean) => void; + openDelay?: number; + closeDelay?: number; +} + +interface HoverCardRootProps { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (value: boolean) => void; + /** + * Platform: WEB ONLY + * @default 700 + */ + openDelay?: number; + /** + * Platform: WEB ONLY + * @default 300 + */ + closeDelay?: number; +} + +interface HoverCardPortalProps extends ForceMountable { + children: React.ReactNode; + /** + * Platform: NATIVE ONLY + */ + hostName?: string; + /** + * Platform: WEB ONLY + */ + container?: HTMLElement | null | undefined; +} + +interface HoverCardOverlayProps extends ForceMountable { + closeOnPress?: boolean; +} + +export type { HoverCardRootProps, HoverCardOverlayProps, HoverCardPortalProps, RootContext }; diff --git a/apps/native/src/components/primitives/label/index.tsx b/apps/native/src/components/primitives/label/index.tsx new file mode 100644 index 0000000..40b5f7b --- /dev/null +++ b/apps/native/src/components/primitives/label/index.tsx @@ -0,0 +1,31 @@ +import * as Slot from '~/components/primitives/slot'; +import type { + PressableRef, + SlottablePressableProps, + SlottableTextProps, + TextRef, +} from '~/components/primitives/types'; +import * as React from 'react'; +import { Pressable, Text as RNText } from 'react-native'; +import type { LabelRootProps, LabelTextProps } from './types'; + +const Root = React.forwardRef< + PressableRef, + Omit & LabelRootProps +>(({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.Pressable : Pressable; + return ; +}); + +Root.displayName = 'RootNativeLabel'; + +const Text = React.forwardRef( + ({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.Text : RNText; + return ; + } +); + +Text.displayName = 'TextNativeLabel'; + +export { Root, Text }; diff --git a/apps/native/src/components/primitives/label/types.ts b/apps/native/src/components/primitives/label/types.ts new file mode 100644 index 0000000..d3b273c --- /dev/null +++ b/apps/native/src/components/primitives/label/types.ts @@ -0,0 +1,15 @@ +import type { ViewStyle } from 'react-native'; + +interface LabelRootProps { + children: React.ReactNode; + style?: ViewStyle; +} + +interface LabelTextProps { + /** + * Equivalent to `id` so that the same value can be passed as `aria-labelledby` to the input element. + */ + nativeID: string; +} + +export type { LabelRootProps, LabelTextProps }; diff --git a/apps/native/src/components/primitives/menubar/index.tsx b/apps/native/src/components/primitives/menubar/index.tsx new file mode 100644 index 0000000..22f37db --- /dev/null +++ b/apps/native/src/components/primitives/menubar/index.tsx @@ -0,0 +1,624 @@ +import * as React from 'react'; +import { + BackHandler, + Pressable, + Text, + View, + type GestureResponderEvent, + type LayoutChangeEvent, + type LayoutRectangle, +} from 'react-native'; +import { useRelativePosition, type LayoutPosition } from '~/components/primitives/hooks'; +import { Portal as RNPPortal } from '~/components/primitives/portal'; +import * as Slot from '~/components/primitives/slot'; +import type { + ForceMountable, + PositionedContentProps, + PressableRef, + SlottablePressableProps, + SlottableTextProps, + SlottableViewProps, + TextRef, + ViewRef, +} from '~/components/primitives/types'; +import type { + MenubarCheckboxItemProps, + MenubarItemProps, + MenubarMenuProps, + MenubarOverlayProps, + MenubarPortalProps, + MenubarRadioGroupProps, + MenubarRadioItemProps, + MenubarRootProps, + MenubarSeparatorProps, + MenubarSubProps, + MenubarSubTriggerProps, +} from './types'; + +interface IMenuContext extends MenubarRootProps { + triggerPosition: LayoutPosition | null; + setTriggerPosition: (triggerPosition: LayoutPosition | null) => void; + contentLayout: LayoutRectangle | null; + setContentLayout: (contentLayout: LayoutRectangle | null) => void; + nativeID: string; +} + +const RootContext = React.createContext(null); + +const Root = React.forwardRef( + ({ asChild, value, onValueChange, ...viewProps }, ref) => { + const nativeID = React.useId(); + const [triggerPosition, setTriggerPosition] = React.useState(null); + const [contentLayout, setContentLayout] = React.useState(null); + + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Root.displayName = 'RootMenubar'; + +function useRootContext() { + const context = React.useContext(RootContext); + if (!context) { + throw new Error('Menubar compound components cannot be rendered outside the Menubar component'); + } + return context; +} + +const MenuContext = React.createContext(null); + +const Menu = React.forwardRef( + ({ asChild, value, ...viewProps }, ref) => { + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Menu.displayName = 'MenuMenubar'; + +function useMenuContext() { + const context = React.useContext(MenuContext); + if (!context) { + throw new Error('Menubar compound components cannot be rendered outside the Menubar component'); + } + return context; +} + +const Trigger = React.forwardRef( + ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { + const triggerRef = React.useRef(null); + const { value, onValueChange, setTriggerPosition } = useRootContext(); + const { value: menuValue } = useMenuContext(); + + React.useImperativeHandle( + ref, + () => { + if (!triggerRef.current) { + return new View({}); + } + return triggerRef.current; + }, + [triggerRef.current] + ); + + function onPress(ev: GestureResponderEvent) { + if (disabled) return; + triggerRef.current?.measure((_x, _y, width, height, pageX, pageY) => { + setTriggerPosition({ width, pageX, pageY, height }); + }); + + onValueChange(menuValue === value ? undefined : menuValue); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Trigger.displayName = 'TriggerMenubar'; + +/** + * @warning when using a custom ``, you will have to adjust the Content's sideOffset to account for nav elements like headers. + */ +function Portal({ forceMount, hostName, children }: MenubarPortalProps) { + const menubar = useRootContext(); + const menu = useMenuContext(); + + if (!menubar.triggerPosition) { + return null; + } + + if (!forceMount) { + if (menubar.value !== menu.value) { + return null; + } + } + + return ( + + + + {children} + + + + ); +} + +const Overlay = React.forwardRef( + ({ asChild, forceMount, onPress: OnPressProp, closeOnPress = true, ...props }, ref) => { + const { value, onValueChange, setContentLayout, setTriggerPosition } = useRootContext(); + + function onPress(ev: GestureResponderEvent) { + if (closeOnPress) { + setTriggerPosition(null); + setContentLayout(null); + onValueChange(undefined); + } + OnPressProp?.(ev); + } + + if (!forceMount) { + if (!value) { + return null; + } + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ; + } +); + +Overlay.displayName = 'OverlayMenubar'; + +/** + * @info `position`, `top`, `left`, and `maxWidth` style properties are controlled internally. Opt out of this behavior by setting `disablePositioningStyle` to `true`. + */ +const Content = React.forwardRef( + ( + { + asChild = false, + forceMount, + align = 'start', + side = 'bottom', + sideOffset = 0, + alignOffset = 0, + avoidCollisions = true, + onLayout: onLayoutProp, + insets, + style, + disablePositioningStyle, + ...props + }, + ref + ) => { + const { + value, + onValueChange, + triggerPosition, + contentLayout, + setContentLayout, + nativeID, + setTriggerPosition, + } = useRootContext(); + const { value: menuValue } = useMenuContext(); + + React.useEffect(() => { + const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { + setTriggerPosition(null); + setContentLayout(null); + onValueChange(undefined); + return true; + }); + + return () => { + setContentLayout(null); + backHandler.remove(); + }; + }, []); + + const positionStyle = useRelativePosition({ + align, + avoidCollisions, + triggerPosition, + contentLayout, + alignOffset, + insets, + sideOffset, + side, + disablePositioningStyle, + }); + + function onLayout(event: LayoutChangeEvent) { + setContentLayout(event.nativeEvent.layout); + onLayoutProp?.(event); + } + + if (!forceMount) { + if (value !== menuValue) { + return null; + } + } + + const Component = asChild ? Slot.View : View; + return ( + + ); + } +); + +Content.displayName = 'ContentMenubar'; + +const Item = React.forwardRef( + ( + { asChild, textValue, onPress: onPressProp, disabled = false, closeOnPress = true, ...props }, + ref + ) => { + const { onValueChange, setContentLayout, setTriggerPosition } = useRootContext(); + + function onPress(ev: GestureResponderEvent) { + if (closeOnPress) { + setTriggerPosition(null); + setContentLayout(null); + onValueChange(undefined); + } + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Item.displayName = 'ItemMenubar'; + +const Group = React.forwardRef(({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ; +}); + +Group.displayName = 'GroupMenubar'; + +const Label = React.forwardRef(({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.Text : Text; + return ; +}); + +Label.displayName = 'LabelMenubar'; + +type FormItemContext = + | { checked: boolean } + | { + value: string | undefined; + onValueChange: (value: string) => void; + }; + +const FormItemContext = React.createContext(null); + +const CheckboxItem = React.forwardRef< + PressableRef, + SlottablePressableProps & MenubarCheckboxItemProps +>( + ( + { + asChild, + checked, + onCheckedChange, + textValue, + onPress: onPressProp, + closeOnPress = true, + disabled = false, + ...props + }, + ref + ) => { + const { onValueChange, setTriggerPosition, setContentLayout, nativeID } = useRootContext(); + + function onPress(ev: GestureResponderEvent) { + onCheckedChange(!checked); + if (closeOnPress) { + setTriggerPosition(null); + setContentLayout(null); + onValueChange(undefined); + } + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + + + ); + } +); + +CheckboxItem.displayName = 'CheckboxItemMenubar'; + +function useFormItemContext() { + const context = React.useContext(FormItemContext); + if (!context) { + throw new Error( + 'CheckboxItem or RadioItem compound components cannot be rendered outside of a CheckboxItem or RadioItem component' + ); + } + return context; +} + +const RadioGroup = React.forwardRef( + ({ asChild, value, onValueChange, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +RadioGroup.displayName = 'RadioGroupMenubar'; + +type BothFormItemContext = Exclude & { + checked: boolean; +}; + +const RadioItemContext = React.createContext({} as { itemValue: string }); + +const RadioItem = React.forwardRef( + ( + { + asChild, + value: itemValue, + textValue, + onPress: onPressProp, + disabled = false, + closeOnPress = true, + ...props + }, + ref + ) => { + const { + onValueChange: onRootValueChange, + setTriggerPosition, + setContentLayout, + } = useRootContext(); + + const { value, onValueChange } = useFormItemContext() as BothFormItemContext; + function onPress(ev: GestureResponderEvent) { + onValueChange(itemValue); + if (closeOnPress) { + setTriggerPosition(null); + setContentLayout(null); + onRootValueChange(undefined); + } + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + + + ); + } +); + +RadioItem.displayName = 'RadioItemMenubar'; + +function useItemIndicatorContext() { + return React.useContext(RadioItemContext); +} + +const ItemIndicator = React.forwardRef( + ({ asChild, forceMount, ...props }, ref) => { + const { itemValue } = useItemIndicatorContext(); + const { checked, value } = useFormItemContext() as BothFormItemContext; + + if (!forceMount) { + if (itemValue == null && !checked) { + return null; + } + if (value !== itemValue) { + return null; + } + } + const Component = asChild ? Slot.View : View; + return ; + } +); + +ItemIndicator.displayName = 'ItemIndicatorMenubar'; + +const Separator = React.forwardRef( + ({ asChild, decorative, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ; + } +); + +Separator.displayName = 'SeparatorMenubar'; + +const SubContext = React.createContext<{ + nativeID: string; + open: boolean; + onOpenChange: (value: boolean) => void; +} | null>(null); + +const Sub = React.forwardRef( + ({ asChild, open, onOpenChange, ...props }, ref) => { + const nativeID = React.useId(); + + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Sub.displayName = 'SubMenubar'; + +function useSubContext() { + const context = React.useContext(SubContext); + if (!context) { + throw new Error('Sub compound components cannot be rendered outside of a Sub component'); + } + return context; +} + +const SubTrigger = React.forwardRef( + ({ asChild, textValue, onPress: onPressProp, disabled = false, ...props }, ref) => { + const { nativeID, open, onOpenChange } = useSubContext(); + + function onPress(ev: GestureResponderEvent) { + onOpenChange(!open); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +SubTrigger.displayName = 'SubTriggerMenubar'; + +const SubContent = React.forwardRef( + ({ asChild = false, forceMount, ...props }, ref) => { + const { open, nativeID } = useSubContext(); + + if (!forceMount) { + if (!open) { + return null; + } + } + + const Component = asChild ? Slot.View : View; + return ; + } +); + +SubContent.displayName = 'SubContentMenubar'; + +export { + CheckboxItem, + Content, + Group, + Item, + ItemIndicator, + Label, + Menu, + Overlay, + Portal, + RadioGroup, + RadioItem, + Root, + Separator, + Sub, + SubContent, + SubTrigger, + Trigger, + useMenuContext, + useRootContext, + useSubContext, +}; + +function onStartShouldSetResponder() { + return true; +} diff --git a/apps/native/src/components/primitives/menubar/types.ts b/apps/native/src/components/primitives/menubar/types.ts new file mode 100644 index 0000000..5774979 --- /dev/null +++ b/apps/native/src/components/primitives/menubar/types.ts @@ -0,0 +1,76 @@ +import { ForceMountable } from '~/components/primitives/types'; + +interface MenubarRootProps { + value: string | undefined; + onValueChange: (value: string | undefined) => void; +} + +interface MenubarMenuProps { + value: string | undefined; +} + +interface MenubarPortalProps extends ForceMountable { + children: React.ReactNode; + /** + * Platform: NATIVE ONLY + */ + hostName?: string; + /** + * Platform: WEB ONLY + */ + container?: HTMLElement | null | undefined; +} + +interface MenubarOverlayProps extends ForceMountable { + closeOnPress?: boolean; +} + +interface MenubarItemProps { + textValue?: string; + closeOnPress?: boolean; +} + +interface MenubarCheckboxItemProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + closeOnPress?: boolean; + textValue?: string; +} + +interface MenubarRadioGroupProps { + value: string | undefined; + onValueChange: (value: string) => void; +} + +interface MenubarRadioItemProps { + value: string; + textValue?: string; + closeOnPress?: boolean; +} + +interface MenubarSeparatorProps { + decorative?: boolean; +} + +interface MenubarSubProps { + open: boolean; + onOpenChange: (value: boolean) => void; +} + +interface MenubarSubTriggerProps { + textValue?: string; +} + +export type { + MenubarCheckboxItemProps, + MenubarItemProps, + MenubarMenuProps, + MenubarOverlayProps, + MenubarPortalProps, + MenubarRadioGroupProps, + MenubarRadioItemProps, + MenubarRootProps, + MenubarSeparatorProps, + MenubarSubProps, + MenubarSubTriggerProps, +}; diff --git a/apps/native/src/components/primitives/navigation-menu/index.tsx b/apps/native/src/components/primitives/navigation-menu/index.tsx new file mode 100644 index 0000000..c807697 --- /dev/null +++ b/apps/native/src/components/primitives/navigation-menu/index.tsx @@ -0,0 +1,315 @@ +import * as React from 'react'; +import { + BackHandler, + Pressable, + View, + type GestureResponderEvent, + type LayoutChangeEvent, + type LayoutRectangle, +} from 'react-native'; +import { useRelativePosition, type LayoutPosition } from '~/components/primitives/hooks'; +import { Portal as RNPPortal } from '~/components/primitives/portal'; +import * as Slot from '~/components/primitives/slot'; +import type { + PositionedContentProps, + PressableRef, + SlottablePressableProps, + SlottableViewProps, + ViewRef, +} from '~/components/primitives/types'; +import type { + NavigationMenuItemProps, + NavigationMenuLinkProps, + NavigationMenuPortalProps, + NavigationMenuRootProps, +} from './types'; + +interface INavigationMenuRootContext extends NavigationMenuRootProps { + triggerPosition: LayoutPosition | null; + setTriggerPosition: (triggerPosition: LayoutPosition | null) => void; + contentLayout: LayoutRectangle | null; + setContentLayout: (contentLayout: LayoutRectangle | null) => void; + nativeID: string; +} + +const RootContext = React.createContext(null); + +const Root = React.forwardRef( + ({ asChild, value, onValueChange, ...viewProps }, ref) => { + const nativeID = React.useId(); + const [triggerPosition, setTriggerPosition] = React.useState(null); + const [contentLayout, setContentLayout] = React.useState(null); + + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Root.displayName = 'RootNativeNavigationMenu'; + +function useRootContext() { + const context = React.useContext(RootContext); + if (!context) { + throw new Error( + 'NavigationMenu compound components cannot be rendered outside the NavigationMenu component' + ); + } + return context; +} + +const List = React.forwardRef(({ asChild, ...viewProps }, ref) => { + const Component = asChild ? Slot.View : View; + return ; +}); + +List.displayName = 'ListNativeNavigationMenu'; + +const ItemContext = React.createContext<(NavigationMenuItemProps & { nativeID: string }) | null>( + null +); + +const Item = React.forwardRef( + ({ asChild, value, ...viewProps }, ref) => { + const nativeID = React.useId(); + + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Item.displayName = 'ItemNativeNavigationMenu'; + +function useItemContext() { + const context = React.useContext(ItemContext); + if (!context) { + throw new Error( + 'NavigationMenu compound components cannot be rendered outside the NavigationMenu component' + ); + } + return context; +} + +const Trigger = React.forwardRef( + ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { + const triggerRef = React.useRef(null); + const { value, onValueChange, setTriggerPosition } = useRootContext(); + const { value: menuValue } = useItemContext(); + + React.useImperativeHandle( + ref, + () => { + if (!triggerRef.current) { + return new View({}); + } + return triggerRef.current; + }, + [triggerRef.current] + ); + + function onPress(ev: GestureResponderEvent) { + if (disabled) return; + triggerRef.current?.measure((_x, _y, width, height, pageX, pageY) => { + setTriggerPosition({ width, pageX, pageY: pageY, height }); + }); + + onValueChange(menuValue === value ? '' : menuValue); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Trigger.displayName = 'TriggerNativeNavigationMenu'; + +/** + * @warning when using a custom ``, you will have to adjust the Content's sideOffset to account for nav elements like headers. + */ +function Portal({ forceMount, hostName, children }: NavigationMenuPortalProps) { + const navigationMenu = useRootContext(); + const item = useItemContext(); + + if (!navigationMenu.triggerPosition) { + return null; + } + + if (!forceMount) { + if (navigationMenu.value !== item.value) { + return null; + } + } + + return ( + + + {children} + + + ); +} + +/** + * @info `position`, `top`, `left`, and `maxWidth` style properties are controlled internally. Opt out of this behavior by setting `disablePositioningStyle` to `true`. + */ +const Content = React.forwardRef( + ( + { + asChild = false, + forceMount, + align = 'center', + side = 'bottom', + sideOffset = 0, + alignOffset = 0, + avoidCollisions = true, + onLayout: onLayoutProp, + insets, + style, + disablePositioningStyle, + ...props + }, + ref + ) => { + const { + value, + onValueChange, + triggerPosition, + setTriggerPosition, + contentLayout, + setContentLayout, + } = useRootContext(); + const { value: menuValue, nativeID } = useItemContext(); + + React.useEffect(() => { + const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { + setTriggerPosition(null); + setContentLayout(null); + onValueChange(''); + return true; + }); + + return () => { + setContentLayout(null); + backHandler.remove(); + }; + }, []); + + const positionStyle = useRelativePosition({ + align, + avoidCollisions, + triggerPosition, + contentLayout, + alignOffset, + insets, + sideOffset, + side, + disablePositioningStyle, + }); + + function onLayout(event: LayoutChangeEvent) { + setContentLayout(event.nativeEvent.layout); + onLayoutProp?.(event); + } + + if (!forceMount) { + if (value !== menuValue) { + return null; + } + } + + const Component = asChild ? Slot.View : View; + return ( + + ); + } +); + +Content.displayName = 'ContentNativeNavigationMenu'; + +const Link = React.forwardRef( + ({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.Pressable : Pressable; + return ; + } +); + +Link.displayName = 'LinkNativeNavigationMenu'; + +const Viewport = React.forwardRef< + ViewRef, + Omit, 'children'> +>((props, ref) => { + return ; +}); + +Viewport.displayName = 'ViewportNativeNavigationMenu'; + +const Indicator = React.forwardRef(({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ; +}); + +Indicator.displayName = 'IndicatorNativeNavigationMenu'; + +export { + Content, + Indicator, + Item, + Link, + List, + Portal, + Root, + Trigger, + Viewport, + useItemContext, + useRootContext, +}; + +function onStartShouldSetResponder() { + return true; +} diff --git a/apps/native/src/components/primitives/navigation-menu/types.ts b/apps/native/src/components/primitives/navigation-menu/types.ts new file mode 100644 index 0000000..e58fe53 --- /dev/null +++ b/apps/native/src/components/primitives/navigation-menu/types.ts @@ -0,0 +1,49 @@ +import type { ForceMountable } from '~/components/primitives/types'; + +interface NavigationMenuRootProps { + value: string | undefined; + onValueChange: (value: string | undefined) => void; + /** + * Platform: WEB ONLY + */ + delayDuration?: number; + /** + * Platform: WEB ONLY + */ + skipDelayDuration?: number; + /** + * Platform: WEB ONLY + */ + dir?: 'ltr' | 'rtl'; + /** + * Platform: WEB ONLY + */ + orientation?: 'horizontal' | 'vertical'; +} + +interface NavigationMenuItemProps { + value: string | undefined; +} + +interface NavigationMenuPortalProps extends ForceMountable { + children: React.ReactNode; + /** + * Platform: NATIVE ONLY + */ + hostName?: string; + /** + * Platform: WEB ONLY + */ + container?: HTMLElement | null | undefined; +} + +interface NavigationMenuLinkProps { + active?: boolean; +} + +export type { + NavigationMenuItemProps, + NavigationMenuPortalProps, + NavigationMenuRootProps, + NavigationMenuLinkProps, +}; diff --git a/apps/native/src/components/primitives/popover/index.tsx b/apps/native/src/components/primitives/popover/index.tsx new file mode 100644 index 0000000..4e82d8d --- /dev/null +++ b/apps/native/src/components/primitives/popover/index.tsx @@ -0,0 +1,286 @@ +import * as React from 'react'; +import { + BackHandler, + type GestureResponderEvent, + type LayoutChangeEvent, + type LayoutRectangle, + Pressable, + View, +} from 'react-native'; +import { type LayoutPosition, useControllableState, useRelativePosition } from '~/components/primitives/hooks'; +import { Portal as RNPPortal } from '~/components/primitives/portal'; +import * as Slot from '~/components/primitives/slot'; +import type { + PositionedContentProps, + PressableRef, + SlottablePressableProps, + SlottableViewProps, + ViewRef, +} from '~/components/primitives/types'; +import type { + PopoverOverlayProps, + PopoverPortalProps, + PopoverRootProps, + RootContext as RootContextType, +} from './types'; + +interface IRootContext extends RootContextType { + triggerPosition: LayoutPosition | null; + setTriggerPosition: (triggerPosition: LayoutPosition | null) => void; + contentLayout: LayoutRectangle | null; + setContentLayout: (contentLayout: LayoutRectangle | null) => void; + nativeID: string; +} + +const RootContext = React.createContext(null); + +const Root = React.forwardRef( + ({ asChild, open: openProp, defaultOpen, onOpenChange: onOpenChangeProp, ...viewProps }, ref) => { + const [open = false, onOpenChange] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen, + onChange: onOpenChangeProp, + }); + const nativeID = React.useId(); + const [triggerPosition, setTriggerPosition] = React.useState(null); + const [contentLayout, setContentLayout] = React.useState(null); + + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Root.displayName = 'RootNativePopover'; + +function usePopoverContext() { + const context = React.useContext(RootContext); + if (!context) { + throw new Error('Popover compound components cannot be rendered outside the Popover component'); + } + return context; +} + +const Trigger = React.forwardRef( + ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { + const triggerRef = React.useRef(null); + const { open, onOpenChange, setTriggerPosition } = usePopoverContext(); + + React.useImperativeHandle( + ref, + () => { + if (!triggerRef.current) { + return new View({}); + } + return triggerRef.current; + }, + [triggerRef.current] + ); + + function onPress(ev: GestureResponderEvent) { + if (disabled) return; + triggerRef.current?.measure((_x, _y, width, height, pageX, pageY) => { + setTriggerPosition({ width, pageX, pageY: pageY, height }); + }); + const newValue = !open; + onOpenChange(newValue); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Trigger.displayName = 'TriggerNativePopover'; + +/** + * @warning when using a custom ``, you might have to adjust the Content's sideOffset to account for nav elements like headers. + */ +function Portal({ forceMount, hostName, children }: PopoverPortalProps) { + const value = usePopoverContext(); + + if (!value.triggerPosition) { + return null; + } + + if (!forceMount) { + if (!value.open) { + return null; + } + } + + return ( + + {children} + + ); +} + +const Overlay = React.forwardRef( + ({ asChild, forceMount, onPress: OnPressProp, closeOnPress = true, ...props }, ref) => { + const { open, onOpenChange, setTriggerPosition, setContentLayout } = usePopoverContext(); + + function onPress(ev: GestureResponderEvent) { + if (closeOnPress) { + setTriggerPosition(null); + setContentLayout(null); + onOpenChange(false); + } + OnPressProp?.(ev); + } + + if (!forceMount) { + if (!open) { + return null; + } + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ; + } +); + +Overlay.displayName = 'OverlayNativePopover'; + +/** + * @info `position`, `top`, `left`, and `maxWidth` style properties are controlled internally. Opt out of this behavior by setting `disablePositioningStyle` to `true`. + */ +const Content = React.forwardRef( + ( + { + asChild = false, + forceMount, + align = 'start', + side = 'bottom', + sideOffset = 0, + alignOffset = 0, + avoidCollisions = true, + onLayout: onLayoutProp, + insets, + style, + disablePositioningStyle, + ...props + }, + ref + ) => { + const { + open, + onOpenChange, + contentLayout, + nativeID, + setContentLayout, + setTriggerPosition, + triggerPosition, + } = usePopoverContext(); + + React.useEffect(() => { + const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { + setTriggerPosition(null); + setContentLayout(null); + onOpenChange(false); + return true; + }); + + return () => { + setContentLayout(null); + backHandler.remove(); + }; + }, []); + + const positionStyle = useRelativePosition({ + align, + avoidCollisions, + triggerPosition, + contentLayout, + alignOffset, + insets, + sideOffset, + side, + disablePositioningStyle, + }); + + function onLayout(event: LayoutChangeEvent) { + setContentLayout(event.nativeEvent.layout); + onLayoutProp?.(event); + } + + if (!forceMount) { + if (!open) { + return null; + } + } + + const Component = asChild ? Slot.View : View; + return ( + + ); + } +); + +Content.displayName = 'ContentNativePopover'; + +const Close = React.forwardRef( + ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { + const { onOpenChange, setContentLayout, setTriggerPosition } = usePopoverContext(); + + function onPress(ev: GestureResponderEvent) { + if (disabled) return; + setTriggerPosition(null); + setContentLayout(null); + onOpenChange(false); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Close.displayName = 'CloseNativePopover'; + +export { Close, Content, Overlay, Portal, Root, Trigger, usePopoverContext }; + +function onStartShouldSetResponder() { + return true; +} diff --git a/apps/native/src/components/primitives/popover/types.ts b/apps/native/src/components/primitives/popover/types.ts new file mode 100644 index 0000000..26d4cec --- /dev/null +++ b/apps/native/src/components/primitives/popover/types.ts @@ -0,0 +1,30 @@ +import type { ForceMountable } from '~/components/primitives/types'; + +interface RootContext { + open: boolean; + onOpenChange: (value: boolean) => void; +} + +interface PopoverRootProps { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (value: boolean) => void; +} + +interface PopoverPortalProps extends ForceMountable { + children: React.ReactNode; + /** + * Platform: NATIVE ONLY + */ + hostName?: string; + /** + * Platform: WEB ONLY + */ + container?: HTMLElement | null | undefined; +} + +interface PopoverOverlayProps extends ForceMountable { + closeOnPress?: boolean; +} + +export type { PopoverRootProps, PopoverPortalProps, PopoverOverlayProps, RootContext }; diff --git a/apps/native/src/components/primitives/portal.tsx b/apps/native/src/components/primitives/portal.tsx new file mode 100644 index 0000000..a261401 --- /dev/null +++ b/apps/native/src/components/primitives/portal.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { create } from 'zustand'; + +const DEFAULT_PORTAL_HOST = 'INTERNAL_PRIMITIVE_DEFAULT_HOST_NAME'; + +type PortalMap = Map; +type PortalHostMap = Map; + +const usePortal = create<{ map: PortalHostMap }>(() => ({ + map: new Map().set( + DEFAULT_PORTAL_HOST, + new Map() + ), +})); + +const updatePortal = ( + hostName: string, + name: string, + children: React.ReactNode +) => { + usePortal.setState((prev) => { + const next = new Map(prev.map); + const portal = next.get(hostName) ?? new Map(); + portal.set(name, children); + next.set(hostName, portal); + return { map: next }; + }); +}; +const removePortal = (hostName: string, name: string) => { + usePortal.setState((prev) => { + const next = new Map(prev.map); + const portal = next.get(hostName) ?? new Map(); + portal.delete(name); + next.set(hostName, portal); + return { map: next }; + }); +}; + +export function PortalHost({ name = DEFAULT_PORTAL_HOST }: { name?: string }) { + const portalMap = + usePortal((state) => state.map).get(name) ?? + new Map(); + if (portalMap.size === 0) return null; + return <>{Array.from(portalMap.values())}; +} + +export function Portal({ + name, + hostName = DEFAULT_PORTAL_HOST, + children, +}: { + name: string; + hostName?: string; + children: React.ReactNode; +}) { + React.useEffect(() => { + updatePortal(hostName, name, children); + }, [hostName, name, children]); + + React.useEffect(() => { + return () => { + removePortal(hostName, name); + }; + }, [hostName, name]); + + return null; +} diff --git a/apps/native/src/components/primitives/radio-group/index.tsx b/apps/native/src/components/primitives/radio-group/index.tsx new file mode 100644 index 0000000..62d6182 --- /dev/null +++ b/apps/native/src/components/primitives/radio-group/index.tsx @@ -0,0 +1,116 @@ +import * as React from 'react'; +import { Pressable, View, type GestureResponderEvent } from 'react-native'; +import * as Slot from '~/components/primitives/slot'; +import type { + ForceMountable, + PressableRef, + SlottablePressableProps, + SlottableViewProps, + ViewRef, +} from '~/components/primitives/types'; +import type { RadioGroupItemProps, RadioGroupRootProps } from './types'; + +const RadioGroupContext = React.createContext(null); + +const Root = React.forwardRef( + ({ asChild, value, onValueChange, disabled = false, ...viewProps }, ref) => { + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Root.displayName = 'RootRadioGroup'; + +function useRadioGroupContext() { + const context = React.useContext(RadioGroupContext); + if (!context) { + throw new Error( + 'RadioGroup compound components cannot be rendered outside the RadioGroup component' + ); + } + return context; +} + +interface RadioItemContext { + itemValue: string | undefined; +} + +const RadioItemContext = React.createContext(null); + +const Item = React.forwardRef( + ( + { asChild, value: itemValue, disabled: disabledProp = false, onPress: onPressProp, ...props }, + ref + ) => { + const { disabled, value, onValueChange } = useRadioGroupContext(); + + function onPress(ev: GestureResponderEvent) { + if (disabled || disabledProp) return; + onValueChange(itemValue); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + + + ); + } +); + +Item.displayName = 'ItemRadioGroup'; + +function useRadioItemContext() { + const context = React.useContext(RadioItemContext); + if (!context) { + throw new Error( + 'RadioItem compound components cannot be rendered outside the RadioItem component' + ); + } + return context; +} + +const Indicator = React.forwardRef( + ({ asChild, forceMount, ...props }, ref) => { + const { value } = useRadioGroupContext(); + const { itemValue } = useRadioItemContext(); + + if (!forceMount) { + if (value !== itemValue) { + return null; + } + } + const Component = asChild ? Slot.View : View; + return ; + } +); + +Indicator.displayName = 'IndicatorRadioGroup'; + +export { Indicator, Item, Root }; diff --git a/apps/native/src/components/primitives/radio-group/types.ts b/apps/native/src/components/primitives/radio-group/types.ts new file mode 100644 index 0000000..dd14ca3 --- /dev/null +++ b/apps/native/src/components/primitives/radio-group/types.ts @@ -0,0 +1,15 @@ +interface RadioGroupRootProps { + value: string | undefined; + onValueChange: (val: string) => void; + disabled?: boolean; +} + +interface RadioGroupItemProps { + value: string; + /** + * nativeID of the label element that describes this radio group item + */ + 'aria-labelledby': string; +} + +export type { RadioGroupRootProps, RadioGroupItemProps }; diff --git a/apps/native/src/components/primitives/select/index.tsx b/apps/native/src/components/primitives/select/index.tsx new file mode 100644 index 0000000..14d989d --- /dev/null +++ b/apps/native/src/components/primitives/select/index.tsx @@ -0,0 +1,455 @@ +import * as React from 'react'; +import { + BackHandler, + type GestureResponderEvent, + type LayoutChangeEvent, + type LayoutRectangle, + Pressable, + Text, + View, +} from 'react-native'; +import { type LayoutPosition, useControllableState, useRelativePosition } from '~/components/primitives/hooks'; +import { Portal as RNPPortal } from '~/components/primitives/portal'; +import * as Slot from '~/components/primitives/slot'; +import type { + ForceMountable, + PositionedContentProps, + PressableRef, + SlottablePressableProps, + SlottableTextProps, + SlottableViewProps, + TextRef, + ViewRef, +} from '~/components/primitives/types'; +import type { + RootContext as RootContextType, + SelectContentProps, + SelectItemProps, + SelectOverlayProps, + SelectPortalProps, + SelectRootProps, + SelectSeparatorProps, + SelectValueProps, +} from './types'; + +interface IRootContext extends RootContextType { + triggerPosition: LayoutPosition | null; + setTriggerPosition: (triggerPosition: LayoutPosition | null) => void; + contentLayout: LayoutRectangle | null; + setContentLayout: (contentLayout: LayoutRectangle | null) => void; + nativeID: string; +} + +const RootContext = React.createContext(null); + +const Root = React.forwardRef( + ( + { + asChild, + value: valueProp, + defaultValue, + onValueChange: onValueChangeProp, + open: openProp, + defaultOpen, + onOpenChange: onOpenChangeProp, + disabled, + ...viewProps + }, + ref + ) => { + const nativeID = React.useId(); + const [open = false, onOpenChange] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen, + onChange: onOpenChangeProp, + }); + const [value, onValueChange] = useControllableState({ + prop: valueProp, + defaultProp: defaultValue, + onChange: onValueChangeProp, + }); + const [triggerPosition, setTriggerPosition] = React.useState(null); + const [contentLayout, setContentLayout] = React.useState(null); + + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Root.displayName = 'RootNativeSelect'; + +function useRootContext() { + const context = React.useContext(RootContext); + if (!context) { + throw new Error('Select compound components cannot be rendered outside the Select component'); + } + return context; +} + +const Trigger = React.forwardRef( + ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { + const triggerRef = React.useRef(null); + const { open, onOpenChange, disabled: disabledRoot, setTriggerPosition } = useRootContext(); + + React.useImperativeHandle( + ref, + () => { + if (!triggerRef.current) { + return new View({}); + } + return triggerRef.current; + }, + [triggerRef.current] + ); + + function onPress(ev: GestureResponderEvent) { + if (disabled) return; + triggerRef.current?.measure((_x, _y, width, height, pageX, pageY) => { + setTriggerPosition({ width, pageX, pageY: pageY, height }); + }); + onOpenChange(!open); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Trigger.displayName = 'TriggerNativeSelect'; + +const Value = React.forwardRef( + ({ asChild, placeholder, ...props }, ref) => { + const { value } = useRootContext(); + const Component = asChild ? Slot.Text : Text; + return ( + + {value?.label ?? placeholder} + + ); + } +); + +Value.displayName = 'ValueNativeSelect'; + +/** + * @warning when using a custom ``, you might have to adjust the Content's sideOffset. + */ +function Portal({ forceMount, hostName, children }: SelectPortalProps) { + const value = useRootContext(); + + if (!value.triggerPosition) { + return null; + } + + if (!forceMount) { + if (!value.open) { + return null; + } + } + + return ( + + {children} + + ); +} + +const Overlay = React.forwardRef( + ({ asChild, forceMount, onPress: OnPressProp, closeOnPress = true, ...props }, ref) => { + const { open, onOpenChange, setTriggerPosition, setContentLayout } = useRootContext(); + + function onPress(ev: GestureResponderEvent) { + if (closeOnPress) { + setTriggerPosition(null); + setContentLayout(null); + onOpenChange(false); + } + OnPressProp?.(ev); + } + + if (!forceMount) { + if (!open) { + return null; + } + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ; + } +); + +Overlay.displayName = 'OverlayNativeSelect'; + +/** + * @info `position`, `top`, `left`, and `maxWidth` style properties are controlled internally. Opt out of this behavior by setting `disablePositioningStyle` to `true`. + */ +const Content = React.forwardRef< + ViewRef, + SlottableViewProps & PositionedContentProps & SelectContentProps +>( + ( + { + asChild = false, + forceMount, + align = 'start', + side = 'bottom', + sideOffset = 0, + alignOffset = 0, + avoidCollisions = true, + onLayout: onLayoutProp, + insets, + style, + disablePositioningStyle, + position: _position, + ...props + }, + ref + ) => { + const { + open, + onOpenChange, + contentLayout, + nativeID, + triggerPosition, + setContentLayout, + setTriggerPosition, + } = useRootContext(); + + React.useEffect(() => { + const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { + setTriggerPosition(null); + setContentLayout(null); + onOpenChange(false); + return true; + }); + + return () => { + setContentLayout(null); + backHandler.remove(); + }; + }, []); + + const positionStyle = useRelativePosition({ + align, + avoidCollisions, + triggerPosition, + contentLayout, + alignOffset, + insets, + sideOffset, + side, + disablePositioningStyle, + }); + + function onLayout(event: LayoutChangeEvent) { + setContentLayout(event.nativeEvent.layout); + onLayoutProp?.(event); + } + + if (!forceMount) { + if (!open) { + return null; + } + } + + const Component = asChild ? Slot.View : View; + return ( + + ); + } +); + +Content.displayName = 'ContentNativeSelect'; + +const ItemContext = React.createContext<{ + itemValue: string; + label: string; +} | null>(null); + +const Item = React.forwardRef( + ( + { + asChild, + value: itemValue, + label, + onPress: onPressProp, + disabled = false, + closeOnPress = true, + ...props + }, + ref + ) => { + const { onOpenChange, value, onValueChange, setTriggerPosition, setContentLayout } = + useRootContext(); + function onPress(ev: GestureResponderEvent) { + if (closeOnPress) { + setTriggerPosition(null); + setContentLayout(null); + onOpenChange(false); + } + + onValueChange({ value: itemValue, label }); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + + + ); + } +); + +Item.displayName = 'ItemNativeSelect'; + +function useItemContext() { + const context = React.useContext(ItemContext); + if (!context) { + throw new Error('Item compound components cannot be rendered outside of an Item component'); + } + return context; +} + +const ItemText = React.forwardRef>( + ({ asChild, ...props }, ref) => { + const { label } = useItemContext(); + + const Component = asChild ? Slot.Text : Text; + return ( + + {label} + + ); + } +); + +ItemText.displayName = 'ItemTextNativeSelect'; + +const ItemIndicator = React.forwardRef( + ({ asChild, forceMount, ...props }, ref) => { + const { itemValue } = useItemContext(); + const { value } = useRootContext(); + + if (!forceMount) { + if (value?.value !== itemValue) { + return null; + } + } + const Component = asChild ? Slot.View : View; + return ; + } +); + +ItemIndicator.displayName = 'ItemIndicatorNativeSelect'; + +const Group = React.forwardRef(({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ; +}); + +Group.displayName = 'GroupNativeSelect'; + +const Label = React.forwardRef(({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.Text : Text; + return ; +}); + +Label.displayName = 'LabelNativeSelect'; + +const Separator = React.forwardRef( + ({ asChild, decorative, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ; + } +); + +Separator.displayName = 'SeparatorNativeSelect'; + +const ScrollUpButton = ({ children }: { children?: React.ReactNode; className?: string }) => { + return children; +}; + +const ScrollDownButton = ({ children }: { children?: React.ReactNode; className?: string }) => { + return children; +}; + +const Viewport = ({ children }: { children?: React.ReactNode; className?: string }) => { + return children; +}; + +export { + Content, + Group, + Item, + ItemIndicator, + ItemText, + Label, + Overlay, + Portal, + Root, + ScrollDownButton, + ScrollUpButton, + Separator, + Trigger, + Value, + Viewport, + useItemContext, + useRootContext, +}; + +export type { Option } from './types'; + +function onStartShouldSetResponder() { + return true; +} diff --git a/apps/native/src/components/primitives/select/types.ts b/apps/native/src/components/primitives/select/types.ts new file mode 100644 index 0000000..2787f96 --- /dev/null +++ b/apps/native/src/components/primitives/select/types.ts @@ -0,0 +1,87 @@ +import type { ForceMountable } from '~/components/primitives/types'; + +type Option = + | { + value: string; + label: string; + } + | undefined; + +interface RootContext { + value: Option; + onValueChange: (option: Option) => void; + open: boolean; + onOpenChange: (value: boolean) => void; + disabled?: boolean; +} + +interface SelectRootProps { + value?: Option; + defaultValue?: Option; + onValueChange?: (option: Option) => void; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (value: boolean) => void; + disabled?: boolean; + /** + * Platform: WEB ONLY + */ + dir?: 'ltr' | 'rtl'; + /** + * Platform: WEB ONLY + */ + name?: string; + /** + * Platform: WEB ONLY + */ + required?: boolean; +} + +interface SelectValueProps { + placeholder: string; +} + +interface SelectPortalProps extends ForceMountable { + children: React.ReactNode; + /** + * Platform: NATIVE ONLY + */ + hostName?: string; + /** + * Platform: WEB ONLY + */ + container?: HTMLElement | null | undefined; +} + +interface SelectOverlayProps extends ForceMountable { + closeOnPress?: boolean; +} + +interface SelectContentProps { + /** + * Platform: WEB ONLY + */ + position?: 'popper' | 'item-aligned' | undefined; +} + +interface SelectItemProps { + value: string; + label: string; + closeOnPress?: boolean; +} + +interface SelectSeparatorProps { + decorative?: boolean; +} + +export type { + Option, + RootContext, + SelectContentProps, + SelectItemProps, + SelectOverlayProps, + SelectPortalProps, + SelectRootProps, + SelectSeparatorProps, + SelectValueProps, +}; diff --git a/apps/native/src/components/primitives/separator/index.tsx b/apps/native/src/components/primitives/separator/index.tsx new file mode 100644 index 0000000..f21c733 --- /dev/null +++ b/apps/native/src/components/primitives/separator/index.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { View } from 'react-native'; +import * as Slot from '~/components/primitives/slot'; +import type { SlottableViewProps, ViewRef } from '~/components/primitives/types'; +import type { SeparatorRootProps } from './types'; + +const Root = React.forwardRef( + ({ asChild, decorative, orientation = 'horizontal', ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ( + + ); + } +); + +Root.displayName = 'RootSeparator'; + +export { Root }; diff --git a/apps/native/src/lib/rn-primitives/separator/types.ts b/apps/native/src/components/primitives/separator/types.ts similarity index 51% rename from apps/native/src/lib/rn-primitives/separator/types.ts rename to apps/native/src/components/primitives/separator/types.ts index a1e0aa9..fd6434a 100644 --- a/apps/native/src/lib/rn-primitives/separator/types.ts +++ b/apps/native/src/components/primitives/separator/types.ts @@ -1,6 +1,6 @@ interface SeparatorRootProps { - orientation?: "horizontal" | "vertical"; - decorative?: boolean; + orientation?: 'horizontal' | 'vertical'; + decorative?: boolean; } export type { SeparatorRootProps }; diff --git a/apps/native/src/components/primitives/slot.tsx b/apps/native/src/components/primitives/slot.tsx new file mode 100644 index 0000000..403b91e --- /dev/null +++ b/apps/native/src/components/primitives/slot.tsx @@ -0,0 +1,187 @@ +import * as React from 'react'; +import { + Image as RNImage, + Pressable as RNPressable, + Text as RNText, + View as RNView, + StyleSheet, + type PressableStateCallbackType, + type ImageProps as RNImageProps, + type ImageStyle as RNImageStyle, + type PressableProps as RNPressableProps, + type TextProps as RNTextProps, + type ViewProps as RNViewProps, + type StyleProp, +} from 'react-native'; + +const Pressable = React.forwardRef, RNPressableProps>( + (props, forwardedRef) => { + const { children, ...pressableSlotProps } = props; + + if (!React.isValidElement(children)) { + console.log('Slot.Pressable - Invalid asChild element', children); + return null; + } + + return React.cloneElement< + React.ComponentPropsWithoutRef, + React.ElementRef + >(isTextChildren(children) ? <> : children, { + ...mergeProps(pressableSlotProps, children.props), + ref: forwardedRef ? composeRefs(forwardedRef, (children as any).ref) : (children as any).ref, + }); + } +); + +Pressable.displayName = 'SlotPressable'; + +const View = React.forwardRef, RNViewProps>( + (props, forwardedRef) => { + const { children, ...viewSlotProps } = props; + + if (!React.isValidElement(children)) { + console.log('Slot.View - Invalid asChild element', children); + return null; + } + + return React.cloneElement< + React.ComponentPropsWithoutRef, + React.ElementRef + >(isTextChildren(children) ? <> : children, { + ...mergeProps(viewSlotProps, children.props), + ref: forwardedRef ? composeRefs(forwardedRef, (children as any).ref) : (children as any).ref, + }); + } +); + +View.displayName = 'SlotView'; + +const Text = React.forwardRef, RNTextProps>( + (props, forwardedRef) => { + const { children, ...textSlotProps } = props; + + if (!React.isValidElement(children)) { + console.log('Slot.Text - Invalid asChild element', children); + return null; + } + + return React.cloneElement< + React.ComponentPropsWithoutRef, + React.ElementRef + >(isTextChildren(children) ? <> : children, { + ...mergeProps(textSlotProps, children.props), + ref: forwardedRef ? composeRefs(forwardedRef, (children as any).ref) : (children as any).ref, + }); + } +); + +Text.displayName = 'SlotText'; + +type ImageSlotProps = RNImageProps & { + children?: React.ReactNode; +}; + +const Image = React.forwardRef, ImageSlotProps>( + (props, forwardedRef) => { + const { children, ...imageSlotProps } = props; + + if (!React.isValidElement(children)) { + console.log('Slot.Image - Invalid asChild element', children); + return null; + } + + return React.cloneElement< + React.ComponentPropsWithoutRef, + React.ElementRef + >(isTextChildren(children) ? <> : children, { + ...mergeProps(imageSlotProps, children.props), + ref: forwardedRef ? composeRefs(forwardedRef, (children as any).ref) : (children as any).ref, + }); + } +); + +Image.displayName = 'SlotImage'; + +export { Image, Pressable, Text, View }; + +// This project uses code from WorkOS/Radix Primitives. +// The code is licensed under the MIT License. +// https://github.com/radix-ui/primitives/tree/main + +function composeRefs(...refs: (React.Ref | undefined)[]) { + return (node: T) => + refs.forEach((ref) => { + if (typeof ref === 'function') { + ref(node); + } else if (ref != null) { + (ref as React.MutableRefObject).current = node; + } + }); +} + +type AnyProps = Record; + +function mergeProps(slotProps: AnyProps, childProps: AnyProps) { + // all child props should override + const overrideProps = { ...childProps }; + + for (const propName in childProps) { + const slotPropValue = slotProps[propName]; + const childPropValue = childProps[propName]; + + const isHandler = /^on[A-Z]/.test(propName); + if (isHandler) { + // if the handler exists on both, we compose them + if (slotPropValue && childPropValue) { + overrideProps[propName] = (...args: unknown[]) => { + childPropValue(...args); + slotPropValue(...args); + }; + } + // but if it exists only on the slot, we use only this one + else if (slotPropValue) { + overrideProps[propName] = slotPropValue; + } + } + // if it's `style`, we merge them + else if (propName === 'style') { + overrideProps[propName] = combineStyles(slotPropValue, childPropValue); + } else if (propName === 'className') { + overrideProps[propName] = [slotPropValue, childPropValue].filter(Boolean).join(' '); + } + } + + return { ...slotProps, ...overrideProps }; +} + +type PressableStyle = RNPressableProps['style']; +type ImageStyle = StyleProp; +type Style = PressableStyle | ImageStyle; + +function combineStyles(slotStyle?: Style, childValue?: Style) { + if (typeof slotStyle === 'function' && typeof childValue === 'function') { + return (state: PressableStateCallbackType) => { + return StyleSheet.flatten([slotStyle(state), childValue(state)]); + }; + } + if (typeof slotStyle === 'function') { + return (state: PressableStateCallbackType) => { + return childValue ? StyleSheet.flatten([slotStyle(state), childValue]) : slotStyle(state); + }; + } + if (typeof childValue === 'function') { + return (state: PressableStateCallbackType) => { + return slotStyle ? StyleSheet.flatten([slotStyle, childValue(state)]) : childValue(state); + }; + } + + return StyleSheet.flatten([slotStyle, childValue].filter(Boolean)); +} + +export function isTextChildren( + children: React.ReactNode | ((state: PressableStateCallbackType) => React.ReactNode) +) { + return Array.isArray(children) + ? children.every((child) => typeof child === 'string') + : typeof children === 'string'; +} diff --git a/apps/native/src/components/primitives/switch/index.tsx b/apps/native/src/components/primitives/switch/index.tsx new file mode 100644 index 0000000..ceebe36 --- /dev/null +++ b/apps/native/src/components/primitives/switch/index.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; +import { Pressable, View, type GestureResponderEvent } from 'react-native'; +import * as Slot from '~/components/primitives/slot'; +import type { + PressableRef, + SlottablePressableProps, + SlottableViewProps, + ViewRef, +} from '~/components/primitives/types'; +import type { SwitchRootProps } from './types'; + +const Root = React.forwardRef< + PressableRef, + SlottablePressableProps & SwitchRootProps +>( + ( + { + asChild, + checked, + onCheckedChange, + disabled, + onPress: onPressProp, + 'aria-valuetext': ariaValueText, + ...props + }, + ref + ) => { + function onPress(ev: GestureResponderEvent) { + if (disabled) return; + onCheckedChange(!checked); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Root.displayName = 'RootNativeSwitch'; + +const Thumb = React.forwardRef( + ({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ; + } +); + +Thumb.displayName = 'ThumbNativeSwitch'; + +export { Root, Thumb }; diff --git a/apps/native/src/components/primitives/switch/types.ts b/apps/native/src/components/primitives/switch/types.ts new file mode 100644 index 0000000..986c204 --- /dev/null +++ b/apps/native/src/components/primitives/switch/types.ts @@ -0,0 +1,11 @@ +interface SwitchRootProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled?: boolean; + /** + * Platform: WEB ONLY + */ + onKeyDown?: (ev: React.KeyboardEvent) => void; +} + +export type { SwitchRootProps }; diff --git a/apps/native/src/components/primitives/table.tsx b/apps/native/src/components/primitives/table.tsx new file mode 100644 index 0000000..509b687 --- /dev/null +++ b/apps/native/src/components/primitives/table.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { Pressable, View } from 'react-native'; +import * as Slot from '~/components/primitives/slot'; +import type { + PressableRef, + SlottablePressableProps, + SlottableViewProps, + ViewRef, +} from '~/components/primitives/types'; + +const Root = React.forwardRef(({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ; +}); +Root.displayName = 'RootTable'; + +const Header = React.forwardRef(({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ; +}); +Header.displayName = 'HeaderTable'; + +const Row = React.forwardRef( + ({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.Pressable : Pressable; + return ; + } +); +Row.displayName = 'RowTable'; + +const Head = React.forwardRef(({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ; +}); +Head.displayName = 'HeadTable'; + +const Body = React.forwardRef(({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ; +}); +Body.displayName = 'BodyTable'; + +const Cell = React.forwardRef(({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ; +}); +Cell.displayName = 'CellTable'; + +const Footer = React.forwardRef(({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ; +}); +Footer.displayName = 'FooterTable'; + +export { Body, Cell, Footer, Head, Header, Root, Row }; diff --git a/apps/native/src/components/primitives/tabs/index.tsx b/apps/native/src/components/primitives/tabs/index.tsx new file mode 100644 index 0000000..e9fd2df --- /dev/null +++ b/apps/native/src/components/primitives/tabs/index.tsx @@ -0,0 +1,133 @@ +import * as React from 'react'; +import { Pressable, View, type GestureResponderEvent } from 'react-native'; +import * as Slot from '~/components/primitives/slot'; +import type { ComponentPropsWithAsChild, SlottableViewProps, ViewRef } from '~/components/primitives/types'; +import type { TabsContentProps, TabsRootProps } from './types'; + +interface RootContext extends TabsRootProps { + nativeID: string; +} + +const TabsContext = React.createContext(null); + +const Root = React.forwardRef( + ( + { + asChild, + value, + onValueChange, + orientation: _orientation, + dir: _dir, + activationMode: _activationMode, + ...viewProps + }, + ref + ) => { + const nativeID = React.useId(); + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Root.displayName = 'RootNativeTabs'; + +function useRootContext() { + const context = React.useContext(TabsContext); + if (!context) { + throw new Error('Tabs compound components cannot be rendered outside the Tabs component'); + } + return context; +} + +const List = React.forwardRef(({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ; +}); + +List.displayName = 'ListNativeTabs'; + +const TriggerContext = React.createContext<{ value: string } | null>(null); + +const Trigger = React.forwardRef< + React.ElementRef, + ComponentPropsWithAsChild & { + value: string; + } +>(({ asChild, onPress: onPressProp, disabled, value: tabValue, ...props }, ref) => { + const { onValueChange, value: rootValue, nativeID } = useRootContext(); + + function onPress(ev: GestureResponderEvent) { + if (disabled) return; + onValueChange(tabValue); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + + + ); +}); + +Trigger.displayName = 'TriggerNativeTabs'; + +function useTriggerContext() { + const context = React.useContext(TriggerContext); + if (!context) { + throw new Error( + 'Tabs.Trigger compound components cannot be rendered outside the Tabs.Trigger component' + ); + } + return context; +} + +const Content = React.forwardRef( + ({ asChild, forceMount, value: tabValue, ...props }, ref) => { + const { value: rootValue, nativeID } = useRootContext(); + + if (!forceMount) { + if (rootValue !== tabValue) { + return null; + } + } + + const Component = asChild ? Slot.View : View; + return ( + + ); + } +); + +Content.displayName = 'ContentNativeTabs'; + +export { Content, List, Root, Trigger, useRootContext, useTriggerContext }; diff --git a/apps/native/src/components/primitives/tabs/types.ts b/apps/native/src/components/primitives/tabs/types.ts new file mode 100644 index 0000000..d78a066 --- /dev/null +++ b/apps/native/src/components/primitives/tabs/types.ts @@ -0,0 +1,24 @@ +import type { ForceMountable } from '~/components/primitives/types'; + +interface TabsRootProps { + value: string; + onValueChange: (value: string) => void; + /** + * Platform: WEB ONLY + */ + orientation?: 'horizontal' | 'vertical'; + /** + * Platform: WEB ONLY + */ + dir?: 'ltr' | 'rtl'; + /** + * Platform: WEB ONLY + */ + activationMode?: 'automatic' | 'manual'; +} + +interface TabsContentProps extends ForceMountable { + value: string; +} + +export type { TabsContentProps, TabsRootProps }; diff --git a/apps/native/src/components/primitives/toggle-group/index.tsx b/apps/native/src/components/primitives/toggle-group/index.tsx new file mode 100644 index 0000000..d799fc0 --- /dev/null +++ b/apps/native/src/components/primitives/toggle-group/index.tsx @@ -0,0 +1,125 @@ +import * as React from 'react'; +import { Pressable, View, type GestureResponderEvent } from 'react-native'; +import * as Slot from '~/components/primitives/slot'; +import type { + PressableRef, + SlottablePressableProps, + SlottableViewProps, + ViewRef, +} from '~/components/primitives/types'; +import { ToggleGroupUtils } from '~/components/primitives/utils'; +import type { ToggleGroupItemProps, ToggleGroupRootProps } from './types'; + +const ToggleGroupContext = React.createContext(null); + +const Root = React.forwardRef( + ( + { + asChild, + type, + value, + onValueChange, + disabled = false, + rovingFocus: _rovingFocus, + orientation: _orientation, + dir: _dir, + loop: _loop, + ...viewProps + }, + ref + ) => { + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Root.displayName = 'RootToggleGroup'; + +function useRootContext() { + const context = React.useContext(ToggleGroupContext); + if (!context) { + throw new Error( + 'ToggleGroup compound components cannot be rendered outside the ToggleGroup component' + ); + } + return context; +} + +const ItemContext = React.createContext(null); + +const Item = React.forwardRef( + ( + { asChild, value: itemValue, disabled: disabledProp = false, onPress: onPressProp, ...props }, + ref + ) => { + const id = React.useId(); + const { type, disabled, value, onValueChange } = useRootContext(); + + function onPress(ev: GestureResponderEvent) { + if (disabled || disabledProp) return; + if (type === 'single') { + onValueChange(ToggleGroupUtils.getNewSingleValue(value, itemValue)); + } + if (type === 'multiple') { + onValueChange(ToggleGroupUtils.getNewMultipleValue(value, itemValue)); + } + onPressProp?.(ev); + } + + const isChecked = + type === 'single' ? ToggleGroupUtils.getIsSelected(value, itemValue) : undefined; + const isSelected = + type === 'multiple' ? ToggleGroupUtils.getIsSelected(value, itemValue) : undefined; + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + + + ); + } +); + +Item.displayName = 'ItemToggleGroup'; + +function useItemContext() { + const context = React.useContext(ItemContext); + if (!context) { + throw new Error( + 'ToggleGroupItem compound components cannot be rendered outside the ToggleGroupItem component' + ); + } + return context; +} + +const utils = ToggleGroupUtils; + +export { Item, Root, useItemContext, useRootContext, utils }; diff --git a/apps/native/src/components/primitives/toggle-group/types.ts b/apps/native/src/components/primitives/toggle-group/types.ts new file mode 100644 index 0000000..da7e64e --- /dev/null +++ b/apps/native/src/components/primitives/toggle-group/types.ts @@ -0,0 +1,37 @@ +type SingleRootProps = { + type: 'single'; + value: string | undefined; + onValueChange: (val: string | undefined) => void; +}; + +type MultipleRootProps = { + type: 'multiple'; + value: string[]; + onValueChange: (val: string[]) => void; +}; + +type ToggleGroupRootProps = (SingleRootProps | MultipleRootProps) & { + disabled?: boolean; + /** + * Platform: WEB ONLY + */ + rovingFocus?: boolean; + /** + * Platform: WEB ONLY + */ + orientation?: 'horizontal' | 'vertical'; + /** + * Platform: WEB ONLY + */ + dir?: 'ltr' | 'rtl'; + /** + * Platform: WEB ONLY + */ + loop?: boolean; +}; + +interface ToggleGroupItemProps { + value: string; +} + +export type { ToggleGroupRootProps, ToggleGroupItemProps }; diff --git a/apps/native/src/components/primitives/toggle/index.tsx b/apps/native/src/components/primitives/toggle/index.tsx new file mode 100644 index 0000000..d124ba3 --- /dev/null +++ b/apps/native/src/components/primitives/toggle/index.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { Pressable, type GestureResponderEvent } from 'react-native'; +import * as Slot from '~/components/primitives/slot'; +import type { PressableRef, SlottablePressableProps } from '~/components/primitives/types'; +import type { ToggleRootProps } from './types'; + +const Root = React.forwardRef( + ({ asChild, pressed, onPressedChange, disabled, onPress: onPressProp, ...props }, ref) => { + function onPress(ev: GestureResponderEvent) { + if (disabled) return; + const newValue = !pressed; + onPressedChange(newValue); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Root.displayName = 'RootNativeToggle'; + +export { Root }; diff --git a/apps/native/src/components/primitives/toggle/types.ts b/apps/native/src/components/primitives/toggle/types.ts new file mode 100644 index 0000000..3ccc248 --- /dev/null +++ b/apps/native/src/components/primitives/toggle/types.ts @@ -0,0 +1,7 @@ +interface ToggleRootProps { + pressed: boolean; + onPressedChange: (pressed: boolean) => void; + disabled?: boolean; +} + +export type { ToggleRootProps }; diff --git a/apps/native/src/components/primitives/tooltip/index.tsx b/apps/native/src/components/primitives/tooltip/index.tsx new file mode 100644 index 0000000..5851a07 --- /dev/null +++ b/apps/native/src/components/primitives/tooltip/index.tsx @@ -0,0 +1,271 @@ +import * as React from 'react'; +import { + BackHandler, + type GestureResponderEvent, + type LayoutChangeEvent, + type LayoutRectangle, + Pressable, + View, +} from 'react-native'; +import { type LayoutPosition, useControllableState, useRelativePosition } from '~/components/primitives/hooks'; +import { Portal as RNPPortal } from '~/components/primitives/portal'; +import * as Slot from '~/components/primitives/slot'; +import type { + PositionedContentProps, + PressableRef, + SlottablePressableProps, + SlottableViewProps, + ViewRef, +} from '~/components/primitives/types'; +import type { + RootContext as RootContextType, + TooltipOverlayProps, + TooltipPortalProps, + TooltipRootProps, +} from './types'; + +interface IRootContext extends RootContextType { + triggerPosition: LayoutPosition | null; + setTriggerPosition: (triggerPosition: LayoutPosition | null) => void; + contentLayout: LayoutRectangle | null; + setContentLayout: (contentLayout: LayoutRectangle | null) => void; + nativeID: string; +} + +const RootContext = React.createContext(null); + +const Root = React.forwardRef( + ( + { + asChild, + defaultOpen, + open: openProp, + onOpenChange: onOpenChangeProp, + delayDuration: _delayDuration, + skipDelayDuration: _skipDelayDuration, + disableHoverableContent: _disableHoverableContent, + ...viewProps + }, + ref + ) => { + const nativeID = React.useId(); + const [triggerPosition, setTriggerPosition] = React.useState(null); + const [contentLayout, setContentLayout] = React.useState(null); + + const [open = false, onOpenChange] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen, + onChange: onOpenChangeProp, + }); + + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Root.displayName = 'RootNativeTooltip'; + +function useTooltipContext() { + const context = React.useContext(RootContext); + if (!context) { + throw new Error('Tooltip compound components cannot be rendered outside the Tooltip component'); + } + return context; +} + +const Trigger = React.forwardRef( + ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { + const triggerRef = React.useRef(null); + const { open, onOpenChange, setTriggerPosition } = useTooltipContext(); + + React.useImperativeHandle( + ref, + () => { + if (!triggerRef.current) { + return new View({}); + } + return triggerRef.current; + }, + [triggerRef.current] + ); + + function onPress(ev: GestureResponderEvent) { + if (disabled) return; + triggerRef.current?.measure((_x, _y, width, height, pageX, pageY) => { + setTriggerPosition({ width, pageX, pageY: pageY, height }); + }); + const newValue = !open; + onOpenChange(newValue); + onPressProp?.(ev); + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + ); + } +); + +Trigger.displayName = 'TriggerNativeTooltip'; + +/** + * @warning when using a custom ``, you might have to adjust the Content's sideOffset to account for nav elements like headers. + */ +function Portal({ forceMount, hostName, children }: TooltipPortalProps) { + const value = useTooltipContext(); + + if (!value.triggerPosition) { + return null; + } + + if (!forceMount) { + if (!value.open) { + return null; + } + } + + return ( + + {children} + + ); +} + +const Overlay = React.forwardRef( + ({ asChild, forceMount, onPress: OnPressProp, closeOnPress = true, ...props }, ref) => { + const { open, onOpenChange, setContentLayout, setTriggerPosition } = useTooltipContext(); + + function onPress(ev: GestureResponderEvent) { + if (closeOnPress) { + setTriggerPosition(null); + setContentLayout(null); + onOpenChange(false); + } + OnPressProp?.(ev); + } + + if (!forceMount) { + if (!open) { + return null; + } + } + + const Component = asChild ? Slot.Pressable : Pressable; + return ; + } +); + +Overlay.displayName = 'OverlayNativeTooltip'; + +/** + * @info `position`, `top`, `left`, and `maxWidth` style properties are controlled internally. Opt out of this behavior on native by setting `disablePositioningStyle` to `true`. + */ +const Content = React.forwardRef( + ( + { + asChild = false, + forceMount, + align = 'center', + side = 'top', + sideOffset = 0, + alignOffset = 0, + avoidCollisions = true, + onLayout: onLayoutProp, + insets, + style, + disablePositioningStyle, + ...props + }, + ref + ) => { + const { + open, + onOpenChange, + nativeID, + contentLayout, + setContentLayout, + setTriggerPosition, + triggerPosition, + } = useTooltipContext(); + + React.useEffect(() => { + const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { + setTriggerPosition(null); + setContentLayout(null); + onOpenChange(false); + return true; + }); + + return () => { + setContentLayout(null); + backHandler.remove(); + }; + }, []); + + const positionStyle = useRelativePosition({ + align, + avoidCollisions, + triggerPosition, + contentLayout, + alignOffset, + insets, + sideOffset, + side, + disablePositioningStyle, + }); + + function onLayout(event: LayoutChangeEvent) { + setContentLayout(event.nativeEvent.layout); + onLayoutProp?.(event); + } + + if (!forceMount) { + if (!open) { + return null; + } + } + + const Component = asChild ? Slot.View : View; + return ( + + ); + } +); + +Content.displayName = 'ContentNativeTooltip'; + +export { Content, Overlay, Portal, Root, Trigger }; + +function onStartShouldSetResponder() { + return true; +} diff --git a/apps/native/src/components/primitives/tooltip/types.ts b/apps/native/src/components/primitives/tooltip/types.ts new file mode 100644 index 0000000..ff63bf0 --- /dev/null +++ b/apps/native/src/components/primitives/tooltip/types.ts @@ -0,0 +1,44 @@ +import type { ForceMountable } from '~/components/primitives/types'; + +interface RootContext extends TooltipRootProps { + open: boolean; + onOpenChange: (value: boolean) => void; +} + +interface TooltipRootProps { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (value: boolean) => void; + /** + * Platform: WEB ONLY + * @default 700 + */ + delayDuration?: number; + /** + * Platform: WEB ONLY + * @default 300 + */ + skipDelayDuration?: number; + /** + * Platform: WEB ONLY + */ + disableHoverableContent?: boolean; +} + +interface TooltipPortalProps extends ForceMountable { + children: React.ReactNode; + /** + * Platform: NATIVE ONLY + */ + hostName?: string; + /** + * Platform: WEB ONLY + */ + container?: HTMLElement | null | undefined; +} + +interface TooltipOverlayProps extends ForceMountable { + closeOnPress?: boolean; +} + +export type { RootContext, TooltipOverlayProps, TooltipPortalProps, TooltipRootProps }; diff --git a/apps/native/src/components/primitives/types.ts b/apps/native/src/components/primitives/types.ts new file mode 100644 index 0000000..40f26e2 --- /dev/null +++ b/apps/native/src/components/primitives/types.ts @@ -0,0 +1,105 @@ +import type { Pressable, Text, View, ViewStyle } from 'react-native'; + +type ComponentPropsWithAsChild> = + React.ComponentPropsWithoutRef & { asChild?: boolean }; + +type ViewRef = React.ElementRef; +type PressableRef = React.ElementRef; +type TextRef = React.ElementRef; + +type SlottableViewProps = ComponentPropsWithAsChild; +type SlottablePressableProps = ComponentPropsWithAsChild & { + /** + * Platform: WEB ONLY + */ + onKeyDown?: (ev: React.KeyboardEvent) => void; + /** + * Platform: WEB ONLY + */ + onKeyUp?: (ev: React.KeyboardEvent) => void; +}; +type SlottableTextProps = ComponentPropsWithAsChild; + +interface Insets { + top?: number; + bottom?: number; + left?: number; + right?: number; +} + +type PointerDownOutsideEvent = CustomEvent<{ originalEvent: PointerEvent }>; +type FocusOutsideEvent = CustomEvent<{ originalEvent: FocusEvent }>; + +/** + * Certain props are only available on the native version of the component. + * @docs For the web version, see the Radix documentation https://www.radix-ui.com/primitives + */ +interface PositionedContentProps { + forceMount?: true | undefined; + style?: ViewStyle; + alignOffset?: number; + insets?: Insets; + avoidCollisions?: boolean; + align?: 'start' | 'center' | 'end'; + side?: 'top' | 'bottom'; + sideOffset?: number; + /** + * Platform: NATIVE ONLY + */ + disablePositioningStyle?: boolean; + /** + * Platform: WEB ONLY + */ + loop?: boolean; + /** + * Platform: WEB ONLY + */ + onCloseAutoFocus?: (event: Event) => void; + /** + * Platform: WEB ONLY + */ + onEscapeKeyDown?: (event: KeyboardEvent) => void; + /** + * Platform: WEB ONLY + */ + onPointerDownOutside?: (event: PointerDownOutsideEvent) => void; + /** + * Platform: WEB ONLY + */ + onFocusOutside?: (event: FocusOutsideEvent) => void; + /** + * Platform: WEB ONLY + */ + onInteractOutside?: ( + event: PointerDownOutsideEvent | FocusOutsideEvent + ) => void; + /** + * Platform: WEB ONLY + */ + collisionBoundary?: Element | null | Array; + /** + * Platform: WEB ONLY + */ + sticky?: 'partial' | 'always'; + /** + * Platform: WEB ONLY + */ + hideWhenDetached?: boolean; +} + +interface ForceMountable { + forceMount?: true | undefined; +} + +export type { + ComponentPropsWithAsChild, + ForceMountable, + Insets, + PositionedContentProps, + PressableRef, + SlottablePressableProps, + SlottableTextProps, + SlottableViewProps, + TextRef, + ViewRef, +}; diff --git a/apps/native/src/components/primitives/utils.ts b/apps/native/src/components/primitives/utils.ts new file mode 100644 index 0000000..8453d37 --- /dev/null +++ b/apps/native/src/components/primitives/utils.ts @@ -0,0 +1,61 @@ +import type { GestureResponderEvent } from 'react-native'; + +const ToggleGroupUtils = { + getIsSelected(value: string | string[] | undefined, itemValue: string) { + if (value === undefined) { + return false; + } + if (typeof value === 'string') { + return value === itemValue; + } + return value.includes(itemValue); + }, + getNewSingleValue(originalValue: string | string[] | undefined, itemValue: string) { + if (originalValue === itemValue) { + return undefined; + } + return itemValue; + }, + getNewMultipleValue(originalValue: string | string[] | undefined, itemValue: string) { + if (originalValue === undefined) { + return [itemValue]; + } + if (typeof originalValue === 'string') { + return originalValue === itemValue ? [] : [originalValue, itemValue]; + } + if (originalValue.includes(itemValue)) { + return originalValue.filter((v) => v !== itemValue); + } + return [...originalValue, itemValue]; + }, +}; + +const EmptyGestureResponderEvent: GestureResponderEvent = { + nativeEvent: { + changedTouches: [], + identifier: '0', + locationX: 0, + locationY: 0, + pageX: 0, + pageY: 0, + target: '0', + timestamp: 0, + touches: [], + }, + bubbles: false, + cancelable: false, + currentTarget: {} as any, + defaultPrevented: false, + eventPhase: 0, + persist: () => {}, + isDefaultPrevented: () => false, + isPropagationStopped: () => false, + isTrusted: false, + preventDefault: () => {}, + stopPropagation: () => {}, + target: {} as any, + timeStamp: 0, + type: '', +}; + +export { ToggleGroupUtils, EmptyGestureResponderEvent }; diff --git a/apps/native/src/components/ui/accordion.tsx b/apps/native/src/components/ui/accordion.tsx index cc20bc4..6d545a3 100644 --- a/apps/native/src/components/ui/accordion.tsx +++ b/apps/native/src/components/ui/accordion.tsx @@ -1,204 +1,128 @@ -import { ChevronDown } from "lucide-react-native"; -import React from "react"; -import { LayoutChangeEvent, Pressable, View } from "react-native"; +import * as React from 'react'; +import { Platform, Pressable, View } from 'react-native'; import Animated, { - Extrapolate, - SharedValue, - interpolate, - measure, - runOnUI, - useAnimatedStyle, - useDerivedValue, - useSharedValue, - withTiming, -} from "react-native-reanimated"; -import { Separator } from "~/components/ui/separator"; -import { cn } from "~/lib/utils"; + Extrapolation, + FadeIn, + FadeOutUp, + LayoutAnimationConfig, + LayoutAnimation, + LinearTransition, + interpolate, + useAnimatedStyle, + useDerivedValue, + withTiming, +} from 'react-native-reanimated'; +import { ChevronDown } from '~/components/Icons'; +import * as AccordionPrimitive from '~/components/primitives/accordion'; +import { TextClassContext } from '~/components/ui/text'; +import { cn } from '~/lib/utils'; const Accordion = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); - -Accordion.displayName = "Accordion"; - -interface AccordionItemProps { - disabled?: boolean; - defaultOpen?: boolean; - onChange?: (isOpen: boolean) => void; -} - -interface AccordionItemContext extends AccordionItemProps { - innerContentRef: React.RefObject; - contentHeight: SharedValue; - progress: SharedValue; - open: SharedValue; - nativeID: string; -} + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ children, ...props }, ref) => { + return ( + + + {children} + + + ); +}); -const AccordionItemContext = React.createContext( - {} as AccordionItemContext, -); +Accordion.displayName = AccordionPrimitive.Root.displayName; const AccordionItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & AccordionItemProps ->( - ( - { className, disabled = false, defaultOpen = false, onChange, ...props }, - ref, - ) => { - const nativeID = React.useId(); - const open = useSharedValue(defaultOpen); - const contentHeight = useSharedValue(0); - const innerContentRef = React.useRef(null); - const progress = useDerivedValue(() => - open.value ? withTiming(1) : withTiming(0), - ); - return ( - - - - ); - }, -); - -AccordionItem.displayName = "AccordionItem"; + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => { + return ( + + + + ); +}); +AccordionItem.displayName = AccordionPrimitive.Item.displayName; -function useAccordionItemContext() { - const context = React.useContext(AccordionItemContext); - if (!context) { - throw new Error( - "AccordionItem compound components cannot be rendered outside the AccordionItem component", - ); - } - return context; -} +const Trigger = Platform.OS === 'web' ? View : Pressable; const AccordionTrigger = React.forwardRef< - React.ElementRef, - Omit, "onPress"> & { - children: React.ReactNode; - } ->(({ children, className, ...props }, ref) => { - const { - contentHeight, - open, - innerContentRef, - onChange, - disabled, - nativeID, - progress, - } = useAccordionItemContext(); - const [isOpen, setIsOpen] = React.useState(open.value); - - const chevronAnimationStyle = useAnimatedStyle(() => ({ - transform: [ - { - rotate: - contentHeight.value === 0 ? "0deg" : `${progress.value * 180}deg`, - }, - ], - opacity: interpolate(progress.value, [0, 1], [1, 0.8], Extrapolate.CLAMP), - })); - - function onPress() { - if (disabled) return; - if (contentHeight.value === 0) { - runOnUI(() => { - "worklet"; - contentHeight.value = measure(innerContentRef).height; - })(); - } - open.value = !open.value; - setIsOpen(open.value); - onChange?.(open.value); - } - - return ( - <> - - {children} - - - - - - - ); + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => { + const { isExpanded } = AccordionPrimitive.useItemContext(); + + const progress = useDerivedValue(() => + isExpanded ? withTiming(1, { duration: 250 }) : withTiming(0, { duration: 200 }) + ); + const chevronStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${progress.value * 180}deg` }], + opacity: interpolate(progress.value, [0, 1], [1, 0.8], Extrapolation.CLAMP), + })); + + return ( + + + + + <>{children} + + + + + + + + ); }); - -AccordionTrigger.displayName = "AccordionTrigger"; +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; const AccordionContent = React.forwardRef< - React.ElementRef, - Omit, "onLayout"> & { - children: React.ReactNode; - } ->(({ children, className, ...props }, ref) => { - const { contentHeight, innerContentRef, nativeID, progress } = - useAccordionItemContext(); - - const heightAnimationStyle = useAnimatedStyle(() => ({ - height: interpolate( - progress.value, - [0, 1], - [0, contentHeight.value], - Extrapolate.CLAMP, - ), - })); - - const onLayout = React.useCallback( - ({ - nativeEvent: { - layout: { height }, - }, - }: LayoutChangeEvent) => { - contentHeight.value = height; - }, - [contentHeight], - ); - - return ( - - - - {children} - - - - ); + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => { + const { isExpanded } = AccordionPrimitive.useItemContext(); + return ( + + + {children} + + + ); }); -AccordionContent.displayName = "AccordionContent"; +function InnerContent({ children, className }: { children: React.ReactNode; className?: string }) { + if (Platform.OS === 'web') { + return {children}; + } + return ( + + {children} + + ); +} + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }; diff --git a/apps/native/src/components/ui/alert-dialog.tsx b/apps/native/src/components/ui/alert-dialog.tsx index c4f912b..4622749 100644 --- a/apps/native/src/components/ui/alert-dialog.tsx +++ b/apps/native/src/components/ui/alert-dialog.tsx @@ -1,300 +1,167 @@ -import { useColorScheme } from "nativewind"; -import React from "react"; -import { - GestureResponderEvent, - Modal, - Pressable, - StyleSheet, - Text, - View, - ViewStyle, -} from "react-native"; -import { cn } from "~/lib/utils"; -import { Button } from "~/components/ui/button"; -import * as Slot from "~/lib/rn-primitives/slot/slot-native"; +import * as React from 'react'; +import { Platform, StyleSheet, View } from 'react-native'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import { buttonTextVariants, buttonVariants } from '~/components/ui/button'; +import * as AlertDialogPrimitive from '~/components/primitives/alert-dialog'; +import { cn } from '~/lib/utils'; +import { TextClassContext } from '~/components/ui/text'; -interface AlertDialogProps { - children: React.ReactNode; - closeOnOverlayPress?: boolean; - defaultOpen?: boolean; - open?: boolean; - setOpen?: React.Dispatch>; -} -interface AlertDialogContext { - visible: boolean; - setVisible: React.Dispatch>; - closeOnOverlayPress: boolean; -} +const AlertDialog = AlertDialogPrimitive.Root; -const AlertDialogContext = React.createContext( - {} as AlertDialogContext, -); - -const AlertDialog = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & AlertDialogProps ->( - ( - { - open, - setOpen, - closeOnOverlayPress = false, - defaultOpen = false, - ...props - }, - ref, - ) => { - const [visible, setVisible] = React.useState(defaultOpen ?? false); - return ( - - - - ); - }, -); - -AlertDialog.displayName = "AlertDialog"; - -function useAlertDialogContext() { - const context = React.useContext(AlertDialogContext); - if (!context) { - throw new Error( - "AlertDialog compound components cannot be rendered outside the AlertDialog component", - ); - } - return context; -} +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; -const AlertDialogTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - asChild?: boolean; - } ->(({ onPress, asChild = false, ...props }, ref) => { - const { setVisible } = useAlertDialogContext(); - function handleOnPress(event: GestureResponderEvent) { - setVisible(true); - onPress?.(event); - } +const AlertDialogPortal = AlertDialogPrimitive.Portal; - const Trigger = asChild ? Slot.Pressable : Button; - return ; -}); - -AlertDialogTrigger.displayName = "AlertDialogTrigger"; - -const AlertDialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { overlayClass?: string } ->( - ( - { - className, - children, - animationType = "fade", - style: styleProp, - overlayClass, - ...props - }, - ref, - ) => { - const { colorScheme } = useColorScheme(); - const { visible, setVisible, closeOnOverlayPress } = - useAlertDialogContext(); - const [style, setStyle] = React.useState( - StyleSheet.flatten(styleProp), - ); - - React.useEffect(() => { - setStyle( - StyleSheet.flatten([ - colorScheme === "dark" ? styles.shadowDark : styles.shadowLight, - styleProp, - ]), - ); - }, [styleProp, colorScheme]); - - return ( - { - setVisible((prev) => !prev); - }} - statusBarTranslucent - {...props} - > - { - setVisible(false); - } - : undefined - } - className={cn( - "flex-1 justify-center items-center p-2", - animationType !== "slide" && "bg-zinc-50/80 dark:bg-zinc-900/80", - overlayClass, - )} - > - - {children} - - - - ); - }, -); - -AlertDialogContent.displayName = "AlertDialogContent"; - -const AlertDialogHeader = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef +const AlertDialogOverlayWeb = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => { - return ; + const { open } = AlertDialogPrimitive.useRootContext(); + return ( + + ); }); -AlertDialogHeader.displayName = "AlertDialogHeader"; - -const AlertDialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - return ( - - ); +AlertDialogOverlayWeb.displayName = 'AlertDialogOverlayWeb'; + +const AlertDialogOverlayNative = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => { + return ( + + + {children} + + + ); }); -AlertDialogTitle.displayName = "AlertDialogTitle"; +AlertDialogOverlayNative.displayName = 'AlertDialogOverlayNative'; -const AlertDialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - return ( - - ); +const AlertDialogOverlay = Platform.select({ + web: AlertDialogOverlayWeb, + default: AlertDialogOverlayNative, }); -AlertDialogDescription.displayName = "AlertDialogDescription"; - -const AlertDialogFooter = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => { - return ( - - ); + const { open } = AlertDialogPrimitive.useRootContext(); + + return ( + + + + + + ); }); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; -AlertDialogFooter.displayName = "AlertDialogFooter"; - -const AlertDialogCancel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - asChild?: boolean; - } ->(({ variant = "outline", asChild = false, ...props }, ref) => { - const { setVisible } = useAlertDialogContext(); - const Trigger = asChild ? Slot.Pressable : Button; - return ( - { - setVisible(false); - }} - ref={ref} - {...props} - /> - ); -}); +const AlertDialogHeader = ({ + className, + ...props +}: React.ComponentPropsWithoutRef) => ( + +); +AlertDialogHeader.displayName = 'AlertDialogHeader'; + +const AlertDialogFooter = ({ + className, + ...props +}: React.ComponentPropsWithoutRef) => ( + +); +AlertDialogFooter.displayName = 'AlertDialogFooter'; -AlertDialogCancel.displayName = "AlertDialogCancel"; +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; -type ButtonProps = React.ComponentPropsWithoutRef; +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; const AlertDialogAction = React.forwardRef< - React.ElementRef, - Omit & { - asChild?: boolean; - onPress?: - | ((event: GestureResponderEvent) => void) - | ((event: GestureResponderEvent) => Promise); - } ->(({ onPress, asChild, ...props }, ref) => { - const { setVisible } = useAlertDialogContext(); - async function onPressAction(ev: GestureResponderEvent) { - await onPress?.(ev); - setVisible(false); - } - - const Trigger = asChild ? Slot.Pressable : Button; - return ; -}); + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; -AlertDialogAction.displayName = "AlertDialogAction"; +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; export { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, }; - -const styles = StyleSheet.create({ - shadowLight: { - shadowColor: "#000000", - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.1, - shadowRadius: 8, - elevation: 5, - }, - shadowDark: { - shadowColor: "#000000", - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.25, - shadowRadius: 8, - elevation: 5, - }, -}); diff --git a/apps/native/src/components/ui/alert.tsx b/apps/native/src/components/ui/alert.tsx deleted file mode 100644 index 2bebcc3..0000000 --- a/apps/native/src/components/ui/alert.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import * as React from "react"; -import { cva, type VariantProps } from "class-variance-authority"; - -import { cn } from "~/lib/utils"; -import { View, Text, StyleSheet, ViewStyle } from "react-native"; -import * as LucideIcon from "lucide-react-native"; -import { useColorScheme } from "nativewind"; - -const alertVariants = cva( - "bg-background relative w-full rounded-lg border p-5", - { - variants: { - variant: { - default: "border-muted-foreground", - destructive: "border-destructive", - success: "border-emerald-500", - }, - }, - defaultVariants: { - variant: "default", - }, - }, -); - -const Alert = React.forwardRef< - React.ElementRef, - Omit, "style"> & - VariantProps & { - icon?: keyof typeof LucideIcon; - style?: ViewStyle; - } ->(({ children, icon, className, variant, style: styleProp, ...props }, ref) => { - const { colorScheme } = useColorScheme(); - const [style, setStyle] = React.useState( - StyleSheet.flatten(styleProp), - ); - - React.useEffect(() => { - setStyle( - StyleSheet.flatten([ - colorScheme === "dark" ? styles.shadowDark : styles.shadowLight, - styleProp, - ]), - ); - }, [styleProp, colorScheme]); - - const Icon = LucideIcon[icon ?? "AlertTriangle"] as LucideIcon.Icon; - return ( - - {icon && ( - - )} - {children} - - ); -}); -Alert.displayName = "Alert"; - -const AlertTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AlertTitle.displayName = "AlertTitle"; - -const AlertDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AlertDescription.displayName = "AlertDescription"; - -export { Alert, AlertTitle, AlertDescription }; - -const styles = StyleSheet.create({ - shadowLight: { - shadowColor: "#000000", - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.1, - shadowRadius: 8, - elevation: 2, - }, - shadowDark: { - shadowColor: "#FFFFFF", - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.05, - shadowRadius: 8, - elevation: 1, - }, -}); diff --git a/apps/native/src/components/ui/avatar.tsx b/apps/native/src/components/ui/avatar.tsx index afad104..d8704c6 100644 --- a/apps/native/src/components/ui/avatar.tsx +++ b/apps/native/src/components/ui/avatar.tsx @@ -1,69 +1,44 @@ -import React from "react"; -import { - Image, - ImageErrorEventData, - NativeSyntheticEvent, - Text, - View, -} from "react-native"; - -import { cn } from "~/lib/utils"; +import * as React from 'react'; +import * as AvatarPrimitive from '~/components/primitives/avatar'; +import { cn } from '~/lib/utils'; const Avatar = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )); -Avatar.displayName = "Avatar"; +Avatar.displayName = AvatarPrimitive.Root.displayName; const AvatarImage = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const [hasError, setHasError] = React.useState(false); - - function onError(error: NativeSyntheticEvent) { - setHasError(!!error); - } - - if (hasError) { - return null; - } - return ( - - ); -}); -AvatarImage.displayName = "AvatarImage"; + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; const AvatarFallback = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { textClass?: string } ->(({ children, textClass, className, ...props }, ref) => ( - - {children} - + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + )); -AvatarFallback.displayName = "AvatarFallback"; +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; -export { Avatar, AvatarImage, AvatarFallback }; +export { Avatar, AvatarFallback, AvatarImage }; diff --git a/apps/native/src/components/ui/badge.tsx b/apps/native/src/components/ui/badge.tsx index 9ce3580..07663af 100644 --- a/apps/native/src/components/ui/badge.tsx +++ b/apps/native/src/components/ui/badge.tsx @@ -1,62 +1,51 @@ -import { cva, type VariantProps } from "class-variance-authority"; -import React from "react"; -import { Text, View } from "react-native"; +import { cva, type VariantProps } from 'class-variance-authority'; +import { View } from 'react-native'; +import * as Slot from '~/components/primitives/slot'; +import type { SlottableViewProps } from '~/components/primitives/types'; +import { cn } from '~/lib/utils'; +import { TextClassContext } from '~/components/ui/text'; -const badgeRootVariants = cva( - "items-center rounded-full border px-2.5 py-0.5", - { - variants: { - variant: { - default: "border-transparent bg-primary ", - secondary: "border-transparent bg-secondary ", - destructive: "border-transparent bg-destructive ", - outline: "border-border", - }, - }, - defaultVariants: { - variant: "default", - }, - }, +const badgeVariants = cva( + 'web:inline-flex items-center rounded-full border border-border px-2.5 py-0.5 web:transition-colors web:focus:outline-none web:focus:ring-2 web:focus:ring-ring web:focus:ring-offset-2', + { + variants: { + variant: { + default: 'border-transparent bg-primary web:hover:opacity-80 active:opacity-80', + secondary: 'border-transparent bg-secondary web:hover:opacity-80 active:opacity-80', + destructive: 'border-transparent bg-destructive web:hover:opacity-80 active:opacity-80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + } ); -const badgeTextVariants = cva("font-semibold", { - variants: { - variant: { - default: "text-primary-foreground", - secondary: "text-secondary-foreground", - destructive: "text-destructive-foreground", - outline: "text-foreground", - }, - size: { - sm: "text-xs native:text-sm", - md: "text-sm native:text-base", - lg: "text-base native:text-lg", - }, - }, - defaultVariants: { - variant: "default", - size: "md", - }, +const badgeTextVariants = cva('text-xs font-semibold ', { + variants: { + variant: { + default: 'text-primary-foreground', + secondary: 'text-secondary-foreground', + destructive: 'text-destructive-foreground', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, }); -const Badge = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & - VariantProps & { textClass?: string } ->(({ className, children, textClass, variant, size, ...props }, ref) => { - return ( - - - {children} - - - ); -}); +type BadgeProps = SlottableViewProps & VariantProps; + +function Badge({ className, variant, asChild, ...props }: BadgeProps) { + const Component = asChild ? Slot.View : View; + return ( + + + + ); +} -export { Badge, badgeRootVariants, badgeTextVariants }; +export { Badge, badgeTextVariants, badgeVariants }; +export type { BadgeProps }; diff --git a/apps/native/src/components/ui/bottom-sheet.native.tsx b/apps/native/src/components/ui/bottom-sheet.native.tsx deleted file mode 100644 index f8f0069..0000000 --- a/apps/native/src/components/ui/bottom-sheet.native.tsx +++ /dev/null @@ -1,356 +0,0 @@ -import type { - BottomSheetBackdropProps, - BottomSheetFooterProps as GBottomSheetFooterProps, -} from "@gorhom/bottom-sheet"; -import { - BottomSheetBackdrop, - BottomSheetModal, - BottomSheetFlatList as GBottomSheetFlatList, - BottomSheetFooter as GBottomSheetFooter, - BottomSheetTextInput as GBottomSheetTextInput, - BottomSheetView as GBottomSheetView, - useBottomSheetModal, -} from "@gorhom/bottom-sheet"; -import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types"; -import { X } from "lucide-react-native"; -import { useColorScheme } from "nativewind"; -import React, { useCallback, useImperativeHandle } from "react"; -import { - GestureResponderEvent, - Keyboard, - Pressable, - View, - ViewStyle, -} from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Button } from "~/components/ui/button"; -import { NAV_THEME } from "~/lib/constants"; -import * as Slot from "~/lib/rn-primitives/slot/slot-native"; -import { cn } from "~/lib/utils"; - -type BottomSheetRef = React.ElementRef; -type BottomSheetProps = React.ComponentPropsWithoutRef; - -interface BottomSheetContext { - sheetRef: React.RefObject; -} - -const BottomSheetContext = React.createContext({} as BottomSheetContext); - -const BottomSheet = React.forwardRef( - ({ ...props }, ref) => { - const sheetRef = React.useRef(null); - - return ( - - - - ); - }, -); - -function useBottomSheetContext() { - const context = React.useContext(BottomSheetContext); - if (!context) { - throw new Error( - "BottomSheet compound components cannot be rendered outside the BottomSheet component", - ); - } - return context; -} - -const CLOSED_INDEX = -1; - -type BottomSheetContentRef = React.ElementRef; - -type BottomSheetContentProps = Omit< - React.ComponentPropsWithoutRef, - "backdropComponent" -> & { - backdropProps?: Partial< - React.ComponentPropsWithoutRef - >; -}; - -const BottomSheetContent = React.forwardRef< - BottomSheetContentRef, - BottomSheetContentProps ->( - ( - { - enablePanDownToClose = true, - enableDynamicSizing = true, - index = 0, - backdropProps, - backgroundStyle, - android_keyboardInputMode = "adjustResize", - ...props - }, - ref, - ) => { - const insets = useSafeAreaInsets(); - const { colorScheme } = useColorScheme(); - const { sheetRef } = useBottomSheetContext(); - - useImperativeHandle( - ref, - () => { - if (!sheetRef.current) { - return {} as BottomSheetModalMethods; - } - return sheetRef.current; - }, - [sheetRef.current], - ); - - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => { - const { - pressBehavior = "close", - opacity = colorScheme === "dark" ? 0.3 : 0.7, - disappearsOnIndex = CLOSED_INDEX, - style, - onPress, - ...rest - } = { - ...props, - ...backdropProps, - }; - return ( - { - if (Keyboard.isVisible()) { - Keyboard.dismiss(); - } - onPress?.(); - }} - {...rest} - /> - ); - }, - [backdropProps, colorScheme], - ); - - return ( - - ); - }, -); - -const BottomSheetOpenTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - asChild?: boolean; - } ->(({ onPress, asChild = false, ...props }, ref) => { - const { sheetRef } = useBottomSheetContext(); - function handleOnPress(ev: GestureResponderEvent) { - sheetRef.current?.present(); - onPress?.(ev); - } - const Trigger = asChild ? Slot.Pressable : Pressable; - return ; -}); - -const BottomSheetCloseTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - asChild?: boolean; - } ->(({ onPress, asChild = false, ...props }, ref) => { - const { dismiss } = useBottomSheetModal(); - function handleOnPress(ev: GestureResponderEvent) { - dismiss(); - if (Keyboard.isVisible()) { - Keyboard.dismiss(); - } - onPress?.(ev); - } - const Trigger = asChild ? Slot.Pressable : Pressable; - return ; -}); - -const BOTTOM_SHEET_HEADER_HEIGHT = 60; // BottomSheetHeader height - -type BottomSheetViewProps = Omit< - React.ComponentPropsWithoutRef, - "style" -> & { - hadHeader?: boolean; - style?: ViewStyle; -}; - -function BottomSheetView({ - className, - children, - hadHeader = true, - style, - ...props -}: BottomSheetViewProps) { - const insets = useSafeAreaInsets(); - return ( - - {children} - - ); -} - -type BottomSheetTextInputRef = React.ElementRef; -type BottomSheetTextInputProps = React.ComponentPropsWithoutRef< - typeof GBottomSheetTextInput ->; -const BottomSheetTextInput = React.forwardRef< - BottomSheetTextInputRef, - BottomSheetTextInputProps ->(({ className, placeholderClassName, ...props }, ref) => { - return ( - - ); -}); - -type BottomSheetFlatListRef = React.ElementRef; -type BottomSheetFlatListProps = React.ComponentPropsWithoutRef< - typeof GBottomSheetFlatList ->; -const BottomSheetFlatList = React.forwardRef< - BottomSheetFlatListRef, - BottomSheetFlatListProps ->(({ className, ...props }, ref) => { - const insets = useSafeAreaInsets(); - return ( - - ); -}); - -type BottomSheetHeaderRef = React.ElementRef; -type BottomSheetHeaderProps = React.ComponentPropsWithoutRef; -const BottomSheetHeader = React.forwardRef< - BottomSheetHeaderRef, - BottomSheetHeaderProps ->(({ className, children, ...props }, ref) => { - const { dismiss } = useBottomSheetModal(); - function close() { - if (Keyboard.isVisible()) { - Keyboard.dismiss(); - } - dismiss(); - } - return ( - - {children} - - - ); -}); - -type BottomSheetFooterRef = React.ElementRef; -type BottomSheetFooterProps = Omit< - React.ComponentPropsWithoutRef, - "style" -> & { - bottomSheetFooterProps: GBottomSheetFooterProps; - children?: React.ReactNode; - style?: ViewStyle; -}; - -/** - * To be used in a useCallback function as a props to BottomSheetContent - */ -const BottomSheetFooter = React.forwardRef< - BottomSheetFooterRef, - BottomSheetFooterProps ->(({ bottomSheetFooterProps, children, className, style, ...props }, ref) => { - const insets = useSafeAreaInsets(); - return ( - - - {children} - - - ); -}); - -function useBottomSheet() { - const ref = React.useRef(null); - - const open = useCallback(() => { - ref.current?.present(); - }, []); - - const close = useCallback(() => { - ref.current?.dismiss(); - }, []); - - return { ref, open, close }; -} - -export { - BottomSheet, - BottomSheetCloseTrigger, - BottomSheetContent, - BottomSheetFlatList, - BottomSheetFooter, - BottomSheetHeader, - BottomSheetOpenTrigger, - BottomSheetTextInput, - BottomSheetView, - useBottomSheet, -}; diff --git a/apps/native/src/components/ui/bottom-sheet.tsx b/apps/native/src/components/ui/bottom-sheet.tsx deleted file mode 100644 index 99dfe43..0000000 --- a/apps/native/src/components/ui/bottom-sheet.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import type { BottomSheetFooterProps as GBottomSheetFooterProps } from "@gorhom/bottom-sheet"; -import { - BottomSheetBackdrop, - BottomSheetModal, - BottomSheetFlatList as GBottomSheetFlatList, - BottomSheetFooter as GBottomSheetFooter, - BottomSheetTextInput as GBottomSheetTextInput, - BottomSheetView as GBottomSheetView, - useBottomSheetModal, -} from "@gorhom/bottom-sheet"; -import { X } from "lucide-react-native"; -import React, { useCallback } from "react"; -import { - GestureResponderEvent, - Keyboard, - Pressable, - View, - ViewStyle, -} from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import * as Slot from "~/lib/rn-primitives/slot/slot-native"; -import { Button } from "~/components/ui/button"; -import { cn } from "~/lib/utils"; - -// !IMPORTANT: This file is only for web. BottomSheet is not available for web yet. -// Should be available in v5 which is in alpha: components/ui/bottom-sheet.tsx - -type BottomSheetRef = React.ElementRef; -type BottomSheetProps = React.ComponentPropsWithoutRef; - -interface BottomSheetContext { - sheetRef: React.RefObject; -} - -const BottomSheetContext = React.createContext({} as BottomSheetContext); - -const BottomSheet = React.forwardRef( - ({ ...props }, ref) => { - return ; - }, -); - -type BottomSheetContentRef = React.ElementRef; - -type BottomSheetContentProps = Omit< - React.ComponentPropsWithoutRef, - "backdropComponent" -> & { - backdropProps?: Partial< - React.ComponentPropsWithoutRef - >; -}; - -const BottomSheetContent = React.forwardRef< - BottomSheetContentRef, - BottomSheetContentProps ->(() => { - return null; -}); - -const BottomSheetOpenTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - asChild?: boolean; - } ->(({ onPress, asChild = false, ...props }, ref) => { - function handleOnPress() { - window.alert( - "Not implemented for web yet. Check `bottom-sheet.tsx` for more info.", - ); - } - const Trigger = asChild ? Slot.Pressable : Pressable; - return ; -}); - -const BottomSheetCloseTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - asChild?: boolean; - } ->(({ onPress, asChild = false, ...props }, ref) => { - const { dismiss } = useBottomSheetModal(); - function handleOnPress(ev: GestureResponderEvent) { - dismiss(); - if (Keyboard.isVisible()) { - Keyboard.dismiss(); - } - onPress?.(ev); - } - const Trigger = asChild ? Slot.Pressable : Pressable; - return ; -}); - -const BOTTOM_SHEET_HEADER_HEIGHT = 60; // BottomSheetHeader height - -type BottomSheetViewProps = Omit< - React.ComponentPropsWithoutRef, - "style" -> & { - hadHeader?: boolean; - style?: ViewStyle; -}; - -function BottomSheetView({ - className, - children, - hadHeader = true, - style, - ...props -}: BottomSheetViewProps) { - const insets = useSafeAreaInsets(); - return ( - - {children} - - ); -} - -type BottomSheetTextInputRef = React.ElementRef; -type BottomSheetTextInputProps = React.ComponentPropsWithoutRef< - typeof GBottomSheetTextInput ->; -const BottomSheetTextInput = React.forwardRef< - BottomSheetTextInputRef, - BottomSheetTextInputProps ->(({ className, placeholderClassName, ...props }, ref) => { - return ( - - ); -}); - -type BottomSheetFlatListRef = React.ElementRef; -type BottomSheetFlatListProps = React.ComponentPropsWithoutRef< - typeof GBottomSheetFlatList ->; -const BottomSheetFlatList = React.forwardRef< - BottomSheetFlatListRef, - BottomSheetFlatListProps ->(({ className, ...props }, ref) => { - const insets = useSafeAreaInsets(); - return ( - - ); -}); - -type BottomSheetHeaderRef = React.ElementRef; -type BottomSheetHeaderProps = React.ComponentPropsWithoutRef; -const BottomSheetHeader = React.forwardRef< - BottomSheetHeaderRef, - BottomSheetHeaderProps ->(({ className, children, ...props }, ref) => { - const { dismiss } = useBottomSheetModal(); - function close() { - if (Keyboard.isVisible()) { - Keyboard.dismiss(); - } - dismiss(); - } - return ( - - {children} - - - ); -}); - -type BottomSheetFooterRef = React.ElementRef; -type BottomSheetFooterProps = Omit< - React.ComponentPropsWithoutRef, - "style" -> & { - bottomSheetFooterProps: GBottomSheetFooterProps; - children?: React.ReactNode; - style?: ViewStyle; -}; - -/** - * To be used in a useCallback function as a props to BottomSheetContent - */ -const BottomSheetFooter = React.forwardRef< - BottomSheetFooterRef, - BottomSheetFooterProps ->(({ bottomSheetFooterProps, children, className, style, ...props }, ref) => { - const insets = useSafeAreaInsets(); - return ( - - - {children} - - - ); -}); - -function useBottomSheet() { - const ref = React.useRef(null); - - const open = useCallback(() => { - ref.current?.present(); - }, []); - - const close = useCallback(() => { - ref.current?.dismiss(); - }, []); - - return { ref, open, close }; -} - -export { - BottomSheet, - BottomSheetCloseTrigger, - BottomSheetContent, - BottomSheetFlatList, - BottomSheetFooter, - BottomSheetHeader, - BottomSheetOpenTrigger, - BottomSheetTextInput, - BottomSheetView, - useBottomSheet, -}; diff --git a/apps/native/src/components/ui/button.tsx b/apps/native/src/components/ui/button.tsx index 42c57fd..5e0e4a0 100644 --- a/apps/native/src/components/ui/button.tsx +++ b/apps/native/src/components/ui/button.tsx @@ -1,139 +1,88 @@ -import { cva } from "class-variance-authority"; -import type { VariantProps } from "class-variance-authority"; -import * as React from "react"; - -import { useColorScheme } from "nativewind"; -import { Platform, Pressable, Text, View } from "react-native"; -import { cn, isTextChildren } from "~/lib/utils"; -import * as Slot from "~/lib/rn-primitives/slot/slot-native"; +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; +import { Pressable } from 'react-native'; +import { TextClassContext } from '~/components/ui/text'; +import { cn } from '~/lib/utils'; const buttonVariants = cva( - "flex-row items-center justify-center rounded-lg web:ring-offset-background web:transition-colors web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2", - { - variants: { - variant: { - default: "bg-primary", - destructive: "bg-destructive", - outline: "border border-input bg-background", - secondary: "bg-secondary", - ghost: "", - link: "", - }, - size: { - default: "px-4 py-2 native:px-6 native:py-3.5", - sm: "px-3 py-1 native:py-2", - lg: "px-8 py-1.5 native:py-4", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - }, + 'group flex items-center justify-center rounded-md web:ring-offset-background web:transition-colors web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2', + { + variants: { + variant: { + default: 'bg-primary web:hover:opacity-90 active:opacity-90', + destructive: 'bg-destructive web:hover:opacity-90 active:opacity-90', + outline: + 'border border-input bg-background web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent', + secondary: 'bg-secondary web:hover:opacity-80 active:opacity-80', + ghost: 'web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent', + link: 'web:underline-offset-4 web:hover:underline web:focus:underline ', + }, + size: { + default: 'h-10 px-4 py-2 native:h-12 native:px-5 native:py-3', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8 native:h-14', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } ); -const buttonTextVariants = cva("font-medium", { - variants: { - variant: { - default: "text-primary-foreground", - destructive: "text-destructive-foreground", - outline: "text-foreground", - secondary: "text-secondary-foreground", - ghost: "text-foreground", - link: "text-primary underline", - }, - size: { - default: "text-sm native:text-xl", - sm: "text-xs native:text-lg", - lg: "text-base native:text-2xl", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, -}); +const buttonTextVariants = cva( + 'web:whitespace-nowrap text-sm native:text-base font-medium text-foreground web:transition-colors', + { + variants: { + variant: { + default: 'text-primary-foreground', + destructive: 'text-destructive-foreground', + outline: 'group-active:text-accent-foreground', + secondary: 'text-secondary-foreground group-active:text-secondary-foreground', + ghost: 'group-active:text-accent-foreground', + link: 'text-primary group-active:underline', + }, + size: { + default: '', + sm: '', + lg: 'native:text-lg', + icon: '', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +); -const rippleColor = (isThemeDark: boolean) => { - const secondary = isThemeDark ? "hsl(240 4% 16%)" : "hsl(240 5% 96%)"; - return { - default: isThemeDark ? "#d4d4d8" : "#3f3f46", - destructive: isThemeDark ? "#b91c1c" : "#f87171", - outline: secondary, - secondary: isThemeDark ? "#3f3f46" : "#e4e4e7", - ghost: secondary, - link: secondary, - }; -}; +type ButtonProps = React.ComponentPropsWithoutRef & + VariantProps; -const Button = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & - VariantProps & { - textClass?: string; - androidRootClass?: string; - } ->( - ( - { - className, - textClass, - variant = "default", - size, - children, - androidRootClass, - disabled, - ...props - }, - ref, - ) => { - const { colorScheme } = useColorScheme(); - const Root = Platform.OS === "android" ? View : Slot.Pressable; - return ( - - - {isTextChildren(children) - ? ({ pressed, hovered }) => ( - - {children as string | string[]} - - ) - : children} - - - ); - }, +const Button = React.forwardRef, ButtonProps>( + ({ className, variant, size, ...props }, ref) => { + return ( + + + + ); + } ); -Button.displayName = "Button"; +Button.displayName = 'Button'; export { Button, buttonTextVariants, buttonVariants }; +export type { ButtonProps }; diff --git a/apps/native/src/components/ui/calendar.tsx b/apps/native/src/components/ui/calendar.tsx deleted file mode 100644 index c229f91..0000000 --- a/apps/native/src/components/ui/calendar.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { useColorScheme } from "nativewind"; -import React from "react"; -import { Calendar as RNCalendar, LocaleConfig } from "react-native-calendars"; -import { NAV_THEME } from "~/lib/constants"; - -/** - * @docs https://github.com/wix/react-native-calendars - */ -function Calendar({ - theme, - ...props -}: React.ComponentProps) { - const { colorScheme } = useColorScheme(); - const id = React.useId(); - - return ( - - ); -} - -const SKY_500 = "#0ea5e9"; -const SKY_600 = "#0284c7"; - -function getTheme( - isThemeDark: boolean, - customTheme?: React.ComponentProps["theme"], -): React.ComponentProps["theme"] { - if (isThemeDark) { - return { - backgroundColor: NAV_THEME.dark.background, - calendarBackground: NAV_THEME.dark.card, - textSectionTitleColor: NAV_THEME.dark.text, - selectedDayBackgroundColor: SKY_500, - selectedDayTextColor: "#000000", - todayTextColor: SKY_500, - dayTextColor: NAV_THEME.dark.text, - textDisabledColor: "#ffffff30", - monthTextColor: NAV_THEME.dark.text, - textMonthFontWeight: "500", - arrowColor: SKY_500, - ...customTheme, - }; - } - return { - backgroundColor: NAV_THEME.light.background, - calendarBackground: NAV_THEME.light.card, - textSectionTitleColor: NAV_THEME.light.text, - selectedDayBackgroundColor: SKY_600, - selectedDayTextColor: "#ffffff", - todayTextColor: SKY_600, - dayTextColor: "#2d4150", - monthTextColor: NAV_THEME.light.text, - textMonthFontWeight: "500", - arrowColor: SKY_600, - ...customTheme, - }; -} - -LocaleConfig.locales.en = { - monthNames: [ - "January", - "Febuary", - "March", - "April", - "May", - "June", - "July", - "August", - "Septemeber", - "October", - "November", - "December", - ], - monthNamesShort: [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sept", - "Oct", - "Nov", - "Dec", - ], - dayNames: [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - ], - dayNamesShort: ["Sun", "Mon", "Tue", "Wed", "Thur", "Fri", "Sat"], - today: "Today", -}; - -LocaleConfig.locales.fr = { - monthNames: [ - "Janvier", - "Février", - "Mars", - "Avril", - "Mai", - "Juin", - "Juillet", - "Août", - "Septembre", - "Octobre", - "Novembre", - "Décembre", - ], - monthNamesShort: [ - "Janv.", - "Févr.", - "Mars", - "Avril", - "Mai", - "Juin", - "Juil.", - "Août", - "Sept.", - "Oct.", - "Nov.", - "Déc.", - ], - dayNames: [ - "Dimanche", - "Lundi", - "Mardi", - "Mercredi", - "Jeudi", - "Vendredi", - "Samedi", - ], - dayNamesShort: ["Dim.", "Lun.", "Mar.", "Mer.", "Jeu.", "Ven.", "Sam."], - today: "Aujourd'hui", -}; - -export { Calendar, LocaleConfig }; diff --git a/apps/native/src/components/ui/card.tsx b/apps/native/src/components/ui/card.tsx index a15a8ae..f5a8fc2 100644 --- a/apps/native/src/components/ui/card.tsx +++ b/apps/native/src/components/ui/card.tsx @@ -1,126 +1,67 @@ -import { useColorScheme } from "nativewind"; -import React from "react"; -import type { ViewStyle } from "react-native"; -import { Text, View, StyleSheet } from "react-native"; +import * as React from 'react'; +import { Text, View } from 'react-native'; +import { TextClassContext } from '~/components/ui/text'; +import { TextRef, ViewRef } from '~/components/primitives/types'; +import { cn } from '~/lib/utils'; -import { cn } from "~/lib/utils"; +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +); +Card.displayName = 'Card'; -const Card = React.forwardRef< - React.ElementRef, - Omit, "style"> & { - style?: ViewStyle; - } ->(({ className, style: styleProp, ...props }, ref) => { - const { colorScheme } = useColorScheme(); - const [style, setStyle] = React.useState(styleProp ?? {}); +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +); +CardHeader.displayName = 'CardHeader'; - React.useEffect(() => { - setStyle( - StyleSheet.flatten([ - colorScheme === "dark" ? styles.shadowDark : styles.shadowLight, - styleProp, - ]), - ); - }, [styleProp, colorScheme]); +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +); +CardTitle.displayName = 'CardTitle'; - return ( - - ); -}); +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +); +CardDescription.displayName = 'CardDescription'; -Card.displayName = "Card"; +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) => ( + + + + ) +); +CardContent.displayName = 'CardContent'; -const CardHeader = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -CardHeader.displayName = "CardHeader"; +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ) +); +CardFooter.displayName = 'CardFooter'; -const CardTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -CardTitle.displayName = "CardTitle"; - -const CardDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -CardDescription.displayName = "CardDescription"; - -const CardContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -CardContent.displayName = "CardContent"; - -const CardFooter = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -CardFooter.displayName = "CardFooter"; - -export { - Card, - CardHeader, - CardFooter, - CardTitle, - CardDescription, - CardContent, -}; - -const styles = StyleSheet.create({ - shadowLight: { - shadowColor: "#000000", - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.05, - shadowRadius: 8, - elevation: 2, - }, - shadowDark: { - shadowColor: "#FFFFFF", - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.03, - shadowRadius: 8, - elevation: 1, - }, -}); +export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }; diff --git a/apps/native/src/components/ui/checkbox.tsx b/apps/native/src/components/ui/checkbox.tsx index 5138a58..f43608d 100644 --- a/apps/native/src/components/ui/checkbox.tsx +++ b/apps/native/src/components/ui/checkbox.tsx @@ -1,51 +1,34 @@ -import React from "react"; -import { Check } from "lucide-react-native"; +import { Check } from '~/components/Icons'; +import * as React from 'react'; +import * as CheckboxPrimitive from '~/components/primitives/checkbox'; -import { cn } from "~/lib/utils"; -import { Pressable, View } from "react-native"; -import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; - -interface CheckboxProps { - value: boolean; - onChange: (checked: boolean) => void; - iconClass?: string; - iconSize?: number; -} - -const AnimatedCheck = Animated.createAnimatedComponent(Check); +import { Platform } from 'react-native'; +import { cn } from '~/lib/utils'; const Checkbox = React.forwardRef< - React.ElementRef, - Omit, "onPress"> & - CheckboxProps ->(({ className, value, onChange, iconClass, iconSize = 16, ...props }, ref) => { - return ( - { - onChange(!value); - }} - {...props} - > - - {value && ( - - )} - - ); + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ); }); - -Checkbox.displayName = "Checkbox"; +Checkbox.displayName = CheckboxPrimitive.Root.displayName; export { Checkbox }; diff --git a/apps/native/src/components/ui/collapsible.tsx b/apps/native/src/components/ui/collapsible.tsx index f853071..b75f3a4 100644 --- a/apps/native/src/components/ui/collapsible.tsx +++ b/apps/native/src/components/ui/collapsible.tsx @@ -1,149 +1,9 @@ -import { VariantProps } from "class-variance-authority"; -import React from "react"; -import { GestureResponderEvent, Pressable, View } from "react-native"; -import Animated, { FadeInDown, FadeOutUp } from "react-native-reanimated"; -import { buttonVariants } from "~/components/ui/button"; -import * as Slot from "~/lib/rn-primitives/slot/slot-native"; -import { cn } from "~/lib/utils"; +import * as CollapsiblePrimitive from '~/components/primitives/collapsible'; -interface CollapsibleProps { - open?: boolean; - setOpen?: React.Dispatch>; - defaultOpen?: boolean; - disabled?: boolean; -} +const Collapsible = CollapsiblePrimitive.Root; -interface CollapsibleContext { - visible: boolean; - setVisible: React.Dispatch>; - nativeID: string; - disabled: boolean; -} +const CollapsibleTrigger = CollapsiblePrimitive.Trigger; -const CollapsibleContext = React.createContext({} as CollapsibleContext); +const CollapsibleContent = CollapsiblePrimitive.Content; -const Collapsible = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & CollapsibleProps ->( - ( - { open, setOpen, defaultOpen, className, disabled = false, ...props }, - ref, - ) => { - const [visible, setVisible] = React.useState(defaultOpen ?? false); - const nativeID = React.useId(); - - return ( - - - - ); - }, -); - -function useCollapsibleContext() { - const context = React.useContext(CollapsibleContext); - if (!context) { - throw new Error( - "Collapsible compound components cannot be rendered outside the Collapsible component", - ); - } - return context; -} - -const CollapsibleHeader = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - return ( - - ); -}); - -const CollapsibleTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & - VariantProps & { - asChild?: boolean; - } ->( - ( - { - className, - onPress, - variant = "outline", - size = "sm", - asChild = false, - ...props - }, - ref, - ) => { - const { nativeID, visible, setVisible, disabled } = useCollapsibleContext(); - - function handleOnPress(event: GestureResponderEvent) { - setVisible((prev) => !prev); - onPress?.(event); - } - - const Trigger = asChild ? Slot.Pressable : Pressable; - return ( - - ); - }, -); - -const CollapsibleContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { nativeID, visible } = useCollapsibleContext(); - - if (!visible) return null; - return ( - - ); -}); - -export { - Collapsible, - CollapsibleContent, - CollapsibleHeader, - CollapsibleTrigger, -}; +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/apps/native/src/components/ui/combobox.tsx b/apps/native/src/components/ui/combobox.tsx deleted file mode 100644 index 61a7e7e..0000000 --- a/apps/native/src/components/ui/combobox.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import { Check, ChevronsUpDown, Search } from "lucide-react-native"; -import React from "react"; -import { ListRenderItemInfo, Text, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { - BottomSheet, - BottomSheetContent, - BottomSheetFlatList, - BottomSheetHeader, - BottomSheetOpenTrigger, - BottomSheetTextInput, - useBottomSheet, -} from "~/components/ui/bottom-sheet"; -import { - Button, - buttonTextVariants, - buttonVariants, -} from "~/components/ui/button"; -import { cn } from "~/lib/utils"; - -// TODO: Fix bottom sheet content UI - Too big/too much space - -const HEADER_HEIGHT = 130; - -interface ComboboxOption { - label: string; - value: string; -} - -const Combobox = React.forwardRef< - React.ElementRef, - Omit, "children"> & { - items: ComboboxOption[]; - placeholder?: string; - inputProps?: React.ComponentPropsWithoutRef; - emptyText?: string; - defaultSelectedItem?: ComboboxOption | null; - selectedItem?: ComboboxOption | null; - onSelectedItemChange?: (option: ComboboxOption | null) => void; - } ->( - ( - { - className, - textClass, - variant = "outline", - size = "sm", - inputProps, - placeholder, - items, - emptyText = "Nothing found...", - defaultSelectedItem = null, - selectedItem: selectedItemProp, - onSelectedItemChange, - ...props - }, - ref, - ) => { - const insets = useSafeAreaInsets(); - const [search, setSearch] = React.useState(""); - const [selectedItem, setSelectedItem] = - React.useState(defaultSelectedItem); - const bottomSheet = useBottomSheet(); - const inputRef = - React.useRef>(null); - - const listItems = React.useMemo(() => { - return search - ? items.filter((item) => { - return item.label - .toLocaleLowerCase() - .includes(search.toLocaleLowerCase()); - }) - : items; - }, [items, search]); - - function onItemChange(listItem: ComboboxOption) { - if (selectedItemProp?.value === listItem.value) { - return null; - } - setSearch(""); - bottomSheet.close(); - return listItem; - } - - const renderItem = React.useCallback( - ({ item }: ListRenderItemInfo) => { - const listItem = item as ComboboxOption; - const isSelected = onSelectedItemChange - ? selectedItemProp?.value === listItem.value - : selectedItem?.value === listItem.value; - return ( - - ); - }, - [selectedItem, selectedItemProp], - ); - - function onSubmitEditing() { - const firstItem = listItems[0]; - if (!firstItem) return; - if (onSelectedItemChange) { - onSelectedItemChange(firstItem); - } else { - setSelectedItem(firstItem); - } - bottomSheet.close(); - } - - function onSearchIconPress() { - if (!inputRef.current) return; - const input = inputRef.current; - if (input && "focus" in input && typeof input.focus === "function") { - input.focus(); - } - } - - const itemSelected = onSelectedItemChange ? selectedItemProp : selectedItem; - - return ( - - - - - {itemSelected ? itemSelected.label : placeholder ?? ""} - - - - - { - setSearch(""); - }} - > - - - {placeholder} - - - - - - - (item as ComboboxOption).value} - className={"px-4"} - keyboardShouldPersistTaps="handled" - ListEmptyComponent={() => { - return ( - - - {emptyText} - - - ); - }} - /> - - - ); - }, -); - -Combobox.displayName = "Combobox"; - -export { Combobox, type ComboboxOption }; diff --git a/apps/native/src/components/ui/command.tsx b/apps/native/src/components/ui/command.tsx deleted file mode 100644 index 3f948b8..0000000 --- a/apps/native/src/components/ui/command.tsx +++ /dev/null @@ -1,446 +0,0 @@ -import { type ListRenderItemInfo } from "@shopify/flash-list"; -import { Search, X } from "lucide-react-native"; -import React, { useImperativeHandle } from "react"; -import { - GestureResponderEvent, - Modal, - Pressable, - Text, - View, -} from "react-native"; -import Animated, { FadeInUp, SlideInUp } from "react-native-reanimated"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useKeyboard } from "~/lib/keyboard"; -import * as Slot from "~/lib/rn-primitives/slot/slot-native"; -import { cn, isTextChildren } from "~/lib/utils"; -import { Button } from "./button"; -import { Input } from "./input"; -import { SectionList } from "./section-list"; - -type Data = Record | string; - -interface CommandProps { - data: T[]; - onItemSelected: (item: Exclude) => void; - filterFn: (search: string, item: Exclude) => boolean; - defaultOpen?: boolean; - onOpenChange?: (isOpen: boolean) => void; - onSearch?: (search: string) => void; -} - -interface CommandContext { - data: T[]; - onItemSelected: (item: Exclude) => void; - isOpen: boolean; - toggleIsOpen: () => void; - search: string; - handleOnSearch: (search: string) => void; -} - -const CommandContext = React.createContext>( - {} as CommandContext, -); - -type CommandWrapperProps = React.ComponentPropsWithoutRef< - typeof View -> & - CommandProps; - -function CommandWrapper( - { - data: dataFromProps, - defaultOpen = false, - onOpenChange, - onSearch, - onItemSelected, - filterFn, - ...props - }: CommandWrapperProps, - ref?: React.ForwardedRef, -) { - const [isOpen, setIsOpen] = React.useState(defaultOpen); - const [search, setSearch] = React.useState(""); - - const data = React.useMemo(() => { - const items = dataFromProps.filter((item) => { - if (typeof item === "string") return true; - return filterFn(search, item as Exclude); - }); - return items.filter((item, index) => { - if (typeof item === "string") { - const nextItem = items[index + 1]; - return nextItem && typeof nextItem !== "string"; - } - return true; - }); - }, [search, dataFromProps, filterFn]); - - function toggleIsOpen() { - setIsOpen((prev) => { - const newVal = !prev; - onOpenChange?.(newVal); - return newVal; - }); - if (search) { - setSearch(""); - } - } - - function handleOnSearch(search: string) { - setSearch(search); - onSearch?.(search); - } - - return ( - - - - ); -} - -interface WithForwardRefCommand extends React.FC> { - ( - props: CommandWrapperProps, - ): ReturnType>>; -} - -const Command: WithForwardRefCommand = React.forwardRef(CommandWrapper); - -Command.displayName = "Command"; - -function useCommandContext() { - const context = React.useContext>(CommandContext); - if (!context) { - throw new Error( - "Command compound components cannot be rendered outside the Command component", - ); - } - return context; -} - -function CommandPressable( - { - onPress, - asChild = false, - ...props - }: React.ComponentPropsWithoutRef & { - asChild?: boolean; - }, - ref: React.ForwardedRef>, -) { - const { toggleIsOpen } = useCommandContext(); - - function handleOnPress(event: GestureResponderEvent) { - toggleIsOpen(); - onPress?.(event); - } - - const Trigger = asChild ? Slot.Pressable : Pressable; - return ; -} - -const CommandTrigger = React.forwardRef(CommandPressable); - -CommandTrigger.displayName = "CommandTrigger"; - -function CommandModal( - { - className, - children, - animationType = "fade", - overlayClass, - style, - ...props - }: React.ComponentPropsWithoutRef & { - overlayClass?: string; - }, - ref: React.ForwardedRef>, -) { - const insets = useSafeAreaInsets(); - const { keyboardHeight } = useKeyboard(); - const { toggleIsOpen, isOpen } = useCommandContext(); - - return ( - - - - - {children} - - - - - - ); -} - -const CommandContent = React.forwardRef(CommandModal); -CommandContent.displayName = "CommandContent"; - -function CommandTextInput( - { - className, - placeholderClassName, - ...props - }: React.ComponentPropsWithoutRef, - ref: React.ForwardedRef>, -) { - const inputRef = React.useRef>(null); - const { search, handleOnSearch, data, onItemSelected, toggleIsOpen } = - useCommandContext(); - - useImperativeHandle( - ref, - () => { - if (!inputRef.current) { - return {} as React.ComponentRef; - } - return inputRef.current; - }, - [inputRef.current], - ); - - function onSubmitEditing() { - const firstItem = data.find((item) => typeof item !== "string"); - if (firstItem && typeof firstItem !== "string") { - onItemSelected(firstItem as Exclude); - } - toggleIsOpen(); - } - - function onSearchIconPress() { - inputRef.current?.focus(); - } - - return ( - - - - - - ); -} - -const CommandInput = React.forwardRef(CommandTextInput); -CommandInput.displayName = "CommandInput"; - -type CommandSectionListProps = Omit< - React.ComponentPropsWithoutRef>, - "data" -> & { - headerHeight?: number; - itemHeight?: number; -}; - -function CommandSectionList( - { - headerHeight = 43, - itemHeight = 57, - className, - extraData, - ...props - }: CommandSectionListProps, - ref: React.ForwardedRef>>, -) { - const { data, search } = useCommandContext(); - - function overrideItemLayout(layout: any, item: any) { - if (typeof item === "string") { - layout.size = headerHeight; - } - layout.size = itemHeight; - } - - return ( - - - - ref={ref} - data={data} - extraData={[search, extraData]} - estimatedItemSize={itemHeight} - overrideItemLayout={overrideItemLayout} - keyboardShouldPersistTaps="handled" - role="menu" - {...props} - /> - - ); -} -interface WithForwardRefCommandList - extends React.FC> { - ( - props: CommandSectionListProps, - ): ReturnType< - React.FC< - Omit>, "data"> & { - headerHeight?: number; - itemHeight?: number; - } - > - >; -} - -const CommandList: WithForwardRefCommandList = - React.forwardRef(CommandSectionList); -CommandList.displayName = "CommandList"; - -const CommandListHeader = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { textClass?: string } ->(({ className, textClass, children, ...props }, ref) => { - return ( - - {isTextChildren(children) ? ( - - {children} - - ) : ( - children - )} - - ); -}); - -type CommandListHeaderProps = ListRenderItemInfo; - -CommandListHeader.displayName = "CommandListHeader"; - -function CommandSectionListItem( - { - className, - index, - containnerClass, - onPress, - children, - ...props - }: React.ComponentPropsWithoutRef & { - index: number; - containnerClass?: string; - }, - ref: React.ForwardedRef>, -) { - const { data, onItemSelected, toggleIsOpen } = useCommandContext(); - - function handleOnPress(event: GestureResponderEvent) { - const item = data[index]; - if (typeof item === "string" || !item) return; - onItemSelected(item as Exclude); - onPress?.(event); - toggleIsOpen(); - } - - return ( - - - - ); -} - -type CommandListItemProps = ListRenderItemInfo; - -const CommandListItem = React.forwardRef(CommandSectionListItem); - -CommandListItem.displayName = "CommandListItem"; - -export { - Command, - CommandContent, - CommandInput, - CommandList, - CommandListHeader, - CommandListItem, - CommandTrigger, - type CommandListHeaderProps, - type CommandListItemProps, -}; diff --git a/apps/native/src/components/ui/context-menu.tsx b/apps/native/src/components/ui/context-menu.tsx index c57b434..8633164 100644 --- a/apps/native/src/components/ui/context-menu.tsx +++ b/apps/native/src/components/ui/context-menu.tsx @@ -1,5 +1,258 @@ -import { Text } from "react-native"; +import { + Check, + ChevronDown, + ChevronRight, + ChevronUp, +} from "~/components/Icons"; +import * as React from "react"; +import { + Platform, + type StyleProp, + StyleSheet, + Text, + View, + type ViewStyle, +} from "react-native"; +import * as ContextMenuPrimitive from "~/components/primitives/context-menu"; +import { TextClassContext } from "~/components/ui/text"; +import { cn } from "~/lib/utils"; -export function ContextMenu() { - return Context Menu (soon); -} +const ContextMenu = ContextMenuPrimitive.Root; + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger; + +const ContextMenuGroup = ContextMenuPrimitive.Group; + +const ContextMenuPortal = ContextMenuPrimitive.Portal; + +const ContextMenuSub = ContextMenuPrimitive.Sub; + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => { + const { open } = ContextMenuPrimitive.useSubContext(); + const Icon = + Platform.OS === "web" ? ChevronRight : open ? ChevronUp : ChevronDown; + return ( + + + <>{children} + + + + ); +}); +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { open } = ContextMenuPrimitive.useSubContext(); + return ( + + ); +}); +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + overlayStyle?: StyleProp; + overlayClassName?: string; + } +>(({ className, overlayClassName, overlayStyle, ...props }, ref) => { + const { open } = ContextMenuPrimitive.useRootContext(); + return ( + + + + + + ); +}); +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + + + +)); +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + <>{children} + +)); +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName; + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + <>{children} + +)); +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; + +const ContextMenuShortcut = ({ + className, + ...props +}: React.ComponentPropsWithoutRef) => { + return ( + + ); +}; +ContextMenuShortcut.displayName = "ContextMenuShortcut"; + +export { + ContextMenu, + ContextMenuCheckboxItem, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuLabel, + ContextMenuPortal, + ContextMenuRadioGroup, + ContextMenuRadioItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +}; diff --git a/apps/native/src/components/ui/data-table.tsx b/apps/native/src/components/ui/data-table.tsx deleted file mode 100644 index ebe9f2f..0000000 --- a/apps/native/src/components/ui/data-table.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { - ColumnDef, - Row, - SortingState, - flexRender, - getCoreRowModel, - getSortedRowModel, - useReactTable, -} from "@tanstack/react-table"; -import React from "react"; -import { ActivityIndicator, Dimensions, RefreshControl } from "react-native"; -import Animated, { FadeInUp, FadeOutUp } from "react-native-reanimated"; -import { cn } from "~/lib/utils"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - TableRowsFlashListProps, - TableRowsList, -} from "./table"; - -interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; - onRowPress?: (row: Row) => void; - estimatedItemSize?: number; - ListEmptyComponent?: TableRowsFlashListProps["ListEmptyComponent"]; - ListFooterComponent?: TableRowsFlashListProps["ListFooterComponent"]; - isRefreshing?: boolean; - onRefresh?: () => void; -} - -/** - * @docs https://tanstack.com/table - */ - -export function DataTable({ - columns, - data, - onRowPress, - estimatedItemSize = 45, - ListEmptyComponent, - ListFooterComponent, - isRefreshing = false, - onRefresh, -}: DataTableProps) { - const [sorting, setSorting] = React.useState([]); - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - onSortingChange: setSorting, - getSortedRowModel: getSortedRowModel(), - state: { - sorting, - }, - }); - - return ( - <> - {isRefreshing && ( - - - - )} - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} - - ); - })} - - ))} - - - - } - renderItem={({ item: row, index }) => { - return ( - { - onRowPress(row); - } - : undefined - } - > - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} - - ); - }} - /> - -
- - ); -} - -const { width } = Dimensions.get("window"); - -function getColumnWidth(size: number, length: number) { - const evenWidth = width / length; - return evenWidth > size ? evenWidth : size; -} diff --git a/apps/native/src/components/ui/dialog.tsx b/apps/native/src/components/ui/dialog.tsx index 679c24c..25df5c9 100644 --- a/apps/native/src/components/ui/dialog.tsx +++ b/apps/native/src/components/ui/dialog.tsx @@ -1,276 +1,150 @@ -import { useColorScheme } from "nativewind"; -import React from "react"; -import { - GestureResponderEvent, - Modal, - Pressable, - StyleSheet, - Text, - View, - ViewStyle, -} from "react-native"; -import * as Slot from "~/lib/rn-primitives/slot/slot-native"; -import { cn } from "~/lib/utils"; -import { Button } from "./button"; +import { X } from '~/components/Icons'; +import * as React from 'react'; +import { Platform, StyleSheet, View } from 'react-native'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import * as DialogPrimitive from '~/components/primitives/dialog'; +import { cn } from '~/lib/utils'; -interface DialogProps { - children: React.ReactNode; - closeOnOverlayPress?: boolean; - defaultOpen?: boolean; - open?: boolean; - setOpen?: React.Dispatch>; -} -interface DialogContext { - visible: boolean; - setVisible: React.Dispatch>; - closeOnOverlayPress: boolean; -} +const Dialog = DialogPrimitive.Root; -const DialogContext = React.createContext({} as DialogContext); +const DialogTrigger = DialogPrimitive.Trigger; -const Dialog = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & DialogProps ->( - ( - { - open, - setOpen, - closeOnOverlayPress = true, - defaultOpen = false, - ...props - }, - ref, - ) => { - const [visible, setVisible] = React.useState(defaultOpen ?? false); - return ( - - - - ); - }, -); - -Dialog.displayName = "Dialog"; - -function useDialogContext() { - const context = React.useContext(DialogContext); - if (!context) { - throw new Error( - "Dialog compound components cannot be rendered outside the Dialog component", - ); - } - return context; -} - -const DialogTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - asChild?: boolean; - } ->(({ onPress, asChild = false, ...props }, ref) => { - const { setVisible } = useDialogContext(); - function handleOnPress(event: GestureResponderEvent) { - setVisible(true); - onPress?.(event); - } - - const Trigger = asChild ? Slot.Pressable : Button; - return ; -}); - -DialogTrigger.displayName = "DialogTrigger"; +const DialogPortal = DialogPrimitive.Portal; -const DialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { overlayClass?: string } ->( - ( - { - className, - children, - animationType = "fade", - style: styleProp, - overlayClass, - ...props - }, - ref, - ) => { - const { colorScheme } = useColorScheme(); - const { visible, setVisible, closeOnOverlayPress } = useDialogContext(); - const [style, setStyle] = React.useState( - StyleSheet.flatten(styleProp), - ); - - React.useEffect(() => { - setStyle( - StyleSheet.flatten([ - colorScheme === "dark" ? styles.shadowDark : styles.shadowLight, - styleProp, - ]), - ); - }, [styleProp, colorScheme]); - - return ( - { - setVisible((prev) => !prev); - }} - statusBarTranslucent - {...props} - > - { - setVisible(false); - } - : undefined - } - className={cn( - "flex-1 justify-center items-center p-2", - animationType !== "slide" && "bg-zinc-50/80 dark:bg-zinc-900/80", - overlayClass, - )} - > - - {children} - - - - ); - }, -); +const DialogClose = DialogPrimitive.Close; -DialogContent.displayName = "DialogContent"; - -const DialogHeader = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef +const DialogOverlayWeb = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => { - return ; + const { open } = DialogPrimitive.useRootContext(); + return ( + + ); }); -DialogHeader.displayName = "DialogHeader"; - -const DialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - return ( - - ); +DialogOverlayWeb.displayName = 'DialogOverlayWeb'; + +const DialogOverlayNative = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => { + return ( + + + <>{children} + + + ); }); -DialogTitle.displayName = "DialogTitle"; +DialogOverlayNative.displayName = 'DialogOverlayNative'; -const DialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - return ( - - ); +const DialogOverlay = Platform.select({ + web: DialogOverlayWeb, + default: DialogOverlayNative, }); -DialogDescription.displayName = "DialogDescription"; - -const DialogFooter = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - return ( - - ); +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => { + const { open } = DialogPrimitive.useRootContext(); + return ( + + + + {children} + + + + + + + ); }); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }: React.ComponentPropsWithoutRef) => ( + +); +DialogHeader.displayName = 'DialogHeader'; -DialogFooter.displayName = "DialogFooter"; +const DialogFooter = ({ className, ...props }: React.ComponentPropsWithoutRef) => ( + +); +DialogFooter.displayName = 'DialogFooter'; -const DialogClose = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - asChild?: boolean; - } ->(({ variant = "outline", asChild, ...props }, ref) => { - const { setVisible } = useDialogContext(); - const Trigger = asChild ? Slot.Pressable : Button; - return ( - { - setVisible(false); - }} - ref={ref} - {...props} - /> - ); -}); +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; -DialogClose.displayName = "DialogClose"; +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; export { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, }; - -const styles = StyleSheet.create({ - shadowLight: { - shadowColor: "#000000", - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.1, - shadowRadius: 8, - elevation: 5, - }, - shadowDark: { - shadowColor: "#000000", - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.25, - shadowRadius: 8, - elevation: 5, - }, -}); diff --git a/apps/native/src/components/ui/dropdown-menu.tsx b/apps/native/src/components/ui/dropdown-menu.tsx index 1316008..fbbbe75 100644 --- a/apps/native/src/components/ui/dropdown-menu.tsx +++ b/apps/native/src/components/ui/dropdown-menu.tsx @@ -1,5 +1,260 @@ -import { Text } from "react-native"; +import { + Check, + ChevronDown, + ChevronRight, + ChevronUp, +} from "~/components/Icons"; +import * as React from "react"; +import { + Platform, + type StyleProp, + StyleSheet, + Text, + View, + type ViewStyle, +} from "react-native"; +import * as DropdownMenuPrimitive from "~/components/primitives/dropdown-menu"; +import { TextClassContext } from "~/components/ui/text"; +import { cn } from "~/lib/utils"; -export function DropdownMenu() { - return Dropdown Menu (soon); -} +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => { + const { open } = DropdownMenuPrimitive.useSubContext(); + const Icon = + Platform.OS === "web" ? ChevronRight : open ? ChevronUp : ChevronDown; + return ( + + + <>{children} + + + + ); +}); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { open } = DropdownMenuPrimitive.useSubContext(); + return ( + + ); +}); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + overlayStyle?: StyleProp; + overlayClassName?: string; + } +>(({ className, overlayClassName, overlayStyle, ...props }, ref) => { + const { open } = DropdownMenuPrimitive.useRootContext(); + return ( + + + + + + ); +}); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + + + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + <>{children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + <>{children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.ComponentPropsWithoutRef) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +}; diff --git a/apps/native/src/components/ui/form.tsx b/apps/native/src/components/ui/form.tsx deleted file mode 100644 index c82c86a..0000000 --- a/apps/native/src/components/ui/form.tsx +++ /dev/null @@ -1,684 +0,0 @@ -// This project uses code from shadcn/ui. -// The code is licensed under the MIT License. -// https://github.com/shadcn-ui/ui - -import { CalendarIcon, X } from "lucide-react-native"; -import React from "react"; -import { - Controller, - ControllerProps, - FieldPath, - FieldValues, - FormProvider, - Noop, - useFormContext, -} from "react-hook-form"; -import { Text, View } from "react-native"; -import Animated, { FadeInDown, FadeOut } from "react-native-reanimated"; -import { - BottomSheet, - BottomSheetCloseTrigger, - BottomSheetContent, - BottomSheetOpenTrigger, - BottomSheetView, -} from "~/components/ui/bottom-sheet"; -import { Button, buttonTextVariants } from "~/components/ui/button"; -import { Calendar } from "~/components/ui/calendar"; -import { Combobox, ComboboxOption } from "~/components/ui/combobox"; -import { Input } from "~/components/ui/input"; -import { Label } from "~/components/ui/label"; -import { RadioGroup } from "~/components/ui/radio-group"; -import { - RenderSelectItem, - Select, - SelectItem, - SelectList, - SelectOption, - SelectTrigger, -} from "~/components/ui/select"; -import { Switch } from "~/components/ui/switch"; -import { Textarea } from "~/components/ui/textarea"; -import { cn } from "~/lib/utils"; -import { Checkbox } from "./checkbox"; - -const Form = FormProvider; - -type FormFieldContextValue< - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, -> = { - name: TName; -}; - -const FormFieldContext = React.createContext( - {} as FormFieldContextValue, -); - -const FormField = < - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, ->({ - ...props -}: ControllerProps) => { - return ( - - - - ); -}; - -const useFormField = () => { - const fieldContext = React.useContext(FormFieldContext); - const itemContext = React.useContext(FormItemContext); - const { getFieldState, formState, handleSubmit } = useFormContext(); - - const fieldState = getFieldState(fieldContext.name, formState); - - if (!fieldContext) { - throw new Error("useFormField should be used within "); - } - - const { nativeID } = itemContext; - - return { - nativeID, - name: fieldContext.name, - formItemNativeID: `${nativeID}-form-item`, - formDescriptionNativeID: `${nativeID}-form-item-description`, - formMessageNativeID: `${nativeID}-form-item-message`, - handleSubmit, - ...fieldState, - }; -}; - -type FormItemContextValue = { - nativeID: string; -}; - -const FormItemContext = React.createContext( - {} as FormItemContextValue, -); - -const FormItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const nativeID = React.useId(); - - return ( - - - - ); -}); -FormItem.displayName = "FormItem"; - -const FormLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { error, formItemNativeID } = useFormField(); - - return ( -