diff options
author | Eric Bailey <git@esb.lol> | 2024-01-18 20:28:04 -0600 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-01-18 20:28:04 -0600 |
commit | 66b8774ecb9c5d465987909577ddad3dd4a3ab8e (patch) | |
tree | b1874c6cedd0111eca41db237e606f8e50739d55 | |
parent | 9cbd3c0937d22e8dccbd9c086d3a3a24dbd27b3a (diff) | |
download | voidsky-66b8774ecb9c5d465987909577ddad3dd4a3ab8e.tar.zst |
New component library based on ALF (#2459)
* Install on native as well * Add button and link components * Comments * Use new prop * Add some form elements * Add labels to input * Fix line height, add suffix * Date inputs * Autofill styles * Clean up InputDate types * Improve types for InputText, value handling * Enforce a11y props on buttons * Add Dialog, Portal * Dialog contents * Native dialog * Clean up * Fix animations * Improvements to web modal, exiting still broken * Clean up dialog types * Add Prompt, Dialog refinement, mobile refinement * Integrate new design tokens, reorg storybook * Button colors * Dim mode * Reorg * Some styles * Toggles * Improve a11y * Autosize dialog, handle max height, Dialog.ScrolLView not working * Try to use BottomSheet's own APIs * Scrollable dialogs * Add web shadow * Handle overscroll * Styles * Dialog text input * Shadows * Button focus states * Button pressed states * Gradient poc * Gradient colors and hovers * Add hrefAttrs to Link * Some more a11y * Toggle invalid states * Update dialog descriptions for demo * Icons * WIP Toggle cleanup * Refactor toggle to not rely on immediate children * Make Toggle controlled * Clean up Toggles storybook * ToggleButton styles * Improve a11y labels * ToggleButton hover darkmode * Some i18n * Refactor input * Allow extension of input * Remove old input * Improve icons, add CalendarDays * Refactor DateField, web done * Add label example * Clean up old InputDate, DateField android, text area example * Consistent imports * Button context, icons * Add todo * Add closeAllDialogs control * Alignment * Expand color palette * Hitslops, add shortcut to Storybook in dev * Fix multiline on ios * Mark dialog close button as unused
60 files changed, 4680 insertions, 965 deletions
diff --git a/assets/icons/arrowTopRight_stoke2_corner0_rounded.svg b/assets/icons/arrowTopRight_stoke2_corner0_rounded.svg new file mode 100644 index 000000000..554a7374e --- /dev/null +++ b/assets/icons/arrowTopRight_stoke2_corner0_rounded.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8 6a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v9a1 1 0 1 1-2 0V8.414l-9.793 9.793a1 1 0 0 1-1.414-1.414L15.586 7H9a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg> diff --git a/assets/icons/calendarDays_stroke2_corner0_rounded.svg b/assets/icons/calendarDays_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..09d9c0f49 --- /dev/null +++ b/assets/icons/calendarDays_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H4Zm1 16V9h14v10H5ZM5 7h14V5H5v2Zm3 10.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM17.25 12a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0ZM12 13.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM9.25 12a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0ZM12 17.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z" clip-rule="evenodd"/></svg> diff --git a/assets/icons/colorPalette_stroke2_corner0_rounded.svg b/assets/icons/colorPalette_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..b1056e1a9 --- /dev/null +++ b/assets/icons/colorPalette_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 12c0-4.09 3.527-7.5 8-7.5s8 3.41 8 7.5c0 1.579-.419 2.056-.708 2.236-.388.241-1.031.286-2.058.153-.33-.043-.652-.096-.991-.152a65.905 65.905 0 0 0-.531-.087c-.52-.081-1.077-.156-1.61-.164-1.065-.016-2.336.245-2.996 1.567-.418.834-.295 1.67-.078 2.314.18.534.47 1.055.683 1.437v.001l.097.175.01.018C7.432 19.407 4 16.033 4 12Zm8-9.5C6.532 2.5 2 6.7 2 12s4.532 9.5 10 9.5c.401 0 .812-.04 1.166-.193.41-.176.761-.517.866-1.028.085-.416-.03-.796-.118-1.029a5.981 5.981 0 0 0-.351-.73l-.12-.215c-.215-.392-.403-.73-.52-1.078-.13-.387-.111-.614-.029-.78.146-.291.404-.473 1.178-.461.385.005.825.06 1.329.14.15.023.308.05.47.077.36.059.742.122 1.105.17 1.021.132 2.325.213 3.373-.439C21.496 15.22 22 13.874 22 12c0-5.3-4.532-9.5-10-9.5Zm3.5 8.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM9 12.25a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm1.5-2.75a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" clip-rule="evenodd"/></svg> diff --git a/assets/icons/globe_stroke2_corner0_rounded.svg b/assets/icons/globe_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..83cb88d13 --- /dev/null +++ b/assets/icons/globe_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4.062 11h2.961c.103-2.204.545-4.218 1.235-5.77.06-.136.123-.269.188-.399A8.007 8.007 0 0 0 4.062 11ZM12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 2c-.227 0-.518.1-.868.432-.354.337-.719.872-1.047 1.61-.561 1.263-.958 2.991-1.06 4.958h5.95c-.102-1.967-.499-3.695-1.06-4.958-.328-.738-.693-1.273-1.047-1.61C12.518 4.099 12.227 4 12 4Zm4.977 7c-.103-2.204-.545-4.218-1.235-5.77a9.78 9.78 0 0 0-.188-.399A8.006 8.006 0 0 1 19.938 11h-2.961Zm-2.003 2H9.026c.101 1.966.498 3.695 1.06 4.958.327.738.692 1.273 1.046 1.61.35.333.641.432.868.432.227 0 .518-.1.868-.432.354-.337.719-.872 1.047-1.61.561-1.263.958-2.991 1.06-4.958Zm.58 6.169c.065-.13.128-.263.188-.399.69-1.552 1.132-3.566 1.235-5.77h2.961a8.006 8.006 0 0 1-4.384 6.169Zm-7.108 0a9.877 9.877 0 0 1-.188-.399c-.69-1.552-1.132-3.566-1.235-5.77H4.062a8.006 8.006 0 0 0 4.384 6.169Z" clip-rule="evenodd"/></svg> diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html index 57ad064f8..942e18fcc 100644 --- a/bskyweb/templates/base.html +++ b/bskyweb/templates/base.html @@ -39,6 +39,25 @@ height: calc(100% + env(safe-area-inset-top)); } + /* Remove autofill styles on Webkit */ + input:-webkit-autofill, + input:-webkit-autofill:hover, + input:-webkit-autofill:focus, + textarea:-webkit-autofill, + textarea:-webkit-autofill:hover, + textarea:-webkit-autofill:focus, + select:-webkit-autofill, + select:-webkit-autofill:hover, + select:-webkit-autofill:focus { + border: 0; + -webkit-text-fill-color: transparent; + -webkit-box-shadow: none; + } + /* Force left-align date/time inputs on iOS mobile */ + input::-webkit-date-and-time-value { + text-align: left; + } + /* Color theming */ :root { --text: black; diff --git a/package.json b/package.json index 517c328c1..ff0ddd3e9 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@segment/analytics-react-native": "^2.10.1", "@segment/sovran-react-native": "^0.4.5", "@sentry/react-native": "5.5.0", + "@tamagui/focus-scope": "^1.84.1", "@tanstack/react-query": "^5.8.1", "@tiptap/core": "^2.0.0-beta.220", "@tiptap/extension-document": "^2.0.0-beta.220", diff --git a/src/App.native.tsx b/src/App.native.tsx index 9de901767..41b78fc98 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -13,6 +13,8 @@ import { import 'view/icons' +import {ThemeProvider as Alf} from '#/alf' +import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {init as initPersistedState} from '#/state/persisted' import {listenSessionDropped} from './state/events' import {useColorMode} from 'state/shell' @@ -25,6 +27,7 @@ import {queryClient} from 'lib/react-query' import {TestCtrls} from 'view/com/testing/TestCtrls' import {Provider as ShellStateProvider} from 'state/shell' import {Provider as ModalStateProvider} from 'state/modals' +import {Provider as DialogStateProvider} from 'state/dialogs' import {Provider as LightboxStateProvider} from 'state/lightbox' import {Provider as MutedThreadsProvider} from 'state/muted-threads' import {Provider as InvitesStateProvider} from 'state/invites' @@ -39,6 +42,7 @@ import { import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' import * as persisted from '#/state/persisted' import {Splash} from '#/Splash' +import {Provider as PortalProvider} from '#/components/Portal' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -48,6 +52,7 @@ function InnerApp() { const colorMode = useColorMode() const {isInitialLoad, currentAccount} = useSession() const {resumeSession} = useSessionApi() + const theme = useColorModeTheme(colorMode) const {_} = useLingui() // init @@ -63,25 +68,27 @@ function InnerApp() { return ( <SafeAreaProvider initialMetrics={initialWindowMetrics}> - <Splash isReady={!isInitialLoad}> - <React.Fragment - // Resets the entire tree below when it changes: - key={currentAccount?.did}> - <LoggedOutViewProvider> - <UnreadNotifsProvider> - <ThemeProvider theme={colorMode}> - {/* All components should be within this provider */} - <RootSiblingParent> - <GestureHandlerRootView style={s.h100pct}> - <TestCtrls /> - <Shell /> - </GestureHandlerRootView> - </RootSiblingParent> - </ThemeProvider> - </UnreadNotifsProvider> - </LoggedOutViewProvider> - </React.Fragment> - </Splash> + <Alf theme={theme}> + <Splash isReady={!isInitialLoad}> + <React.Fragment + // Resets the entire tree below when it changes: + key={currentAccount?.did}> + <LoggedOutViewProvider> + <UnreadNotifsProvider> + <ThemeProvider theme={colorMode}> + {/* All components should be within this provider */} + <RootSiblingParent> + <GestureHandlerRootView style={s.h100pct}> + <TestCtrls /> + <Shell /> + </GestureHandlerRootView> + </RootSiblingParent> + </ThemeProvider> + </UnreadNotifsProvider> + </LoggedOutViewProvider> + </React.Fragment> + </Splash> + </Alf> </SafeAreaProvider> ) } @@ -109,11 +116,15 @@ function App() { <MutedThreadsProvider> <InvitesStateProvider> <ModalStateProvider> - <LightboxStateProvider> - <I18nProvider> - <InnerApp /> - </I18nProvider> - </LightboxStateProvider> + <DialogStateProvider> + <LightboxStateProvider> + <I18nProvider> + <PortalProvider> + <InnerApp /> + </PortalProvider> + </I18nProvider> + </LightboxStateProvider> + </DialogStateProvider> </ModalStateProvider> </InvitesStateProvider> </MutedThreadsProvider> diff --git a/src/App.web.tsx b/src/App.web.tsx index 1bdb3c208..1efa0567c 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -8,6 +8,7 @@ import {RootSiblingParent} from 'react-native-root-siblings' import 'view/icons' import {ThemeProvider as Alf} from '#/alf' +import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {init as initPersistedState} from '#/state/persisted' import {useColorMode} from 'state/shell' import {Shell} from 'view/shell/index' @@ -16,6 +17,7 @@ import {ThemeProvider} from 'lib/ThemeContext' import {queryClient} from 'lib/react-query' import {Provider as ShellStateProvider} from 'state/shell' import {Provider as ModalStateProvider} from 'state/modals' +import {Provider as DialogStateProvider} from 'state/dialogs' import {Provider as LightboxStateProvider} from 'state/lightbox' import {Provider as MutedThreadsProvider} from 'state/muted-threads' import {Provider as InvitesStateProvider} from 'state/invites' @@ -29,7 +31,7 @@ import { } from 'state/session' import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' import * as persisted from '#/state/persisted' -import {useColorModeTheme} from '#/alf/util/useColorModeTheme' +import {Provider as PortalProvider} from '#/components/Portal' function InnerApp() { const {isInitialLoad, currentAccount} = useSession() @@ -92,11 +94,15 @@ function App() { <MutedThreadsProvider> <InvitesStateProvider> <ModalStateProvider> - <LightboxStateProvider> - <I18nProvider> - <InnerApp /> - </I18nProvider> - </LightboxStateProvider> + <DialogStateProvider> + <LightboxStateProvider> + <I18nProvider> + <PortalProvider> + <InnerApp /> + </PortalProvider> + </I18nProvider> + </LightboxStateProvider> + </DialogStateProvider> </ModalStateProvider> </InvitesStateProvider> </MutedThreadsProvider> diff --git a/src/Navigation.tsx b/src/Navigation.tsx index c68cb0580..ea0cd384e 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -61,7 +61,7 @@ import {ProfileListScreen} from './view/screens/ProfileList' import {PostThreadScreen} from './view/screens/PostThread' import {PostLikedByScreen} from './view/screens/PostLikedBy' import {PostRepostedByScreen} from './view/screens/PostRepostedBy' -import {DebugScreen} from './view/screens/DebugNew' +import {Storybook} from './view/screens/Storybook' import {LogScreen} from './view/screens/Log' import {SupportScreen} from './view/screens/Support' import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy' @@ -200,8 +200,8 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { /> <Stack.Screen name="Debug" - getComponent={() => DebugScreen} - options={{title: title(msg`Debug`), requireAuth: true}} + getComponent={() => Storybook} + options={{title: title(msg`Storybook`), requireAuth: true}} /> <Stack.Screen name="Log" diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index c142f5f71..203c2f282 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -4,6 +4,9 @@ export const atoms = { /* * Positioning */ + fixed: { + position: 'fixed', + }, absolute: { position: 'absolute', }, @@ -32,6 +35,10 @@ export const atoms = { zIndex: 50, }, + overflow_hidden: { + overflow: 'hidden', + }, + /* * Width */ @@ -45,6 +52,12 @@ export const atoms = { /* * Border radius */ + rounded_2xs: { + borderRadius: tokens.borderRadius._2xs, + }, + rounded_xs: { + borderRadius: tokens.borderRadius.xs, + }, rounded_sm: { borderRadius: tokens.borderRadius.sm, }, @@ -58,8 +71,8 @@ export const atoms = { /* * Flex */ - gap_xxs: { - gap: tokens.space.xxs, + gap_2xs: { + gap: tokens.space._2xs, }, gap_xs: { gap: tokens.space.xs, @@ -76,8 +89,17 @@ export const atoms = { gap_xl: { gap: tokens.space.xl, }, - gap_xxl: { - gap: tokens.space.xxl, + gap_2xl: { + gap: tokens.space._2xl, + }, + gap_3xl: { + gap: tokens.space._3xl, + }, + gap_4xl: { + gap: tokens.space._4xl, + }, + gap_5xl: { + gap: tokens.space._5xl, }, flex: { display: 'flex', @@ -125,9 +147,9 @@ export const atoms = { text_right: { textAlign: 'right', }, - text_xxs: { - fontSize: tokens.fontSize.xxs, - lineHeight: tokens.fontSize.xxs, + text_2xs: { + fontSize: tokens.fontSize._2xs, + lineHeight: tokens.fontSize._2xs, }, text_xs: { fontSize: tokens.fontSize.xs, @@ -149,9 +171,21 @@ export const atoms = { fontSize: tokens.fontSize.xl, lineHeight: tokens.fontSize.xl, }, - text_xxl: { - fontSize: tokens.fontSize.xxl, - lineHeight: tokens.fontSize.xxl, + text_2xl: { + fontSize: tokens.fontSize._2xl, + lineHeight: tokens.fontSize._2xl, + }, + text_3xl: { + fontSize: tokens.fontSize._3xl, + lineHeight: tokens.fontSize._3xl, + }, + text_4xl: { + fontSize: tokens.fontSize._4xl, + lineHeight: tokens.fontSize._4xl, + }, + text_5xl: { + fontSize: tokens.fontSize._5xl, + lineHeight: tokens.fontSize._5xl, }, leading_tight: { lineHeight: 1.25, @@ -162,11 +196,8 @@ export const atoms = { font_normal: { fontWeight: tokens.fontWeight.normal, }, - font_semibold: { - fontWeight: tokens.fontWeight.semibold, - }, font_bold: { - fontWeight: tokens.fontWeight.bold, + fontWeight: tokens.fontWeight.semibold, }, /* @@ -183,10 +214,29 @@ export const atoms = { }, /* + * Shadow + */ + shadow_sm: { + shadowRadius: 8, + shadowOpacity: 0.1, + elevation: 8, + }, + shadow_md: { + shadowRadius: 16, + shadowOpacity: 0.1, + elevation: 16, + }, + shadow_lg: { + shadowRadius: 32, + shadowOpacity: 0.1, + elevation: 24, + }, + + /* * Padding */ - p_xxs: { - padding: tokens.space.xxs, + p_2xs: { + padding: tokens.space._2xs, }, p_xs: { padding: tokens.space.xs, @@ -203,12 +253,21 @@ export const atoms = { p_xl: { padding: tokens.space.xl, }, - p_xxl: { - padding: tokens.space.xxl, + p_2xl: { + padding: tokens.space._2xl, }, - px_xxs: { - paddingLeft: tokens.space.xxs, - paddingRight: tokens.space.xxs, + p_3xl: { + padding: tokens.space._3xl, + }, + p_4xl: { + padding: tokens.space._4xl, + }, + p_5xl: { + padding: tokens.space._5xl, + }, + px_2xs: { + paddingLeft: tokens.space._2xs, + paddingRight: tokens.space._2xs, }, px_xs: { paddingLeft: tokens.space.xs, @@ -230,13 +289,25 @@ export const atoms = { paddingLeft: tokens.space.xl, paddingRight: tokens.space.xl, }, - px_xxl: { - paddingLeft: tokens.space.xxl, - paddingRight: tokens.space.xxl, + px_2xl: { + paddingLeft: tokens.space._2xl, + paddingRight: tokens.space._2xl, + }, + px_3xl: { + paddingLeft: tokens.space._3xl, + paddingRight: tokens.space._3xl, }, - py_xxs: { - paddingTop: tokens.space.xxs, - paddingBottom: tokens.space.xxs, + px_4xl: { + paddingLeft: tokens.space._4xl, + paddingRight: tokens.space._4xl, + }, + px_5xl: { + paddingLeft: tokens.space._5xl, + paddingRight: tokens.space._5xl, + }, + py_2xs: { + paddingTop: tokens.space._2xs, + paddingBottom: tokens.space._2xs, }, py_xs: { paddingTop: tokens.space.xs, @@ -258,12 +329,24 @@ export const atoms = { paddingTop: tokens.space.xl, paddingBottom: tokens.space.xl, }, - py_xxl: { - paddingTop: tokens.space.xxl, - paddingBottom: tokens.space.xxl, + py_2xl: { + paddingTop: tokens.space._2xl, + paddingBottom: tokens.space._2xl, + }, + py_3xl: { + paddingTop: tokens.space._3xl, + paddingBottom: tokens.space._3xl, + }, + py_4xl: { + paddingTop: tokens.space._4xl, + paddingBottom: tokens.space._4xl, }, - pt_xxs: { - paddingTop: tokens.space.xxs, + py_5xl: { + paddingTop: tokens.space._5xl, + paddingBottom: tokens.space._5xl, + }, + pt_2xs: { + paddingTop: tokens.space._2xs, }, pt_xs: { paddingTop: tokens.space.xs, @@ -280,11 +363,20 @@ export const atoms = { pt_xl: { paddingTop: tokens.space.xl, }, - pt_xxl: { - paddingTop: tokens.space.xxl, + pt_2xl: { + paddingTop: tokens.space._2xl, + }, + pt_3xl: { + paddingTop: tokens.space._3xl, + }, + pt_4xl: { + paddingTop: tokens.space._4xl, }, - pb_xxs: { - paddingBottom: tokens.space.xxs, + pt_5xl: { + paddingTop: tokens.space._5xl, + }, + pb_2xs: { + paddingBottom: tokens.space._2xs, }, pb_xs: { paddingBottom: tokens.space.xs, @@ -301,11 +393,20 @@ export const atoms = { pb_xl: { paddingBottom: tokens.space.xl, }, - pb_xxl: { - paddingBottom: tokens.space.xxl, + pb_2xl: { + paddingBottom: tokens.space._2xl, + }, + pb_3xl: { + paddingBottom: tokens.space._3xl, + }, + pb_4xl: { + paddingBottom: tokens.space._4xl, + }, + pb_5xl: { + paddingBottom: tokens.space._5xl, }, - pl_xxs: { - paddingLeft: tokens.space.xxs, + pl_2xs: { + paddingLeft: tokens.space._2xs, }, pl_xs: { paddingLeft: tokens.space.xs, @@ -322,11 +423,20 @@ export const atoms = { pl_xl: { paddingLeft: tokens.space.xl, }, - pl_xxl: { - paddingLeft: tokens.space.xxl, + pl_2xl: { + paddingLeft: tokens.space._2xl, }, - pr_xxs: { - paddingRight: tokens.space.xxs, + pl_3xl: { + paddingLeft: tokens.space._3xl, + }, + pl_4xl: { + paddingLeft: tokens.space._4xl, + }, + pl_5xl: { + paddingLeft: tokens.space._5xl, + }, + pr_2xs: { + paddingRight: tokens.space._2xs, }, pr_xs: { paddingRight: tokens.space.xs, @@ -343,15 +453,24 @@ export const atoms = { pr_xl: { paddingRight: tokens.space.xl, }, - pr_xxl: { - paddingRight: tokens.space.xxl, + pr_2xl: { + paddingRight: tokens.space._2xl, + }, + pr_3xl: { + paddingRight: tokens.space._3xl, + }, + pr_4xl: { + paddingRight: tokens.space._4xl, + }, + pr_5xl: { + paddingRight: tokens.space._5xl, }, /* * Margin */ - m_xxs: { - margin: tokens.space.xxs, + m_2xs: { + margin: tokens.space._2xs, }, m_xs: { margin: tokens.space.xs, @@ -368,12 +487,21 @@ export const atoms = { m_xl: { margin: tokens.space.xl, }, - m_xxl: { - margin: tokens.space.xxl, + m_2xl: { + margin: tokens.space._2xl, + }, + m_3xl: { + margin: tokens.space._3xl, + }, + m_4xl: { + margin: tokens.space._4xl, }, - mx_xxs: { - marginLeft: tokens.space.xxs, - marginRight: tokens.space.xxs, + m_5xl: { + margin: tokens.space._5xl, + }, + mx_2xs: { + marginLeft: tokens.space._2xs, + marginRight: tokens.space._2xs, }, mx_xs: { marginLeft: tokens.space.xs, @@ -395,13 +523,25 @@ export const atoms = { marginLeft: tokens.space.xl, marginRight: tokens.space.xl, }, - mx_xxl: { - marginLeft: tokens.space.xxl, - marginRight: tokens.space.xxl, + mx_2xl: { + marginLeft: tokens.space._2xl, + marginRight: tokens.space._2xl, + }, + mx_3xl: { + marginLeft: tokens.space._3xl, + marginRight: tokens.space._3xl, + }, + mx_4xl: { + marginLeft: tokens.space._4xl, + marginRight: tokens.space._4xl, + }, + mx_5xl: { + marginLeft: tokens.space._5xl, + marginRight: tokens.space._5xl, }, - my_xxs: { - marginTop: tokens.space.xxs, - marginBottom: tokens.space.xxs, + my_2xs: { + marginTop: tokens.space._2xs, + marginBottom: tokens.space._2xs, }, my_xs: { marginTop: tokens.space.xs, @@ -423,12 +563,24 @@ export const atoms = { marginTop: tokens.space.xl, marginBottom: tokens.space.xl, }, - my_xxl: { - marginTop: tokens.space.xxl, - marginBottom: tokens.space.xxl, + my_2xl: { + marginTop: tokens.space._2xl, + marginBottom: tokens.space._2xl, }, - mt_xxs: { - marginTop: tokens.space.xxs, + my_3xl: { + marginTop: tokens.space._3xl, + marginBottom: tokens.space._3xl, + }, + my_4xl: { + marginTop: tokens.space._4xl, + marginBottom: tokens.space._4xl, + }, + my_5xl: { + marginTop: tokens.space._5xl, + marginBottom: tokens.space._5xl, + }, + mt_2xs: { + marginTop: tokens.space._2xs, }, mt_xs: { marginTop: tokens.space.xs, @@ -445,11 +597,20 @@ export const atoms = { mt_xl: { marginTop: tokens.space.xl, }, - mt_xxl: { - marginTop: tokens.space.xxl, + mt_2xl: { + marginTop: tokens.space._2xl, + }, + mt_3xl: { + marginTop: tokens.space._3xl, }, - mb_xxs: { - marginBottom: tokens.space.xxs, + mt_4xl: { + marginTop: tokens.space._4xl, + }, + mt_5xl: { + marginTop: tokens.space._5xl, + }, + mb_2xs: { + marginBottom: tokens.space._2xs, }, mb_xs: { marginBottom: tokens.space.xs, @@ -466,11 +627,20 @@ export const atoms = { mb_xl: { marginBottom: tokens.space.xl, }, - mb_xxl: { - marginBottom: tokens.space.xxl, + mb_2xl: { + marginBottom: tokens.space._2xl, + }, + mb_3xl: { + marginBottom: tokens.space._3xl, + }, + mb_4xl: { + marginBottom: tokens.space._4xl, }, - ml_xxs: { - marginLeft: tokens.space.xxs, + mb_5xl: { + marginBottom: tokens.space._5xl, + }, + ml_2xs: { + marginLeft: tokens.space._2xs, }, ml_xs: { marginLeft: tokens.space.xs, @@ -487,11 +657,20 @@ export const atoms = { ml_xl: { marginLeft: tokens.space.xl, }, - ml_xxl: { - marginLeft: tokens.space.xxl, + ml_2xl: { + marginLeft: tokens.space._2xl, + }, + ml_3xl: { + marginLeft: tokens.space._3xl, + }, + ml_4xl: { + marginLeft: tokens.space._4xl, + }, + ml_5xl: { + marginLeft: tokens.space._5xl, }, - mr_xxs: { - marginRight: tokens.space.xxs, + mr_2xs: { + marginRight: tokens.space._2xs, }, mr_xs: { marginRight: tokens.space.xs, @@ -508,7 +687,16 @@ export const atoms = { mr_xl: { marginRight: tokens.space.xl, }, - mr_xxl: { - marginRight: tokens.space.xxl, + mr_2xl: { + marginRight: tokens.space._2xl, + }, + mr_3xl: { + marginRight: tokens.space._3xl, + }, + mr_4xl: { + marginRight: tokens.space._4xl, + }, + mr_5xl: { + marginRight: tokens.space._5xl, }, } as const diff --git a/src/alf/index.tsx b/src/alf/index.tsx index 1daa0bfed..69a879853 100644 --- a/src/alf/index.tsx +++ b/src/alf/index.tsx @@ -5,6 +5,7 @@ import * as themes from '#/alf/themes' export * as tokens from '#/alf/tokens' export {atoms} from '#/alf/atoms' export * from '#/alf/util/platform' +export * from '#/alf/util/flatten' type BreakpointName = keyof typeof breakpoints diff --git a/src/alf/themes.ts b/src/alf/themes.ts index aae5c5893..acd180333 100644 --- a/src/alf/themes.ts +++ b/src/alf/themes.ts @@ -1,108 +1,320 @@ import * as tokens from '#/alf/tokens' import type {Mutable} from '#/alf/types' +import {atoms} from '#/alf/atoms' -export type ThemeName = 'light' | 'dark' +export type ThemeName = 'light' | 'dim' | 'dark' export type ReadonlyTheme = typeof light export type Theme = Mutable<ReadonlyTheme> +export type ReadonlyPalette = typeof lightPalette +export type Palette = Mutable<ReadonlyPalette> -export type Palette = { - primary: string - positive: string - negative: string -} +export const lightPalette = { + white: tokens.color.gray_0, + black: tokens.color.gray_1000, + + contrast_25: tokens.color.gray_25, + contrast_50: tokens.color.gray_50, + contrast_100: tokens.color.gray_100, + contrast_200: tokens.color.gray_200, + contrast_300: tokens.color.gray_300, + contrast_400: tokens.color.gray_400, + contrast_500: tokens.color.gray_500, + contrast_600: tokens.color.gray_600, + contrast_700: tokens.color.gray_700, + contrast_800: tokens.color.gray_800, + contrast_900: tokens.color.gray_900, + contrast_950: tokens.color.gray_950, + contrast_975: tokens.color.gray_975, + + primary_25: tokens.color.blue_25, + primary_50: tokens.color.blue_50, + primary_100: tokens.color.blue_100, + primary_200: tokens.color.blue_200, + primary_300: tokens.color.blue_300, + primary_400: tokens.color.blue_400, + primary_500: tokens.color.blue_500, + primary_600: tokens.color.blue_600, + primary_700: tokens.color.blue_700, + primary_800: tokens.color.blue_800, + primary_900: tokens.color.blue_900, + primary_950: tokens.color.blue_950, + primary_975: tokens.color.blue_975, + + positive_25: tokens.color.green_25, + positive_50: tokens.color.green_50, + positive_100: tokens.color.green_100, + positive_200: tokens.color.green_200, + positive_300: tokens.color.green_300, + positive_400: tokens.color.green_400, + positive_500: tokens.color.green_500, + positive_600: tokens.color.green_600, + positive_700: tokens.color.green_700, + positive_800: tokens.color.green_800, + positive_900: tokens.color.green_900, + positive_950: tokens.color.green_950, + positive_975: tokens.color.green_975, -export const lightPalette: Palette = { - primary: tokens.color.blue_500, - positive: tokens.color.green_500, - negative: tokens.color.red_500, + negative_25: tokens.color.red_25, + negative_50: tokens.color.red_50, + negative_100: tokens.color.red_100, + negative_200: tokens.color.red_200, + negative_300: tokens.color.red_300, + negative_400: tokens.color.red_400, + negative_500: tokens.color.red_500, + negative_600: tokens.color.red_600, + negative_700: tokens.color.red_700, + negative_800: tokens.color.red_800, + negative_900: tokens.color.red_900, + negative_950: tokens.color.red_950, + negative_975: tokens.color.red_975, } as const export const darkPalette: Palette = { - primary: tokens.color.blue_500, - positive: tokens.color.green_400, - negative: tokens.color.red_400, + white: tokens.color.gray_0, + black: tokens.color.gray_1000, + + contrast_25: tokens.color.gray_975, + contrast_50: tokens.color.gray_950, + contrast_100: tokens.color.gray_900, + contrast_200: tokens.color.gray_800, + contrast_300: tokens.color.gray_700, + contrast_400: tokens.color.gray_600, + contrast_500: tokens.color.gray_500, + contrast_600: tokens.color.gray_400, + contrast_700: tokens.color.gray_300, + contrast_800: tokens.color.gray_200, + contrast_900: tokens.color.gray_100, + contrast_950: tokens.color.gray_50, + contrast_975: tokens.color.gray_25, + + primary_25: tokens.color.blue_25, + primary_50: tokens.color.blue_50, + primary_100: tokens.color.blue_100, + primary_200: tokens.color.blue_200, + primary_300: tokens.color.blue_300, + primary_400: tokens.color.blue_400, + primary_500: tokens.color.blue_500, + primary_600: tokens.color.blue_600, + primary_700: tokens.color.blue_700, + primary_800: tokens.color.blue_800, + primary_900: tokens.color.blue_900, + primary_950: tokens.color.blue_950, + primary_975: tokens.color.blue_975, + + positive_25: tokens.color.green_25, + positive_50: tokens.color.green_50, + positive_100: tokens.color.green_100, + positive_200: tokens.color.green_200, + positive_300: tokens.color.green_300, + positive_400: tokens.color.green_400, + positive_500: tokens.color.green_500, + positive_600: tokens.color.green_600, + positive_700: tokens.color.green_700, + positive_800: tokens.color.green_800, + positive_900: tokens.color.green_900, + positive_950: tokens.color.green_950, + positive_975: tokens.color.green_975, + + negative_25: tokens.color.red_25, + negative_50: tokens.color.red_50, + negative_100: tokens.color.red_100, + negative_200: tokens.color.red_200, + negative_300: tokens.color.red_300, + negative_400: tokens.color.red_400, + negative_500: tokens.color.red_500, + negative_600: tokens.color.red_600, + negative_700: tokens.color.red_700, + negative_800: tokens.color.red_800, + negative_900: tokens.color.red_900, + negative_950: tokens.color.red_950, + negative_975: tokens.color.red_975, } as const export const light = { + name: 'light', palette: lightPalette, atoms: { text: { - color: tokens.color.gray_1000, + color: lightPalette.black, }, text_contrast_700: { - color: tokens.color.gray_700, + color: lightPalette.contrast_700, + }, + text_contrast_600: { + color: lightPalette.contrast_600, }, text_contrast_500: { - color: tokens.color.gray_500, + color: lightPalette.contrast_500, + }, + text_contrast_400: { + color: lightPalette.contrast_400, }, text_inverted: { - color: tokens.color.white, + color: lightPalette.white, }, bg: { - backgroundColor: tokens.color.white, + backgroundColor: lightPalette.white, + }, + bg_contrast_25: { + backgroundColor: lightPalette.contrast_25, + }, + bg_contrast_50: { + backgroundColor: lightPalette.contrast_50, }, bg_contrast_100: { - backgroundColor: tokens.color.gray_100, + backgroundColor: lightPalette.contrast_100, }, bg_contrast_200: { - backgroundColor: tokens.color.gray_200, + backgroundColor: lightPalette.contrast_200, }, bg_contrast_300: { - backgroundColor: tokens.color.gray_300, + backgroundColor: lightPalette.contrast_300, + }, + border: { + borderColor: lightPalette.contrast_200, + }, + border_contrast: { + borderColor: lightPalette.contrast_400, + }, + shadow_sm: { + ...atoms.shadow_sm, + shadowColor: lightPalette.black, + }, + shadow_md: { + ...atoms.shadow_md, + shadowColor: lightPalette.black, }, - bg_positive: { - backgroundColor: tokens.color.green_500, + shadow_lg: { + ...atoms.shadow_lg, + shadowColor: lightPalette.black, + }, + }, +} + +export const dim: Theme = { + name: 'dim', + palette: darkPalette, + atoms: { + text: { + color: darkPalette.white, + }, + text_contrast_700: { + color: darkPalette.contrast_800, }, - bg_negative: { - backgroundColor: tokens.color.red_400, + text_contrast_600: { + color: darkPalette.contrast_700, + }, + text_contrast_500: { + color: darkPalette.contrast_600, + }, + text_contrast_400: { + color: darkPalette.contrast_500, + }, + text_inverted: { + color: darkPalette.black, + }, + bg: { + backgroundColor: darkPalette.contrast_50, + }, + bg_contrast_25: { + backgroundColor: darkPalette.contrast_100, + }, + bg_contrast_50: { + backgroundColor: darkPalette.contrast_200, + }, + bg_contrast_100: { + backgroundColor: darkPalette.contrast_300, + }, + bg_contrast_200: { + backgroundColor: darkPalette.contrast_400, + }, + bg_contrast_300: { + backgroundColor: darkPalette.contrast_500, }, border: { - borderColor: tokens.color.gray_200, + borderColor: darkPalette.contrast_200, + }, + border_contrast: { + borderColor: darkPalette.contrast_400, }, - border_contrast_500: { - borderColor: tokens.color.gray_500, + shadow_sm: { + ...atoms.shadow_sm, + shadowOpacity: 0.7, + shadowColor: tokens.color.trueBlack, + }, + shadow_md: { + ...atoms.shadow_md, + shadowOpacity: 0.7, + shadowColor: tokens.color.trueBlack, + }, + shadow_lg: { + ...atoms.shadow_lg, + shadowOpacity: 0.7, + shadowColor: tokens.color.trueBlack, }, }, } export const dark: Theme = { + name: 'dark', palette: darkPalette, atoms: { text: { - color: tokens.color.white, + color: darkPalette.white, }, text_contrast_700: { - color: tokens.color.gray_300, + color: darkPalette.contrast_700, + }, + text_contrast_600: { + color: darkPalette.contrast_600, }, text_contrast_500: { - color: tokens.color.gray_500, + color: darkPalette.contrast_500, + }, + text_contrast_400: { + color: darkPalette.contrast_400, }, text_inverted: { - color: tokens.color.gray_1000, + color: darkPalette.black, }, bg: { - backgroundColor: tokens.color.gray_1000, + backgroundColor: darkPalette.contrast_25, + }, + bg_contrast_25: { + backgroundColor: darkPalette.contrast_50, + }, + bg_contrast_50: { + backgroundColor: darkPalette.contrast_100, }, bg_contrast_100: { - backgroundColor: tokens.color.gray_900, + backgroundColor: darkPalette.contrast_200, }, bg_contrast_200: { - backgroundColor: tokens.color.gray_800, + backgroundColor: darkPalette.contrast_300, }, bg_contrast_300: { - backgroundColor: tokens.color.gray_700, + backgroundColor: darkPalette.contrast_400, }, - bg_positive: { - backgroundColor: tokens.color.green_400, + border: { + borderColor: darkPalette.contrast_100, }, - bg_negative: { - backgroundColor: tokens.color.red_400, + border_contrast: { + borderColor: darkPalette.contrast_300, }, - border: { - borderColor: tokens.color.gray_800, + shadow_sm: { + ...atoms.shadow_sm, + shadowOpacity: 0.7, + shadowColor: tokens.color.trueBlack, + }, + shadow_md: { + ...atoms.shadow_md, + shadowOpacity: 0.7, + shadowColor: tokens.color.trueBlack, }, - border_contrast_500: { - borderColor: tokens.color.gray_500, + shadow_lg: { + ...atoms.shadow_lg, + shadowOpacity: 0.7, + shadowColor: tokens.color.trueBlack, }, }, } diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts index 4034e0deb..0e370cdc1 100644 --- a/src/alf/tokens.ts +++ b/src/alf/tokens.ts @@ -1,79 +1,95 @@ const BLUE_HUE = 211 -const GRAYSCALE_SATURATION = 22 +const RED_HUE = 346 +const GREEN_HUE = 152 export const color = { - white: '#FFFFFF', + trueBlack: '#000000', - gray_0: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 100%)`, - gray_100: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 95%)`, - gray_200: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 85%)`, - gray_300: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 75%)`, - gray_400: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 65%)`, - gray_500: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 55%)`, - gray_600: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 45%)`, - gray_700: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 35%)`, - gray_800: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 25%)`, - gray_900: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 15%)`, - gray_1000: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 5%)`, + gray_0: `hsl(${BLUE_HUE}, 20%, 100%)`, + gray_25: `hsl(${BLUE_HUE}, 20%, 97%)`, + gray_50: `hsl(${BLUE_HUE}, 20%, 95%)`, + gray_100: `hsl(${BLUE_HUE}, 20%, 90%)`, + gray_200: `hsl(${BLUE_HUE}, 20%, 80%)`, + gray_300: `hsl(${BLUE_HUE}, 20%, 70%)`, + gray_400: `hsl(${BLUE_HUE}, 20%, 60%)`, + gray_500: `hsl(${BLUE_HUE}, 20%, 50%)`, + gray_600: `hsl(${BLUE_HUE}, 20%, 42%)`, + gray_700: `hsl(${BLUE_HUE}, 20%, 34%)`, + gray_800: `hsl(${BLUE_HUE}, 20%, 26%)`, + gray_900: `hsl(${BLUE_HUE}, 20%, 18%)`, + gray_950: `hsl(${BLUE_HUE}, 20%, 10%)`, + gray_975: `hsl(${BLUE_HUE}, 20%, 7%)`, + gray_1000: `hsl(${BLUE_HUE}, 20%, 4%)`, - blue_0: `hsl(${BLUE_HUE}, 99%, 100%)`, - blue_100: `hsl(${BLUE_HUE}, 99%, 93%)`, - blue_200: `hsl(${BLUE_HUE}, 99%, 83%)`, - blue_300: `hsl(${BLUE_HUE}, 99%, 73%)`, - blue_400: `hsl(${BLUE_HUE}, 99%, 63%)`, + blue_25: `hsl(${BLUE_HUE}, 99%, 97%)`, + blue_50: `hsl(${BLUE_HUE}, 99%, 95%)`, + blue_100: `hsl(${BLUE_HUE}, 99%, 90%)`, + blue_200: `hsl(${BLUE_HUE}, 99%, 80%)`, + blue_300: `hsl(${BLUE_HUE}, 99%, 70%)`, + blue_400: `hsl(${BLUE_HUE}, 99%, 60%)`, blue_500: `hsl(${BLUE_HUE}, 99%, 53%)`, - blue_600: `hsl(${BLUE_HUE}, 99%, 43%)`, - blue_700: `hsl(${BLUE_HUE}, 99%, 33%)`, - blue_800: `hsl(${BLUE_HUE}, 99%, 23%)`, - blue_900: `hsl(${BLUE_HUE}, 99%, 13%)`, - blue_1000: `hsl(${BLUE_HUE}, 99%, 8%)`, + blue_600: `hsl(${BLUE_HUE}, 99%, 42%)`, + blue_700: `hsl(${BLUE_HUE}, 99%, 34%)`, + blue_800: `hsl(${BLUE_HUE}, 99%, 26%)`, + blue_900: `hsl(${BLUE_HUE}, 99%, 18%)`, + blue_950: `hsl(${BLUE_HUE}, 99%, 10%)`, + blue_975: `hsl(${BLUE_HUE}, 99%, 7%)`, - green_0: `hsl(130, 60%, 100%)`, - green_100: `hsl(130, 60%, 95%)`, - green_200: `hsl(130, 60%, 85%)`, - green_300: `hsl(130, 60%, 75%)`, - green_400: `hsl(130, 60%, 65%)`, - green_500: `hsl(130, 60%, 55%)`, - green_600: `hsl(130, 60%, 45%)`, - green_700: `hsl(130, 60%, 35%)`, - green_800: `hsl(130, 60%, 25%)`, - green_900: `hsl(130, 60%, 15%)`, - green_1000: `hsl(130, 60%, 5%)`, + green_25: `hsl(${GREEN_HUE}, 82%, 97%)`, + green_50: `hsl(${GREEN_HUE}, 82%, 95%)`, + green_100: `hsl(${GREEN_HUE}, 82%, 90%)`, + green_200: `hsl(${GREEN_HUE}, 82%, 80%)`, + green_300: `hsl(${GREEN_HUE}, 82%, 70%)`, + green_400: `hsl(${GREEN_HUE}, 82%, 60%)`, + green_500: `hsl(${GREEN_HUE}, 82%, 50%)`, + green_600: `hsl(${GREEN_HUE}, 82%, 42%)`, + green_700: `hsl(${GREEN_HUE}, 82%, 34%)`, + green_800: `hsl(${GREEN_HUE}, 82%, 26%)`, + green_900: `hsl(${GREEN_HUE}, 82%, 18%)`, + green_950: `hsl(${GREEN_HUE}, 82%, 10%)`, + green_975: `hsl(${GREEN_HUE}, 82%, 7%)`, - red_0: `hsl(349, 96%, 100%)`, - red_100: `hsl(349, 96%, 95%)`, - red_200: `hsl(349, 96%, 85%)`, - red_300: `hsl(349, 96%, 75%)`, - red_400: `hsl(349, 96%, 65%)`, - red_500: `hsl(349, 96%, 55%)`, - red_600: `hsl(349, 96%, 45%)`, - red_700: `hsl(349, 96%, 35%)`, - red_800: `hsl(349, 96%, 25%)`, - red_900: `hsl(349, 96%, 15%)`, - red_1000: `hsl(349, 96%, 5%)`, + red_25: `hsl(${RED_HUE}, 91%, 97%)`, + red_50: `hsl(${RED_HUE}, 91%, 95%)`, + red_100: `hsl(${RED_HUE}, 91%, 90%)`, + red_200: `hsl(${RED_HUE}, 91%, 80%)`, + red_300: `hsl(${RED_HUE}, 91%, 70%)`, + red_400: `hsl(${RED_HUE}, 91%, 60%)`, + red_500: `hsl(${RED_HUE}, 91%, 50%)`, + red_600: `hsl(${RED_HUE}, 91%, 42%)`, + red_700: `hsl(${RED_HUE}, 91%, 34%)`, + red_800: `hsl(${RED_HUE}, 91%, 26%)`, + red_900: `hsl(${RED_HUE}, 91%, 18%)`, + red_950: `hsl(${RED_HUE}, 91%, 10%)`, + red_975: `hsl(${RED_HUE}, 91%, 7%)`, } as const export const space = { - xxs: 2, + _2xs: 2, xs: 4, sm: 8, md: 12, - lg: 18, - xl: 24, - xxl: 32, + lg: 16, + xl: 20, + _2xl: 24, + _3xl: 28, + _4xl: 32, + _5xl: 40, } as const export const fontSize = { - xxs: 10, + _2xs: 10, xs: 12, sm: 14, md: 16, lg: 18, - xl: 22, - xxl: 26, + xl: 20, + _2xl: 22, + _3xl: 26, + _4xl: 32, + _5xl: 40, } as const -// TODO test export const lineHeight = { none: 1, normal: 1.5, @@ -81,6 +97,8 @@ export const lineHeight = { } as const export const borderRadius = { + _2xs: 2, + xs: 4, sm: 8, md: 12, full: 999, @@ -92,6 +110,56 @@ export const fontWeight = { bold: '900', } as const +export const gradients = { + sky: { + values: [ + [0, '#0A7AFF'], + [1, '#59B9FF'], + ], + hover_value: '#0A7AFF', + }, + midnight: { + values: [ + [0, '#022C5E'], + [1, '#4079BC'], + ], + hover_value: '#022C5E', + }, + sunrise: { + values: [ + [0, '#4E90AE'], + [0.4, '#AEA3AB'], + [0.8, '#E6A98F'], + [1, '#F3A84C'], + ], + hover_value: '#AEA3AB', + }, + sunset: { + values: [ + [0, '#6772AF'], + [0.6, '#B88BB6'], + [1, '#FFA6AC'], + ], + hover_value: '#B88BB6', + }, + nordic: { + values: [ + [0, '#083367'], + [1, '#9EE8C1'], + ], + hover_value: '#3A7085', + }, + bonfire: { + values: [ + [0, '#203E4E'], + [0.4, '#755B62'], + [0.8, '#CD7765'], + [1, '#EF956E'], + ], + hover_value: '#755B62', + }, +} as const + export type Color = keyof typeof color export type Space = keyof typeof space export type FontSize = keyof typeof fontSize diff --git a/src/alf/util/flatten.ts b/src/alf/util/flatten.ts new file mode 100644 index 000000000..448716a08 --- /dev/null +++ b/src/alf/util/flatten.ts @@ -0,0 +1,3 @@ +import {StyleSheet} from 'react-native' + +export const flatten = StyleSheet.flatten diff --git a/src/components/Button.tsx b/src/components/Button.tsx new file mode 100644 index 000000000..d2100f0b4 --- /dev/null +++ b/src/components/Button.tsx @@ -0,0 +1,507 @@ +import React from 'react' +import { + Pressable, + Text, + PressableProps, + TextProps, + ViewStyle, + AccessibilityProps, + View, + TextStyle, + StyleSheet, +} from 'react-native' +import LinearGradient from 'react-native-linear-gradient' + +import {useTheme, atoms as a, tokens, web, native} from '#/alf' +import {Props as SVGIconProps} from '#/components/icons/common' + +export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient' +export type ButtonColor = + | 'primary' + | 'secondary' + | 'negative' + | 'gradient_sky' + | 'gradient_midnight' + | 'gradient_sunrise' + | 'gradient_sunset' + | 'gradient_nordic' + | 'gradient_bonfire' +export type ButtonSize = 'small' | 'large' +export type VariantProps = { + /** + * The style variation of the button + */ + variant?: ButtonVariant + /** + * The color of the button + */ + color?: ButtonColor + /** + * The size of the button + */ + size?: ButtonSize +} + +export type ButtonProps = React.PropsWithChildren< + Pick<PressableProps, 'disabled' | 'onPress'> & + AccessibilityProps & + VariantProps & { + label: string + } +> +export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean} + +const Context = React.createContext< + VariantProps & { + hovered: boolean + focused: boolean + pressed: boolean + disabled: boolean + } +>({ + hovered: false, + focused: false, + pressed: false, + disabled: false, +}) + +export function useButtonContext() { + return React.useContext(Context) +} + +export function Button({ + children, + variant, + color, + size, + label, + disabled = false, + ...rest +}: ButtonProps) { + const t = useTheme() + const [state, setState] = React.useState({ + pressed: false, + hovered: false, + focused: false, + }) + + const onPressIn = React.useCallback(() => { + setState(s => ({ + ...s, + pressed: true, + })) + }, [setState]) + const onPressOut = React.useCallback(() => { + setState(s => ({ + ...s, + pressed: false, + })) + }, [setState]) + const onHoverIn = React.useCallback(() => { + setState(s => ({ + ...s, + hovered: true, + })) + }, [setState]) + const onHoverOut = React.useCallback(() => { + setState(s => ({ + ...s, + hovered: false, + })) + }, [setState]) + const onFocus = React.useCallback(() => { + setState(s => ({ + ...s, + focused: true, + })) + }, [setState]) + const onBlur = React.useCallback(() => { + setState(s => ({ + ...s, + focused: false, + })) + }, [setState]) + + const {baseStyles, hoverStyles, focusStyles} = React.useMemo(() => { + const baseStyles: ViewStyle[] = [] + const hoverStyles: ViewStyle[] = [] + const light = t.name === 'light' + + if (color === 'primary') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({ + backgroundColor: t.palette.primary_500, + }) + hoverStyles.push({ + backgroundColor: t.palette.primary_600, + }) + } else { + baseStyles.push({ + backgroundColor: t.palette.primary_700, + }) + } + } else if (variant === 'outline') { + baseStyles.push(a.border, t.atoms.bg, { + borderWidth: 1, + }) + + if (!disabled) { + baseStyles.push(a.border, { + borderColor: tokens.color.blue_500, + }) + hoverStyles.push(a.border, { + backgroundColor: light + ? t.palette.primary_50 + : t.palette.primary_950, + }) + } else { + baseStyles.push(a.border, { + borderColor: light ? tokens.color.blue_200 : tokens.color.blue_900, + }) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push(t.atoms.bg) + hoverStyles.push({ + backgroundColor: light + ? t.palette.primary_100 + : t.palette.primary_900, + }) + } + } + } else if (color === 'secondary') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({ + backgroundColor: light + ? tokens.color.gray_100 + : tokens.color.gray_900, + }) + hoverStyles.push({ + backgroundColor: light + ? tokens.color.gray_200 + : tokens.color.gray_950, + }) + } else { + baseStyles.push({ + backgroundColor: light + ? tokens.color.gray_300 + : tokens.color.gray_950, + }) + } + } else if (variant === 'outline') { + baseStyles.push(a.border, t.atoms.bg, { + borderWidth: 1, + }) + + if (!disabled) { + baseStyles.push(a.border, { + borderColor: light ? tokens.color.gray_500 : tokens.color.gray_500, + }) + hoverStyles.push(a.border, t.atoms.bg_contrast_50) + } else { + baseStyles.push(a.border, { + borderColor: light ? tokens.color.gray_200 : tokens.color.gray_800, + }) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push(t.atoms.bg) + hoverStyles.push({ + backgroundColor: light + ? tokens.color.gray_100 + : tokens.color.gray_900, + }) + } + } + } else if (color === 'negative') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({ + backgroundColor: t.palette.negative_400, + }) + hoverStyles.push({ + backgroundColor: t.palette.negative_500, + }) + } else { + baseStyles.push({ + backgroundColor: t.palette.negative_600, + }) + } + } else if (variant === 'outline') { + baseStyles.push(a.border, t.atoms.bg, { + borderWidth: 1, + }) + + if (!disabled) { + baseStyles.push(a.border, { + borderColor: t.palette.negative_400, + }) + hoverStyles.push(a.border, { + backgroundColor: light + ? t.palette.negative_50 + : t.palette.negative_975, + }) + } else { + baseStyles.push(a.border, { + borderColor: light + ? t.palette.negative_200 + : t.palette.negative_900, + }) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push(t.atoms.bg) + hoverStyles.push({ + backgroundColor: light + ? t.palette.negative_100 + : t.palette.negative_950, + }) + } + } + } + + if (size === 'large') { + baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_sm) + } else if (size === 'small') { + baseStyles.push({paddingVertical: 9}, a.px_md, a.rounded_sm, a.gap_sm) + } + + return { + baseStyles, + hoverStyles, + focusStyles: [ + ...hoverStyles, + { + outline: 0, + } as ViewStyle, + ], + } + }, [t, variant, color, size, disabled]) + + const {gradientColors, gradientHoverColors, gradientLocations} = + React.useMemo(() => { + const colors: string[] = [] + const hoverColors: string[] = [] + const locations: number[] = [] + const gradient = { + primary: tokens.gradients.sky, + secondary: tokens.gradients.sky, + negative: tokens.gradients.sky, + gradient_sky: tokens.gradients.sky, + gradient_midnight: tokens.gradients.midnight, + gradient_sunrise: tokens.gradients.sunrise, + gradient_sunset: tokens.gradients.sunset, + gradient_nordic: tokens.gradients.nordic, + gradient_bonfire: tokens.gradients.bonfire, + }[color || 'primary'] + + if (variant === 'gradient') { + colors.push(...gradient.values.map(([_, color]) => color)) + hoverColors.push(...gradient.values.map(_ => gradient.hover_value)) + locations.push(...gradient.values.map(([location, _]) => location)) + } + + return { + gradientColors: colors, + gradientHoverColors: hoverColors, + gradientLocations: locations, + } + }, [variant, color]) + + const context = React.useMemo( + () => ({ + ...state, + variant, + color, + size, + disabled: disabled || false, + }), + [state, variant, color, size, disabled], + ) + + return ( + <Pressable + role="button" + accessibilityHint={undefined} // optional + {...rest} + aria-label={label} + aria-pressed={state.pressed} + accessibilityLabel={label} + disabled={disabled || false} + accessibilityState={{ + disabled: disabled || false, + }} + style={[ + a.flex_row, + a.align_center, + a.overflow_hidden, + ...baseStyles, + ...(state.hovered || state.pressed ? hoverStyles : []), + ...(state.focused ? focusStyles : []), + ]} + onPressIn={onPressIn} + onPressOut={onPressOut} + onHoverIn={onHoverIn} + onHoverOut={onHoverOut} + onFocus={onFocus} + onBlur={onBlur}> + {variant === 'gradient' && ( + <LinearGradient + colors={ + state.hovered || state.pressed || state.focused + ? gradientHoverColors + : gradientColors + } + locations={gradientLocations} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={[a.absolute, a.inset_0]} + /> + )} + <Context.Provider value={context}> + {typeof children === 'string' ? ( + <ButtonText>{children}</ButtonText> + ) : ( + children + )} + </Context.Provider> + </Pressable> + ) +} + +export function useSharedButtonTextStyles() { + const t = useTheme() + const {color, variant, disabled, size} = useButtonContext() + return React.useMemo(() => { + const baseStyles: TextStyle[] = [] + const light = t.name === 'light' + + if (color === 'primary') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({color: t.palette.white}) + } else { + baseStyles.push({color: t.palette.white, opacity: 0.5}) + } + } else if (variant === 'outline') { + if (!disabled) { + baseStyles.push({ + color: light ? t.palette.primary_600 : t.palette.primary_500, + }) + } else { + baseStyles.push({color: t.palette.primary_600, opacity: 0.5}) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push({color: t.palette.primary_600}) + } else { + baseStyles.push({color: t.palette.primary_600, opacity: 0.5}) + } + } + } else if (color === 'secondary') { + if (variant === 'solid' || variant === 'gradient') { + if (!disabled) { + baseStyles.push({ + color: light ? tokens.color.gray_700 : tokens.color.gray_100, + }) + } else { + baseStyles.push({ + color: light ? tokens.color.gray_400 : tokens.color.gray_700, + }) + } + } else if (variant === 'outline') { + if (!disabled) { + baseStyles.push({ + color: light ? tokens.color.gray_600 : tokens.color.gray_300, + }) + } else { + baseStyles.push({ + color: light ? tokens.color.gray_400 : tokens.color.gray_700, + }) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push({ + color: light ? tokens.color.gray_600 : tokens.color.gray_300, + }) + } else { + baseStyles.push({ + color: light ? tokens.color.gray_400 : tokens.color.gray_600, + }) + } + } + } else if (color === 'negative') { + if (variant === 'solid' || variant === 'gradient') { + if (!disabled) { + baseStyles.push({color: t.palette.white}) + } else { + baseStyles.push({color: t.palette.white, opacity: 0.5}) + } + } else if (variant === 'outline') { + if (!disabled) { + baseStyles.push({color: t.palette.negative_400}) + } else { + baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push({color: t.palette.negative_400}) + } else { + baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) + } + } + } else { + if (!disabled) { + baseStyles.push({color: t.palette.white}) + } else { + baseStyles.push({color: t.palette.white, opacity: 0.5}) + } + } + + if (size === 'large') { + baseStyles.push( + a.text_md, + web({paddingBottom: 1}), + native({marginTop: 2}), + ) + } else { + baseStyles.push( + a.text_md, + web({paddingBottom: 1}), + native({marginTop: 2}), + ) + } + + return StyleSheet.flatten(baseStyles) + }, [t, variant, color, size, disabled]) +} + +export function ButtonText({children, style, ...rest}: ButtonTextProps) { + const textStyles = useSharedButtonTextStyles() + + return ( + <Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}> + {children} + </Text> + ) +} + +export function ButtonIcon({ + icon: Comp, +}: { + icon: React.ComponentType<SVGIconProps> +}) { + const {size} = useButtonContext() + const textStyles = useSharedButtonTextStyles() + + return ( + <View style={[a.z_20]}> + <Comp + size={size === 'large' ? 'md' : 'sm'} + style={[{color: textStyles.color, pointerEvents: 'none'}]} + /> + </View> + ) +} diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts new file mode 100644 index 000000000..b28b9f5a2 --- /dev/null +++ b/src/components/Dialog/context.ts @@ -0,0 +1,35 @@ +import React from 'react' + +import {useDialogStateContext} from '#/state/dialogs' +import {DialogContextProps, DialogControlProps} from '#/components/Dialog/types' + +export const Context = React.createContext<DialogContextProps>({ + close: () => {}, +}) + +export function useDialogContext() { + return React.useContext(Context) +} + +export function useDialogControl() { + const id = React.useId() + const control = React.useRef<DialogControlProps>({ + open: () => {}, + close: () => {}, + }) + const {activeDialogs} = useDialogStateContext() + + React.useEffect(() => { + activeDialogs.current.set(id, control) + return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps + activeDialogs.current.delete(id) + } + }, [id, activeDialogs]) + + return { + ref: control, + open: () => control.current.open(), + close: () => control.current.close(), + } +} diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx new file mode 100644 index 000000000..44e4dc8a7 --- /dev/null +++ b/src/components/Dialog/index.tsx @@ -0,0 +1,162 @@ +import React, {useImperativeHandle} from 'react' +import {View, Dimensions} from 'react-native' +import BottomSheet, { + BottomSheetBackdrop, + BottomSheetScrollView, + BottomSheetTextInput, + BottomSheetView, +} from '@gorhom/bottom-sheet' +import {useSafeAreaInsets} from 'react-native-safe-area-context' + +import {useTheme, atoms as a} from '#/alf' +import {Portal} from '#/components/Portal' +import {createInput} from '#/components/forms/TextField' + +import { + DialogOuterProps, + DialogControlProps, + DialogInnerProps, +} from '#/components/Dialog/types' +import {Context} from '#/components/Dialog/context' + +export {useDialogControl, useDialogContext} from '#/components/Dialog/context' +export * from '#/components/Dialog/types' +// @ts-ignore +export const Input = createInput(BottomSheetTextInput) + +export function Outer({ + children, + control, + onClose, + nativeOptions, +}: React.PropsWithChildren<DialogOuterProps>) { + const t = useTheme() + const sheet = React.useRef<BottomSheet>(null) + const sheetOptions = nativeOptions?.sheet || {} + const hasSnapPoints = !!sheetOptions.snapPoints + + const open = React.useCallback<DialogControlProps['open']>((i = 0) => { + sheet.current?.snapToIndex(i) + }, []) + + const close = React.useCallback(() => { + sheet.current?.close() + onClose?.() + }, [onClose]) + + useImperativeHandle( + control.ref, + () => ({ + open, + close, + }), + [open, close], + ) + + const context = React.useMemo(() => ({close}), [close]) + + return ( + <Portal> + <BottomSheet + enableDynamicSizing={!hasSnapPoints} + enablePanDownToClose + keyboardBehavior="interactive" + android_keyboardInputMode="adjustResize" + keyboardBlurBehavior="restore" + {...sheetOptions} + ref={sheet} + index={-1} + backgroundStyle={{backgroundColor: 'transparent'}} + backdropComponent={props => ( + <BottomSheetBackdrop + opacity={0.4} + appearsOnIndex={0} + disappearsOnIndex={-1} + {...props} + /> + )} + handleIndicatorStyle={{backgroundColor: t.palette.primary_500}} + handleStyle={{display: 'none'}} + onClose={onClose}> + <Context.Provider value={context}> + <View + style={[ + a.absolute, + a.inset_0, + t.atoms.bg, + { + borderTopLeftRadius: 40, + borderTopRightRadius: 40, + height: Dimensions.get('window').height * 2, + }, + ]} + /> + {children} + </Context.Provider> + </BottomSheet> + </Portal> + ) +} + +// TODO a11y props here, or is that handled by the sheet? +export function Inner(props: DialogInnerProps) { + const insets = useSafeAreaInsets() + return ( + <BottomSheetView + style={[ + a.p_lg, + a.pt_3xl, + { + borderTopLeftRadius: 40, + borderTopRightRadius: 40, + paddingBottom: insets.bottom + a.pb_5xl.paddingBottom, + }, + ]}> + {props.children} + </BottomSheetView> + ) +} + +export function ScrollableInner(props: DialogInnerProps) { + const insets = useSafeAreaInsets() + return ( + <BottomSheetScrollView + style={[ + a.flex_1, // main diff is this + a.p_lg, + a.pt_3xl, + { + borderTopLeftRadius: 40, + borderTopRightRadius: 40, + }, + ]}> + {props.children} + <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} /> + </BottomSheetScrollView> + ) +} + +export function Handle() { + const t = useTheme() + return ( + <View + style={[ + a.absolute, + a.rounded_sm, + a.z_10, + { + top: a.pt_lg.paddingTop, + width: 35, + height: 4, + alignSelf: 'center', + backgroundColor: t.palette.contrast_900, + opacity: 0.5, + }, + ]} + /> + ) +} + +export function Close() { + return null +} diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx new file mode 100644 index 000000000..305c00e97 --- /dev/null +++ b/src/components/Dialog/index.web.tsx @@ -0,0 +1,194 @@ +import React, {useImperativeHandle} from 'react' +import {View, TouchableWithoutFeedback} from 'react-native' +import {FocusScope} from '@tamagui/focus-scope' +import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useTheme, atoms as a, useBreakpoints, web} from '#/alf' +import {Portal} from '#/components/Portal' + +import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types' +import {Context} from '#/components/Dialog/context' + +export {useDialogControl, useDialogContext} from '#/components/Dialog/context' +export * from '#/components/Dialog/types' +export {Input} from '#/components/forms/TextField' + +const stopPropagation = (e: any) => e.stopPropagation() + +export function Outer({ + control, + onClose, + children, +}: React.PropsWithChildren<DialogOuterProps>) { + const {_} = useLingui() + const t = useTheme() + const {gtMobile} = useBreakpoints() + const [isOpen, setIsOpen] = React.useState(false) + const [isVisible, setIsVisible] = React.useState(true) + + const open = React.useCallback(() => { + setIsOpen(true) + }, [setIsOpen]) + + const close = React.useCallback(async () => { + setIsVisible(false) + await new Promise(resolve => setTimeout(resolve, 150)) + setIsOpen(false) + setIsVisible(true) + onClose?.() + }, [onClose, setIsOpen]) + + useImperativeHandle( + control.ref, + () => ({ + open, + close, + }), + [open, close], + ) + + React.useEffect(() => { + if (!isOpen) return + + function handler(e: KeyboardEvent) { + if (e.key === 'Escape') close() + } + + document.addEventListener('keydown', handler) + + return () => document.removeEventListener('keydown', handler) + }, [isOpen, close]) + + const context = React.useMemo( + () => ({ + close, + }), + [close], + ) + + return ( + <> + {isOpen && ( + <Portal> + <Context.Provider value={context}> + <TouchableWithoutFeedback + accessibilityHint={undefined} + accessibilityLabel={_(msg`Close active dialog`)} + onPress={close}> + <View + style={[ + web(a.fixed), + a.inset_0, + a.z_10, + a.align_center, + gtMobile ? a.p_lg : a.p_md, + {overflowY: 'auto'}, + ]}> + {isVisible && ( + <Animated.View + entering={FadeIn.duration(150)} + // exiting={FadeOut.duration(150)} + style={[ + web(a.fixed), + a.inset_0, + {opacity: 0.5, backgroundColor: t.palette.black}, + ]} + /> + )} + + <View + style={[ + a.w_full, + a.z_20, + a.justify_center, + a.align_center, + { + minHeight: web('calc(90vh - 36px)') || undefined, + }, + ]}> + {isVisible ? children : null} + </View> + </View> + </TouchableWithoutFeedback> + </Context.Provider> + </Portal> + )} + </> + ) +} + +export function Inner({ + children, + style, + label, + accessibilityLabelledBy, + accessibilityDescribedBy, +}: DialogInnerProps) { + const t = useTheme() + const {gtMobile} = useBreakpoints() + return ( + <FocusScope loop enabled trapped> + <Animated.View + role="dialog" + aria-role="dialog" + aria-label={label} + aria-labelledby={accessibilityLabelledBy} + aria-describedby={accessibilityDescribedBy} + // @ts-ignore web only -prf + onClick={stopPropagation} + onStartShouldSetResponder={_ => true} + onTouchEnd={stopPropagation} + entering={FadeInDown.duration(100)} + // exiting={FadeOut.duration(100)} + style={[ + a.relative, + a.rounded_md, + a.w_full, + a.border, + gtMobile ? a.p_xl : a.p_lg, + t.atoms.bg, + { + maxWidth: 600, + borderColor: t.palette.contrast_200, + shadowColor: t.palette.black, + shadowOpacity: t.name === 'light' ? 0.1 : 0.4, + shadowRadius: 30, + }, + ...(Array.isArray(style) ? style : [style || {}]), + ]}> + {children} + </Animated.View> + </FocusScope> + ) +} + +export const ScrollableInner = Inner + +export function Handle() { + return null +} + +/** + * TODO(eric) unused rn + */ +// export function Close() { +// const {_} = useLingui() +// const t = useTheme() +// const {close} = useDialogContext() +// return ( +// <View +// style={[ +// a.absolute, +// a.z_10, +// { +// top: a.pt_lg.paddingTop, +// right: a.pr_lg.paddingRight, +// }, +// ]}> +// <Button onPress={close} label={_(msg`Close active dialog`)}> +// </Button> +// </View> +// ) +// } diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts new file mode 100644 index 000000000..d36784183 --- /dev/null +++ b/src/components/Dialog/types.ts @@ -0,0 +1,43 @@ +import React from 'react' +import type {ViewStyle, AccessibilityProps} from 'react-native' +import {BottomSheetProps} from '@gorhom/bottom-sheet' + +type A11yProps = Required<AccessibilityProps> + +export type DialogContextProps = { + close: () => void +} + +export type DialogControlProps = { + open: (index?: number) => void + close: () => void +} + +export type DialogOuterProps = { + control: { + ref: React.RefObject<DialogControlProps> + open: (index?: number) => void + close: () => void + } + onClose?: () => void + nativeOptions?: { + sheet?: Omit<BottomSheetProps, 'children'> + } + webOptions?: {} +} + +type DialogInnerPropsBase<T> = React.PropsWithChildren<{ + style?: ViewStyle +}> & + T +export type DialogInnerProps = + | DialogInnerPropsBase<{ + label?: undefined + accessibilityLabelledBy: A11yProps['aria-labelledby'] + accessibilityDescribedBy: string + }> + | DialogInnerPropsBase<{ + label: string + accessibilityLabelledBy?: undefined + accessibilityDescribedBy?: undefined + }> diff --git a/src/components/Link.tsx b/src/components/Link.tsx new file mode 100644 index 000000000..8f686f3c4 --- /dev/null +++ b/src/components/Link.tsx @@ -0,0 +1,191 @@ +import React from 'react' +import { + Text, + TextStyle, + StyleProp, + GestureResponderEvent, + Linking, +} from 'react-native' +import { + useLinkProps, + useNavigation, + StackActions, +} from '@react-navigation/native' +import {sanitizeUrl} from '@braintree/sanitize-url' + +import {isWeb} from '#/platform/detection' +import {useTheme, web, flatten} from '#/alf' +import {Button, ButtonProps, useButtonContext} from '#/components/Button' +import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types' +import { + convertBskyAppUrlIfNeeded, + isExternalUrl, + linkRequiresWarning, +} from '#/lib/strings/url-helpers' +import {useModalControls} from '#/state/modals' +import {router} from '#/routes' + +export type LinkProps = Omit< + ButtonProps, + 'style' | 'onPress' | 'disabled' | 'label' +> & { + /** + * `TextStyle` to apply to the anchor element itself. Does not apply to any children. + */ + style?: StyleProp<TextStyle> + /** + * The React Navigation `StackAction` to perform when the link is pressed. + */ + action?: 'push' | 'replace' | 'navigate' + /** + * If true, will warn the user if the link text does not match the href. Only + * works for Links with children that are strings i.e. text links. + */ + warnOnMismatchingTextChild?: boolean + label?: ButtonProps['label'] +} & Pick<Parameters<typeof useLinkProps<AllNavigatorParams>>[0], 'to'> + +/** + * A interactive element that renders as a `<a>` tag on the web. On mobile it + * will translate the `href` to navigator screens and params and dispatch a + * navigation action. + * + * Intended to behave as a web anchor tag. For more complex routing, use a + * `Button`. + */ +export function Link({ + children, + to, + action = 'push', + warnOnMismatchingTextChild, + style, + ...rest +}: LinkProps) { + const navigation = useNavigation<NavigationProp>() + const {href} = useLinkProps<AllNavigatorParams>({ + to: + typeof to === 'string' ? convertBskyAppUrlIfNeeded(sanitizeUrl(to)) : to, + }) + const isExternal = isExternalUrl(href) + const {openModal, closeModal} = useModalControls() + const onPress = React.useCallback( + (e: GestureResponderEvent) => { + const stringChildren = typeof children === 'string' ? children : '' + const requiresWarning = Boolean( + warnOnMismatchingTextChild && + stringChildren && + isExternal && + linkRequiresWarning(href, stringChildren), + ) + + if (requiresWarning) { + e.preventDefault() + + openModal({ + name: 'link-warning', + text: stringChildren, + href: href, + }) + } else { + e.preventDefault() + + if (isExternal) { + Linking.openURL(href) + } else { + /** + * A `GestureResponderEvent`, but cast to `any` to avoid using a bunch + * of @ts-ignore below. + */ + const event = e as any + const isMiddleClick = isWeb && event.button === 1 + const isMetaKey = + isWeb && + (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) + const shouldOpenInNewTab = isMetaKey || isMiddleClick + + if ( + shouldOpenInNewTab || + href.startsWith('http') || + href.startsWith('mailto') + ) { + Linking.openURL(href) + } else { + closeModal() // close any active modals + + if (action === 'push') { + navigation.dispatch(StackActions.push(...router.matchPath(href))) + } else if (action === 'replace') { + navigation.dispatch( + StackActions.replace(...router.matchPath(href)), + ) + } else if (action === 'navigate') { + // @ts-ignore + navigation.navigate(...router.matchPath(href)) + } else { + throw Error('Unsupported navigator action.') + } + } + } + } + }, + [ + href, + isExternal, + warnOnMismatchingTextChild, + navigation, + action, + children, + closeModal, + openModal, + ], + ) + + return ( + <Button + label={href} + {...rest} + role="link" + accessibilityRole="link" + href={href} + onPress={onPress} + {...web({ + hrefAttrs: { + target: isExternal ? 'blank' : undefined, + rel: isExternal ? 'noopener noreferrer' : undefined, + }, + dataSet: { + // default to no underline, apply this ourselves + noUnderline: '1', + }, + })}> + {typeof children === 'string' ? ( + <LinkText style={style}>{children}</LinkText> + ) : ( + children + )} + </Button> + ) +} + +function LinkText({ + children, + style, +}: React.PropsWithChildren<{ + style?: StyleProp<TextStyle> +}>) { + const t = useTheme() + const {hovered} = useButtonContext() + return ( + <Text + style={[ + {color: t.palette.primary_500}, + hovered && { + textDecorationLine: 'underline', + textDecorationColor: t.palette.primary_500, + }, + flatten(style), + ]}> + {children as string} + </Text> + ) +} diff --git a/src/components/Portal.tsx b/src/components/Portal.tsx new file mode 100644 index 000000000..1813d9e05 --- /dev/null +++ b/src/components/Portal.tsx @@ -0,0 +1,56 @@ +import React from 'react' + +type Component = React.ReactElement + +type ContextType = { + outlet: Component | null + append(id: string, component: Component): void + remove(id: string): void +} + +type ComponentMap = { + [id: string]: Component +} + +export const Context = React.createContext<ContextType>({ + outlet: null, + append: () => {}, + remove: () => {}, +}) + +export function Provider(props: React.PropsWithChildren<{}>) { + const map = React.useRef<ComponentMap>({}) + const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null) + + const append = React.useCallback<ContextType['append']>((id, component) => { + if (map.current[id]) return + map.current[id] = <React.Fragment key={id}>{component}</React.Fragment> + setOutlet(<>{Object.values(map.current)}</>) + }, []) + + const remove = React.useCallback<ContextType['remove']>(id => { + delete map.current[id] + setOutlet(<>{Object.values(map.current)}</>) + }, []) + + return ( + <Context.Provider value={{outlet, append, remove}}> + {props.children} + </Context.Provider> + ) +} + +export function Outlet() { + const ctx = React.useContext(Context) + return ctx.outlet +} + +export function Portal({children}: React.PropsWithChildren<{}>) { + const {append, remove} = React.useContext(Context) + const id = React.useId() + React.useEffect(() => { + append(id, children as Component) + return () => remove(id) + }, [id, children, append, remove]) + return null +} diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx new file mode 100644 index 000000000..7115f6190 --- /dev/null +++ b/src/components/Prompt.tsx @@ -0,0 +1,119 @@ +import React from 'react' +import {View, PressableProps} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useTheme, atoms as a} from '#/alf' +import {H4, P} from '#/components/Typography' +import {Button} from '#/components/Button' + +import * as Dialog from '#/components/Dialog' + +export {useDialogControl as usePromptControl} from '#/components/Dialog' + +const Context = React.createContext<{ + titleId: string + descriptionId: string +}>({ + titleId: '', + descriptionId: '', +}) + +export function Outer({ + children, + control, +}: React.PropsWithChildren<{ + control: Dialog.DialogOuterProps['control'] +}>) { + const titleId = React.useId() + const descriptionId = React.useId() + + const context = React.useMemo( + () => ({titleId, descriptionId}), + [titleId, descriptionId], + ) + + return ( + <Dialog.Outer control={control}> + <Context.Provider value={context}> + <Dialog.Handle /> + + <Dialog.Inner + accessibilityLabelledBy={titleId} + accessibilityDescribedBy={descriptionId} + style={{width: 'auto', maxWidth: 400}}> + {children} + </Dialog.Inner> + </Context.Provider> + </Dialog.Outer> + ) +} + +export function Title({children}: React.PropsWithChildren<{}>) { + const t = useTheme() + const {titleId} = React.useContext(Context) + return ( + <H4 + nativeID={titleId} + style={[a.font_bold, t.atoms.text_contrast_700, a.pb_sm]}> + {children} + </H4> + ) +} + +export function Description({children}: React.PropsWithChildren<{}>) { + const t = useTheme() + const {descriptionId} = React.useContext(Context) + return ( + <P nativeID={descriptionId} style={[t.atoms.text, a.pb_lg]}> + {children} + </P> + ) +} + +export function Actions({children}: React.PropsWithChildren<{}>) { + return ( + <View style={[a.w_full, a.flex_row, a.gap_sm, a.justify_end]}> + {children} + </View> + ) +} + +export function Cancel({ + children, +}: React.PropsWithChildren<{onPress?: PressableProps['onPress']}>) { + const {_} = useLingui() + const {close} = Dialog.useDialogContext() + return ( + <Button + variant="solid" + color="secondary" + size="small" + label={_(msg`Cancel`)} + onPress={close}> + {children} + </Button> + ) +} + +export function Action({ + children, + onPress, +}: React.PropsWithChildren<{onPress?: () => void}>) { + const {_} = useLingui() + const {close} = Dialog.useDialogContext() + const handleOnPress = React.useCallback(() => { + close() + onPress?.() + }, [close, onPress]) + return ( + <Button + variant="solid" + color="primary" + size="small" + label={_(msg`Confirm`)} + onPress={handleOnPress}> + {children} + </Button> + ) +} diff --git a/src/view/com/Typography.tsx b/src/components/Typography.tsx index 6579c2e51..66cf0720d 100644 --- a/src/view/com/Typography.tsx +++ b/src/components/Typography.tsx @@ -1,6 +1,7 @@ import React from 'react' import {Text as RNText, TextProps} from 'react-native' -import {useTheme, atoms, web} from '#/alf' + +import {useTheme, atoms, web, flatten} from '#/alf' export function Text({style, ...rest}: TextProps) { const t = useTheme() @@ -18,7 +19,7 @@ export function H1({style, ...rest}: TextProps) { <RNText {...attr} {...rest} - style={[atoms.text_xl, atoms.font_bold, t.atoms.text, style]} + style={[atoms.text_5xl, atoms.font_bold, t.atoms.text, flatten(style)]} /> ) } @@ -34,7 +35,7 @@ export function H2({style, ...rest}: TextProps) { <RNText {...attr} {...rest} - style={[atoms.text_lg, atoms.font_bold, t.atoms.text, style]} + style={[atoms.text_4xl, atoms.font_bold, t.atoms.text, flatten(style)]} /> ) } @@ -50,7 +51,7 @@ export function H3({style, ...rest}: TextProps) { <RNText {...attr} {...rest} - style={[atoms.text_md, atoms.font_bold, t.atoms.text, style]} + style={[atoms.text_3xl, atoms.font_bold, t.atoms.text, flatten(style)]} /> ) } @@ -66,7 +67,7 @@ export function H4({style, ...rest}: TextProps) { <RNText {...attr} {...rest} - style={[atoms.text_sm, atoms.font_bold, t.atoms.text, style]} + style={[atoms.text_2xl, atoms.font_bold, t.atoms.text, flatten(style)]} /> ) } @@ -82,7 +83,7 @@ export function H5({style, ...rest}: TextProps) { <RNText {...attr} {...rest} - style={[atoms.text_xs, atoms.font_bold, t.atoms.text, style]} + style={[atoms.text_xl, atoms.font_bold, t.atoms.text, flatten(style)]} /> ) } @@ -98,7 +99,26 @@ export function H6({style, ...rest}: TextProps) { <RNText {...attr} {...rest} - style={[atoms.text_xxs, atoms.font_bold, t.atoms.text, style]} + style={[atoms.text_lg, atoms.font_bold, t.atoms.text, flatten(style)]} + /> + ) +} + +export function P({style, ...rest}: TextProps) { + const t = useTheme() + const attr = + web({ + role: 'paragraph', + }) || {} + const _style = flatten(style) + const lineHeight = + (_style?.lineHeight || atoms.text_md.lineHeight) * + atoms.leading_normal.lineHeight + return ( + <RNText + {...attr} + {...rest} + style={[atoms.text_md, t.atoms.text, _style, {lineHeight}]} /> ) } diff --git a/src/components/forms/DateField/index.android.tsx b/src/components/forms/DateField/index.android.tsx new file mode 100644 index 000000000..83fa285f5 --- /dev/null +++ b/src/components/forms/DateField/index.android.tsx @@ -0,0 +1,108 @@ +import React from 'react' +import {View, Pressable} from 'react-native' +import DateTimePicker, { + BaseProps as DateTimePickerProps, +} from '@react-native-community/datetimepicker' + +import {useTheme, atoms} from '#/alf' +import {Text} from '#/components/Typography' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import * as TextField from '#/components/forms/TextField' +import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' + +import {DateFieldProps} from '#/components/forms/DateField/types' +import { + localizeDate, + toSimpleDateString, +} from '#/components/forms/DateField/utils' + +export * as utils from '#/components/forms/DateField/utils' +export const Label = TextField.Label + +export function DateField({ + value, + onChangeDate, + label, + isInvalid, + testID, +}: DateFieldProps) { + const t = useTheme() + const [open, setOpen] = React.useState(false) + const { + state: pressed, + onIn: onPressIn, + onOut: onPressOut, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + + const {chromeFocus, chromeError, chromeErrorHover} = + TextField.useSharedInputStyles() + + const onChangeInternal = React.useCallback< + Required<DateTimePickerProps>['onChange'] + >( + (_event, date) => { + setOpen(false) + + if (date) { + const formatted = toSimpleDateString(date) + onChangeDate(formatted) + } + }, + [onChangeDate, setOpen], + ) + + return ( + <View style={[atoms.relative, atoms.w_full]}> + <Pressable + aria-label={label} + accessibilityLabel={label} + accessibilityHint={undefined} + onPress={() => setOpen(true)} + onPressIn={onPressIn} + onPressOut={onPressOut} + onFocus={onFocus} + onBlur={onBlur} + style={[ + { + paddingTop: 16, + paddingBottom: 16, + borderColor: 'transparent', + borderWidth: 2, + }, + atoms.flex_row, + atoms.flex_1, + atoms.w_full, + atoms.px_lg, + atoms.rounded_sm, + t.atoms.bg_contrast_50, + focused || pressed ? chromeFocus : {}, + isInvalid ? chromeError : {}, + isInvalid && (focused || pressed) ? chromeErrorHover : {}, + ]}> + <TextField.Icon icon={CalendarDays} /> + + <Text + style={[atoms.text_md, atoms.pl_xs, t.atoms.text, {paddingTop: 3}]}> + {localizeDate(value)} + </Text> + </Pressable> + + {open && ( + <DateTimePicker + aria-label={label} + accessibilityLabel={label} + accessibilityHint={undefined} + testID={`${testID}-datepicker`} + mode="date" + timeZoneName={'Etc/UTC'} + display="spinner" + // @ts-ignore applies in iOS only -prf + themeVariant={t.name === 'dark' ? 'dark' : 'light'} + value={new Date(value)} + onChange={onChangeInternal} + /> + )} + </View> + ) +} diff --git a/src/components/forms/DateField/index.tsx b/src/components/forms/DateField/index.tsx new file mode 100644 index 000000000..c359a9d46 --- /dev/null +++ b/src/components/forms/DateField/index.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import {View} from 'react-native' +import DateTimePicker, { + DateTimePickerEvent, +} from '@react-native-community/datetimepicker' + +import {useTheme, atoms} from '#/alf' +import * as TextField from '#/components/forms/TextField' +import {toSimpleDateString} from '#/components/forms/DateField/utils' +import {DateFieldProps} from '#/components/forms/DateField/types' + +export * as utils from '#/components/forms/DateField/utils' +export const Label = TextField.Label + +/** + * Date-only input. Accepts a date in the format YYYY-MM-DD, and reports date + * changes in the same format. + * + * For dates of unknown format, convert with the + * `utils.toSimpleDateString(Date)` export of this file. + */ +export function DateField({ + value, + onChangeDate, + testID, + label, +}: DateFieldProps) { + const t = useTheme() + + const onChangeInternal = React.useCallback( + (event: DateTimePickerEvent, date: Date | undefined) => { + if (date) { + const formatted = toSimpleDateString(date) + onChangeDate(formatted) + } + }, + [onChangeDate], + ) + + return ( + <View style={[atoms.relative, atoms.w_full]}> + <DateTimePicker + aria-label={label} + accessibilityLabel={label} + accessibilityHint={undefined} + testID={`${testID}-datepicker`} + mode="date" + timeZoneName={'Etc/UTC'} + display="spinner" + themeVariant={t.name === 'dark' ? 'dark' : 'light'} + value={new Date(value)} + onChange={onChangeInternal} + /> + </View> + ) +} diff --git a/src/components/forms/DateField/index.web.tsx b/src/components/forms/DateField/index.web.tsx new file mode 100644 index 000000000..32f38a5d1 --- /dev/null +++ b/src/components/forms/DateField/index.web.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import {TextInput, TextInputProps, StyleSheet} from 'react-native' +// @ts-ignore +import {unstable_createElement} from 'react-native-web' + +import * as TextField from '#/components/forms/TextField' +import {toSimpleDateString} from '#/components/forms/DateField/utils' +import {DateFieldProps} from '#/components/forms/DateField/types' + +export * as utils from '#/components/forms/DateField/utils' +export const Label = TextField.Label + +const InputBase = React.forwardRef<HTMLInputElement, TextInputProps>( + ({style, ...props}, ref) => { + return unstable_createElement('input', { + ...props, + ref, + type: 'date', + style: [ + StyleSheet.flatten(style), + { + background: 'transparent', + border: 0, + }, + ], + }) + }, +) + +InputBase.displayName = 'InputBase' + +const Input = TextField.createInput(InputBase as unknown as typeof TextInput) + +export function DateField({ + value, + onChangeDate, + label, + isInvalid, + testID, +}: DateFieldProps) { + const handleOnChange = React.useCallback( + (e: any) => { + const date = e.target.valueAsDate || e.target.value + + if (date) { + const formatted = toSimpleDateString(date) + onChangeDate(formatted) + } + }, + [onChangeDate], + ) + + return ( + <TextField.Root isInvalid={isInvalid}> + <Input + value={value} + label={label} + onChange={handleOnChange} + onChangeText={() => {}} + testID={testID} + /> + </TextField.Root> + ) +} diff --git a/src/components/forms/DateField/types.ts b/src/components/forms/DateField/types.ts new file mode 100644 index 000000000..129f5672d --- /dev/null +++ b/src/components/forms/DateField/types.ts @@ -0,0 +1,7 @@ +export type DateFieldProps = { + value: string + onChangeDate: (date: string) => void + label: string + isInvalid?: boolean + testID?: string +} diff --git a/src/components/forms/DateField/utils.ts b/src/components/forms/DateField/utils.ts new file mode 100644 index 000000000..c787272fe --- /dev/null +++ b/src/components/forms/DateField/utils.ts @@ -0,0 +1,16 @@ +import {getLocales} from 'expo-localization' + +const LOCALE = getLocales()[0] + +// we need the date in the form yyyy-MM-dd to pass to the input +export function toSimpleDateString(date: Date | string): string { + const _date = typeof date === 'string' ? new Date(date) : date + return _date.toISOString().split('T')[0] +} + +export function localizeDate(date: Date | string): string { + const _date = typeof date === 'string' ? new Date(date) : date + return new Intl.DateTimeFormat(LOCALE.languageTag, { + timeZone: 'UTC', + }).format(_date) +} diff --git a/src/components/forms/InputGroup.tsx b/src/components/forms/InputGroup.tsx new file mode 100644 index 000000000..6908d4df8 --- /dev/null +++ b/src/components/forms/InputGroup.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms, useTheme} from '#/alf' + +/** + * NOT FINISHED, just here as a reference + */ +export function InputGroup(props: React.PropsWithChildren<{}>) { + const t = useTheme() + const children = React.Children.toArray(props.children) + const total = children.length + return ( + <View style={[atoms.w_full]}> + {children.map((child, i) => { + return React.isValidElement(child) ? ( + <React.Fragment key={i}> + {i > 0 ? ( + <View + style={[atoms.border_b, {borderColor: t.palette.contrast_500}]} + /> + ) : null} + {React.cloneElement(child, { + // @ts-ignore + style: [ + ...(Array.isArray(child.props?.style) + ? child.props.style + : [child.props.style || {}]), + { + borderTopLeftRadius: i > 0 ? 0 : undefined, + borderTopRightRadius: i > 0 ? 0 : undefined, + borderBottomLeftRadius: i < total - 1 ? 0 : undefined, + borderBottomRightRadius: i < total - 1 ? 0 : undefined, + borderBottomWidth: i < total - 1 ? 0 : undefined, + }, + ], + })} + </React.Fragment> + ) : null + })} + </View> + ) +} diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx new file mode 100644 index 000000000..1ee58303a --- /dev/null +++ b/src/components/forms/TextField.tsx @@ -0,0 +1,334 @@ +import React from 'react' +import { + View, + TextInput, + TextInputProps, + TextStyle, + ViewStyle, + Pressable, + StyleSheet, + AccessibilityProps, +} from 'react-native' + +import {HITSLOP_20} from 'lib/constants' +import {isWeb} from '#/platform/detection' +import {useTheme, atoms as a, web, tokens, android} from '#/alf' +import {Text} from '#/components/Typography' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {Props as SVGIconProps} from '#/components/icons/common' + +const Context = React.createContext<{ + inputRef: React.RefObject<TextInput> | null + isInvalid: boolean + hovered: boolean + onHoverIn: () => void + onHoverOut: () => void + focused: boolean + onFocus: () => void + onBlur: () => void +}>({ + inputRef: null, + isInvalid: false, + hovered: false, + onHoverIn: () => {}, + onHoverOut: () => {}, + focused: false, + onFocus: () => {}, + onBlur: () => {}, +}) + +export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}> + +export function Root({children, isInvalid = false}: RootProps) { + const inputRef = React.useRef<TextInput>(null) + const rootRef = React.useRef<View>(null) + const { + state: hovered, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + + const context = React.useMemo( + () => ({ + inputRef, + hovered, + onHoverIn, + onHoverOut, + focused, + onFocus, + onBlur, + isInvalid, + }), + [ + inputRef, + hovered, + onHoverIn, + onHoverOut, + focused, + onFocus, + onBlur, + isInvalid, + ], + ) + + React.useLayoutEffect(() => { + const root = rootRef.current + if (!root || !isWeb) return + // @ts-ignore web only + root.tabIndex = -1 + }, []) + + return ( + <Context.Provider value={context}> + <Pressable + accessibilityRole="button" + ref={rootRef} + role="none" + style={[ + a.flex_row, + a.align_center, + a.relative, + a.w_full, + a.px_md, + { + paddingVertical: 14, + }, + ]} + // onPressIn/out don't work on android web + onPress={() => inputRef.current?.focus()} + onHoverIn={onHoverIn} + onHoverOut={onHoverOut}> + {children} + </Pressable> + </Context.Provider> + ) +} + +export function useSharedInputStyles() { + const t = useTheme() + return React.useMemo(() => { + const hover: ViewStyle[] = [ + { + borderColor: t.palette.contrast_100, + }, + ] + const focus: ViewStyle[] = [ + { + backgroundColor: t.palette.contrast_50, + borderColor: t.palette.primary_500, + }, + ] + const error: ViewStyle[] = [ + { + backgroundColor: + t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900, + borderColor: + t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800, + }, + ] + const errorHover: ViewStyle[] = [ + { + backgroundColor: + t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900, + borderColor: tokens.color.red_500, + }, + ] + + return { + chromeHover: StyleSheet.flatten(hover), + chromeFocus: StyleSheet.flatten(focus), + chromeError: StyleSheet.flatten(error), + chromeErrorHover: StyleSheet.flatten(errorHover), + } + }, [t]) +} + +export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & { + label: string + value: string + onChangeText: (value: string) => void + isInvalid?: boolean +} + +export function createInput(Component: typeof TextInput) { + return function Input({ + label, + placeholder, + value, + onChangeText, + isInvalid, + ...rest + }: InputProps) { + const t = useTheme() + const ctx = React.useContext(Context) + const withinRoot = Boolean(ctx.inputRef) + + const {chromeHover, chromeFocus, chromeError, chromeErrorHover} = + useSharedInputStyles() + + if (!withinRoot) { + return ( + <Root isInvalid={isInvalid}> + <Input + label={label} + placeholder={placeholder} + value={value} + onChangeText={onChangeText} + isInvalid={isInvalid} + {...rest} + /> + </Root> + ) + } + + return ( + <> + <Component + accessibilityHint={undefined} + {...rest} + aria-label={label} + accessibilityLabel={label} + ref={ctx.inputRef} + value={value} + onChangeText={onChangeText} + onFocus={ctx.onFocus} + onBlur={ctx.onBlur} + placeholder={placeholder || label} + placeholderTextColor={t.palette.contrast_500} + hitSlop={HITSLOP_20} + style={[ + a.relative, + a.z_20, + a.flex_1, + a.text_md, + t.atoms.text, + a.px_xs, + android({ + paddingBottom: 2, + }), + { + lineHeight: a.text_md.lineHeight * 1.1875, + textAlignVertical: rest.multiline ? 'top' : undefined, + minHeight: rest.multiline ? 60 : undefined, + }, + ]} + /> + + <View + style={[ + a.z_10, + a.absolute, + a.inset_0, + a.rounded_sm, + t.atoms.bg_contrast_25, + {borderColor: 'transparent', borderWidth: 2}, + ctx.hovered ? chromeHover : {}, + ctx.focused ? chromeFocus : {}, + ctx.isInvalid || isInvalid ? chromeError : {}, + (ctx.isInvalid || isInvalid) && (ctx.hovered || ctx.focused) + ? chromeErrorHover + : {}, + ]} + /> + </> + ) + } +} + +export const Input = createInput(TextInput) + +export function Label({children}: React.PropsWithChildren<{}>) { + const t = useTheme() + return ( + <Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_600, a.mb_sm]}> + {children} + </Text> + ) +} + +export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) { + const t = useTheme() + const ctx = React.useContext(Context) + const {hover, focus, errorHover, errorFocus} = React.useMemo(() => { + const hover: TextStyle[] = [ + { + color: t.palette.contrast_800, + }, + ] + const focus: TextStyle[] = [ + { + color: t.palette.primary_500, + }, + ] + const errorHover: TextStyle[] = [ + { + color: t.palette.negative_500, + }, + ] + const errorFocus: TextStyle[] = [ + { + color: t.palette.negative_500, + }, + ] + + return { + hover, + focus, + errorHover, + errorFocus, + } + }, [t]) + + return ( + <View style={[a.z_20, a.pr_xs]}> + <Comp + size="md" + style={[ + {color: t.palette.contrast_500, pointerEvents: 'none'}, + ctx.hovered ? hover : {}, + ctx.focused ? focus : {}, + ctx.isInvalid && ctx.hovered ? errorHover : {}, + ctx.isInvalid && ctx.focused ? errorFocus : {}, + ]} + /> + </View> + ) +} + +export function Suffix({ + children, + label, + accessibilityHint, +}: React.PropsWithChildren<{ + label: string + accessibilityHint?: AccessibilityProps['accessibilityHint'] +}>) { + const t = useTheme() + const ctx = React.useContext(Context) + return ( + <Text + aria-label={label} + accessibilityLabel={label} + accessibilityHint={accessibilityHint} + style={[ + a.z_20, + a.pr_sm, + a.text_md, + t.atoms.text_contrast_400, + { + pointerEvents: 'none', + }, + web({ + marginTop: -2, + }), + ctx.hovered || ctx.focused + ? { + color: t.palette.contrast_800, + } + : {}, + ]}> + {children} + </Text> + ) +} diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx new file mode 100644 index 000000000..ad82bdff5 --- /dev/null +++ b/src/components/forms/Toggle.tsx @@ -0,0 +1,473 @@ +import React from 'react' +import {Pressable, View, ViewStyle} from 'react-native' + +import {HITSLOP_10} from 'lib/constants' +import {useTheme, atoms as a, web, native} from '#/alf' +import {Text} from '#/components/Typography' +import {useInteractionState} from '#/components/hooks/useInteractionState' + +export type ItemState = { + name: string + selected: boolean + disabled: boolean + isInvalid: boolean + hovered: boolean + pressed: boolean + focused: boolean +} + +const ItemContext = React.createContext<ItemState>({ + name: '', + selected: false, + disabled: false, + isInvalid: false, + hovered: false, + pressed: false, + focused: false, +}) + +const GroupContext = React.createContext<{ + values: string[] + disabled: boolean + type: 'radio' | 'checkbox' + maxSelectionsReached: boolean + setFieldValue: (props: {name: string; value: boolean}) => void +}>({ + type: 'checkbox', + values: [], + disabled: false, + maxSelectionsReached: false, + setFieldValue: () => {}, +}) + +export type GroupProps = React.PropsWithChildren<{ + type?: 'radio' | 'checkbox' + values: string[] + maxSelections?: number + disabled?: boolean + onChange: (value: string[]) => void + label: string +}> + +export type ItemProps = { + type?: 'radio' | 'checkbox' + name: string + label: string + value?: boolean + disabled?: boolean + onChange?: (selected: boolean) => void + isInvalid?: boolean + style?: (state: ItemState) => ViewStyle + children: ((props: ItemState) => React.ReactNode) | React.ReactNode +} + +export function useItemContext() { + return React.useContext(ItemContext) +} + +export function Group({ + children, + values: providedValues, + onChange, + disabled = false, + type = 'checkbox', + maxSelections, + label, +}: GroupProps) { + const groupRole = type === 'radio' ? 'radiogroup' : undefined + const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues + const [maxReached, setMaxReached] = React.useState(false) + + const setFieldValue = React.useCallback< + (props: {name: string; value: boolean}) => void + >( + ({name, value}) => { + if (type === 'checkbox') { + const pruned = values.filter(v => v !== name) + const next = value ? pruned.concat(name) : pruned + onChange(next) + } else { + onChange([name]) + } + }, + [type, onChange, values], + ) + + React.useEffect(() => { + if (type === 'checkbox') { + if ( + maxSelections && + values.length >= maxSelections && + maxReached === false + ) { + setMaxReached(true) + } else if ( + maxSelections && + values.length < maxSelections && + maxReached === true + ) { + setMaxReached(false) + } + } + }, [type, values.length, maxSelections, maxReached, setMaxReached]) + + const context = React.useMemo( + () => ({ + values, + type, + disabled, + maxSelectionsReached: maxReached, + setFieldValue, + }), + [values, disabled, type, maxReached, setFieldValue], + ) + + return ( + <GroupContext.Provider value={context}> + <View + role={groupRole} + {...(groupRole === 'radiogroup' + ? { + 'aria-label': label, + accessibilityLabel: label, + accessibilityRole: groupRole, + } + : {})}> + {children} + </View> + </GroupContext.Provider> + ) +} + +export function Item({ + children, + name, + value = false, + disabled: itemDisabled = false, + onChange, + isInvalid, + style, + type = 'checkbox', + label, + ...rest +}: ItemProps) { + const { + values: selectedValues, + type: groupType, + disabled: groupDisabled, + setFieldValue, + maxSelectionsReached, + } = React.useContext(GroupContext) + const { + state: hovered, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + const { + state: pressed, + onIn: onPressIn, + onOut: onPressOut, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + + const role = groupType === 'radio' ? 'radio' : type + const selected = selectedValues.includes(name) || !!value + const disabled = + groupDisabled || itemDisabled || (!selected && maxSelectionsReached) + + const onPress = React.useCallback(() => { + const next = !selected + setFieldValue({name, value: next}) + onChange?.(next) + }, [name, selected, onChange, setFieldValue]) + + const state = React.useMemo( + () => ({ + name, + selected, + disabled: disabled ?? false, + isInvalid: isInvalid ?? false, + hovered, + pressed, + focused, + }), + [name, selected, disabled, hovered, pressed, focused, isInvalid], + ) + + return ( + <ItemContext.Provider value={state}> + <Pressable + accessibilityHint={undefined} // optional + hitSlop={HITSLOP_10} + {...rest} + disabled={disabled} + aria-disabled={disabled ?? false} + aria-checked={selected} + aria-invalid={isInvalid} + aria-label={label} + role={role} + accessibilityRole={role} + accessibilityState={{ + disabled: disabled ?? false, + selected: selected, + }} + accessibilityLabel={label} + onPress={onPress} + onHoverIn={onHoverIn} + onHoverOut={onHoverOut} + onPressIn={onPressIn} + onPressOut={onPressOut} + onFocus={onFocus} + onBlur={onBlur} + style={[ + a.flex_row, + a.align_center, + a.gap_sm, + focused ? web({outline: 'none'}) : {}, + style?.(state), + ]}> + {typeof children === 'function' ? children(state) : children} + </Pressable> + </ItemContext.Provider> + ) +} + +export function Label({children}: React.PropsWithChildren<{}>) { + const t = useTheme() + const {disabled} = useItemContext() + return ( + <Text + style={[ + a.font_bold, + { + userSelect: 'none', + color: disabled ? t.palette.contrast_400 : t.palette.contrast_600, + }, + native({ + paddingTop: 3, + }), + ]}> + {children} + </Text> + ) +} + +// TODO(eric) refactor to memoize styles without knowledge of state +export function createSharedToggleStyles({ + theme: t, + hovered, + focused, + selected, + disabled, + isInvalid, +}: { + theme: ReturnType<typeof useTheme> + selected: boolean + hovered: boolean + focused: boolean + disabled: boolean + isInvalid: boolean +}) { + const base: ViewStyle[] = [] + const baseHover: ViewStyle[] = [] + const indicator: ViewStyle[] = [] + + if (selected) { + base.push({ + backgroundColor: + t.name === 'light' ? t.palette.primary_25 : t.palette.primary_900, + borderColor: t.palette.primary_500, + }) + + if (hovered || focused) { + baseHover.push({ + backgroundColor: + t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800, + borderColor: + t.name === 'light' ? t.palette.primary_600 : t.palette.primary_400, + }) + } + } else { + if (hovered || focused) { + baseHover.push({ + backgroundColor: + t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100, + borderColor: t.palette.contrast_500, + }) + } + } + + if (isInvalid) { + base.push({ + backgroundColor: + t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900, + borderColor: + t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800, + }) + + if (hovered || focused) { + baseHover.push({ + backgroundColor: + t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900, + borderColor: t.palette.negative_500, + }) + } + } + + if (disabled) { + base.push({ + backgroundColor: t.palette.contrast_100, + borderColor: t.palette.contrast_400, + }) + } + + return { + baseStyles: base, + baseHoverStyles: disabled ? [] : baseHover, + indicatorStyles: indicator, + } +} + +export function Checkbox() { + const t = useTheme() + const {selected, hovered, focused, disabled, isInvalid} = useItemContext() + const {baseStyles, baseHoverStyles, indicatorStyles} = + createSharedToggleStyles({ + theme: t, + hovered, + focused, + selected, + disabled, + isInvalid, + }) + return ( + <View + style={[ + a.justify_center, + a.align_center, + a.border, + a.rounded_xs, + t.atoms.border_contrast, + { + height: 20, + width: 20, + }, + baseStyles, + hovered || focused ? baseHoverStyles : {}, + ]}> + {selected ? ( + <View + style={[ + a.absolute, + a.rounded_2xs, + {height: 12, width: 12}, + selected + ? { + backgroundColor: t.palette.primary_500, + } + : {}, + indicatorStyles, + ]} + /> + ) : null} + </View> + ) +} + +export function Switch() { + const t = useTheme() + const {selected, hovered, focused, disabled, isInvalid} = useItemContext() + const {baseStyles, baseHoverStyles, indicatorStyles} = + createSharedToggleStyles({ + theme: t, + hovered, + focused, + selected, + disabled, + isInvalid, + }) + return ( + <View + style={[ + a.relative, + a.border, + a.rounded_full, + t.atoms.bg, + t.atoms.border_contrast, + { + height: 20, + width: 30, + }, + baseStyles, + hovered || focused ? baseHoverStyles : {}, + ]}> + <View + style={[ + a.absolute, + a.rounded_full, + { + height: 12, + width: 12, + top: 3, + left: 3, + backgroundColor: t.palette.contrast_400, + }, + selected + ? { + backgroundColor: t.palette.primary_500, + left: 13, + } + : {}, + indicatorStyles, + ]} + /> + </View> + ) +} + +export function Radio() { + const t = useTheme() + const {selected, hovered, focused, disabled, isInvalid} = + React.useContext(ItemContext) + const {baseStyles, baseHoverStyles, indicatorStyles} = + createSharedToggleStyles({ + theme: t, + hovered, + focused, + selected, + disabled, + isInvalid, + }) + return ( + <View + style={[ + a.justify_center, + a.align_center, + a.border, + a.rounded_full, + t.atoms.border_contrast, + { + height: 20, + width: 20, + }, + baseStyles, + hovered || focused ? baseHoverStyles : {}, + ]}> + {selected ? ( + <View + style={[ + a.absolute, + a.rounded_full, + {height: 12, width: 12}, + selected + ? { + backgroundColor: t.palette.primary_500, + } + : {}, + indicatorStyles, + ]} + /> + ) : null} + </View> + ) +} diff --git a/src/components/forms/ToggleButton.tsx b/src/components/forms/ToggleButton.tsx new file mode 100644 index 000000000..615fedae8 --- /dev/null +++ b/src/components/forms/ToggleButton.tsx @@ -0,0 +1,124 @@ +import React from 'react' +import {View, AccessibilityProps, TextStyle, ViewStyle} from 'react-native' + +import {atoms as a, useTheme, native} from '#/alf' +import {Text} from '#/components/Typography' + +import * as Toggle from '#/components/forms/Toggle' + +export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> & + AccessibilityProps & + React.PropsWithChildren<{}> + +export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & { + multiple?: boolean +} + +export function Group({children, multiple, ...props}: GroupProps) { + const t = useTheme() + return ( + <Toggle.Group type={multiple ? 'checkbox' : 'radio'} {...props}> + <View + style={[ + a.flex_row, + a.border, + a.rounded_sm, + a.overflow_hidden, + t.atoms.border, + ]}> + {children} + </View> + </Toggle.Group> + ) +} + +export function Button({children, ...props}: ItemProps) { + return ( + <Toggle.Item {...props}> + <ButtonInner>{children}</ButtonInner> + </Toggle.Item> + ) +} + +function ButtonInner({children}: React.PropsWithChildren<{}>) { + const t = useTheme() + const state = Toggle.useItemContext() + + const {baseStyles, hoverStyles, activeStyles, textStyles} = + React.useMemo(() => { + const base: ViewStyle[] = [] + const hover: ViewStyle[] = [] + const active: ViewStyle[] = [] + const text: TextStyle[] = [] + + hover.push( + t.name === 'light' ? t.atoms.bg_contrast_100 : t.atoms.bg_contrast_25, + ) + + if (state.selected) { + active.push({ + backgroundColor: t.palette.contrast_800, + }) + text.push(t.atoms.text_inverted) + hover.push({ + backgroundColor: t.palette.contrast_800, + }) + + if (state.disabled) { + active.push({ + backgroundColor: t.palette.contrast_500, + }) + } + } + + if (state.disabled) { + base.push({ + backgroundColor: t.palette.contrast_100, + }) + text.push({ + opacity: 0.5, + }) + } + + return { + baseStyles: base, + hoverStyles: hover, + activeStyles: active, + textStyles: text, + } + }, [t, state]) + + return ( + <View + style={[ + { + borderLeftWidth: 1, + marginLeft: -1, + }, + a.px_lg, + a.py_md, + native({ + paddingTop: 14, + }), + t.atoms.bg, + t.atoms.border, + baseStyles, + activeStyles, + (state.hovered || state.focused || state.pressed) && hoverStyles, + ]}> + {typeof children === 'string' ? ( + <Text + style={[ + a.text_center, + a.font_bold, + t.atoms.text_contrast_500, + textStyles, + ]}> + {children} + </Text> + ) : ( + children + )} + </View> + ) +} diff --git a/src/components/hooks/useInteractionState.ts b/src/components/hooks/useInteractionState.ts new file mode 100644 index 000000000..653b1c10e --- /dev/null +++ b/src/components/hooks/useInteractionState.ts @@ -0,0 +1,21 @@ +import React from 'react' + +export function useInteractionState() { + const [state, setState] = React.useState(false) + + const onIn = React.useCallback(() => { + setState(true) + }, [setState]) + const onOut = React.useCallback(() => { + setState(false) + }, [setState]) + + return React.useMemo( + () => ({ + state, + onIn, + onOut, + }), + [state, onIn, onOut], + ) +} diff --git a/src/components/icons/ArrowTopRight.tsx b/src/components/icons/ArrowTopRight.tsx new file mode 100644 index 000000000..92ad30a12 --- /dev/null +++ b/src/components/icons/ArrowTopRight.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M8 6a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v9a1 1 0 1 1-2 0V8.414l-9.793 9.793a1 1 0 0 1-1.414-1.414L15.586 7H9a1 1 0 0 1-1-1Z', +}) diff --git a/src/components/icons/CalendarDays.tsx b/src/components/icons/CalendarDays.tsx new file mode 100644 index 000000000..72cc48e26 --- /dev/null +++ b/src/components/icons/CalendarDays.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const CalendarDays_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4 3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H4Zm1 16V9h14v10H5ZM5 7h14V5H5v2Zm3 10.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM17.25 12a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0ZM12 13.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM9.25 12a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0ZM12 17.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z', +}) diff --git a/src/components/icons/ColorPalette.tsx b/src/components/icons/ColorPalette.tsx new file mode 100644 index 000000000..157fa7fa1 --- /dev/null +++ b/src/components/icons/ColorPalette.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ColorPalette_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4 12c0-4.09 3.527-7.5 8-7.5s8 3.41 8 7.5c0 1.579-.419 2.056-.708 2.236-.388.241-1.031.286-2.058.153-.33-.043-.652-.096-.991-.152a65.905 65.905 0 0 0-.531-.087c-.52-.081-1.077-.156-1.61-.164-1.065-.016-2.336.245-2.996 1.567-.418.834-.295 1.67-.078 2.314.18.534.47 1.055.683 1.437v.001l.097.175.01.018C7.432 19.407 4 16.033 4 12Zm8-9.5C6.532 2.5 2 6.7 2 12s4.532 9.5 10 9.5c.401 0 .812-.04 1.166-.193.41-.176.761-.517.866-1.028.085-.416-.03-.796-.118-1.029a5.981 5.981 0 0 0-.351-.73l-.12-.215c-.215-.392-.403-.73-.52-1.078-.13-.387-.111-.614-.029-.78.146-.291.404-.473 1.178-.461.385.005.825.06 1.329.14.15.023.308.05.47.077.36.059.742.122 1.105.17 1.021.132 2.325.213 3.373-.439C21.496 15.22 22 13.874 22 12c0-5.3-4.532-9.5-10-9.5Zm3.5 8.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM9 12.25a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm1.5-2.75a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z', +}) diff --git a/src/components/icons/Globe.tsx b/src/components/icons/Globe.tsx new file mode 100644 index 000000000..f81b3ff7a --- /dev/null +++ b/src/components/icons/Globe.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Globe_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4.062 11h2.961c.103-2.204.545-4.218 1.235-5.77.06-.136.123-.269.188-.399A8.007 8.007 0 0 0 4.062 11ZM12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 2c-.227 0-.518.1-.868.432-.354.337-.719.872-1.047 1.61-.561 1.263-.958 2.991-1.06 4.958h5.95c-.102-1.967-.499-3.695-1.06-4.958-.328-.738-.693-1.273-1.047-1.61C12.518 4.099 12.227 4 12 4Zm4.977 7c-.103-2.204-.545-4.218-1.235-5.77a9.78 9.78 0 0 0-.188-.399A8.006 8.006 0 0 1 19.938 11h-2.961Zm-2.003 2H9.026c.101 1.966.498 3.695 1.06 4.958.327.738.692 1.273 1.046 1.61.35.333.641.432.868.432.227 0 .518-.1.868-.432.354-.337.719-.872 1.047-1.61.561-1.263.958-2.991 1.06-4.958Zm.58 6.169c.065-.13.128-.263.188-.399.69-1.552 1.132-3.566 1.235-5.77h2.961a8.006 8.006 0 0 1-4.384 6.169Zm-7.108 0a9.877 9.877 0 0 1-.188-.399c-.69-1.552-1.132-3.566-1.235-5.77H4.062a8.006 8.006 0 0 0 4.384 6.169Z', +}) diff --git a/src/components/icons/TEMPLATE.tsx b/src/components/icons/TEMPLATE.tsx new file mode 100644 index 000000000..9fc147037 --- /dev/null +++ b/src/components/icons/TEMPLATE.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import Svg, {Path} from 'react-native-svg' + +import {useCommonSVGProps, Props} from '#/components/icons/common' + +export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef( + function LogoImpl(props: Props, ref) { + const {fill, size, style, ...rest} = useCommonSVGProps(props) + + return ( + <Svg + fill="none" + {...rest} + // @ts-ignore it's fiiiiine + ref={ref} + viewBox="0 0 24 24" + width={size} + height={size} + style={[style]}> + <Path + fill={fill} + fillRule="evenodd" + clipRule="evenodd" + d="M4.062 11h2.961c.103-2.204.545-4.218 1.235-5.77.06-.136.123-.269.188-.399A8.007 8.007 0 0 0 4.062 11ZM12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 2c-.227 0-.518.1-.868.432-.354.337-.719.872-1.047 1.61-.561 1.263-.958 2.991-1.06 4.958h5.95c-.102-1.967-.499-3.695-1.06-4.958-.328-.738-.693-1.273-1.047-1.61C12.518 4.099 12.227 4 12 4Zm4.977 7c-.103-2.204-.545-4.218-1.235-5.77a9.78 9.78 0 0 0-.188-.399A8.006 8.006 0 0 1 19.938 11h-2.961Zm-2.003 2H9.026c.101 1.966.498 3.695 1.06 4.958.327.738.692 1.273 1.046 1.61.35.333.641.432.868.432.227 0 .518-.1.868-.432.354-.337.719-.872 1.047-1.61.561-1.263.958-2.991 1.06-4.958Zm.58 6.169c.065-.13.128-.263.188-.399.69-1.552 1.132-3.566 1.235-5.77h2.961a8.006 8.006 0 0 1-4.384 6.169Zm-7.108 0a9.877 9.877 0 0 1-.188-.399c-.69-1.552-1.132-3.566-1.235-5.77H4.062a8.006 8.006 0 0 0 4.384 6.169Z" + /> + </Svg> + ) + }, +) + +export function createSinglePathSVG({path}: {path: string}) { + return React.forwardRef<Svg, Props>(function LogoImpl(props, ref) { + const {fill, size, style, ...rest} = useCommonSVGProps(props) + + return ( + <Svg + fill="none" + {...rest} + ref={ref} + viewBox="0 0 24 24" + width={size} + height={size} + style={[style]}> + <Path fill={fill} fillRule="evenodd" clipRule="evenodd" d={path} /> + </Svg> + ) + }) +} diff --git a/src/components/icons/common.ts b/src/components/icons/common.ts new file mode 100644 index 000000000..9e9f15c4d --- /dev/null +++ b/src/components/icons/common.ts @@ -0,0 +1,32 @@ +import {StyleSheet, TextProps} from 'react-native' +import type {SvgProps, PathProps} from 'react-native-svg' + +import {tokens} from '#/alf' + +export type Props = { + fill?: PathProps['fill'] + style?: TextProps['style'] + size?: keyof typeof sizes +} & Omit<SvgProps, 'style' | 'size'> + +export const sizes = { + xs: 12, + sm: 16, + md: 20, + lg: 24, + xl: 28, +} + +export function useCommonSVGProps(props: Props) { + const {fill, size, ...rest} = props + const style = StyleSheet.flatten(rest.style) + const _fill = fill || style?.color || tokens.color.blue_500 + const _size = Number(size ? sizes[size] : rest.width || sizes.md) + + return { + fill: _fill, + size: _size, + style, + ...rest, + } +} diff --git a/src/state/dialogs/index.tsx b/src/state/dialogs/index.tsx new file mode 100644 index 000000000..4cafaa086 --- /dev/null +++ b/src/state/dialogs/index.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import {DialogControlProps} from '#/components/Dialog' + +const DialogContext = React.createContext<{ + activeDialogs: React.MutableRefObject< + Map<string, React.MutableRefObject<DialogControlProps>> + > +}>({ + activeDialogs: { + current: new Map(), + }, +}) + +const DialogControlContext = React.createContext<{ + closeAllDialogs(): void +}>({ + closeAllDialogs: () => {}, +}) + +export function useDialogStateContext() { + return React.useContext(DialogContext) +} + +export function useDialogStateControlContext() { + return React.useContext(DialogControlContext) +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + const activeDialogs = React.useRef< + Map<string, React.MutableRefObject<DialogControlProps>> + >(new Map()) + const closeAllDialogs = React.useCallback(() => { + activeDialogs.current.forEach(dialog => dialog.current.close()) + }, []) + const context = React.useMemo(() => ({activeDialogs}), []) + const controls = React.useMemo(() => ({closeAllDialogs}), [closeAllDialogs]) + return ( + <DialogContext.Provider value={context}> + <DialogControlContext.Provider value={controls}> + {children} + </DialogControlContext.Provider> + </DialogContext.Provider> + ) +} diff --git a/src/view/com/Button.tsx b/src/view/com/Button.tsx deleted file mode 100644 index d1f70d4ae..000000000 --- a/src/view/com/Button.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import React from 'react' -import {Pressable, Text, PressableProps, TextProps} from 'react-native' -import * as tokens from '#/alf/tokens' -import {atoms} from '#/alf' - -export type ButtonType = - | 'primary' - | 'secondary' - | 'tertiary' - | 'positive' - | 'negative' -export type ButtonSize = 'small' | 'large' - -export type VariantProps = { - type?: ButtonType - size?: ButtonSize -} -type ButtonState = { - pressed: boolean - hovered: boolean - focused: boolean -} -export type ButtonProps = Omit<PressableProps, 'children'> & - VariantProps & { - children: - | ((props: { - state: ButtonState - type?: ButtonType - size?: ButtonSize - }) => React.ReactNode) - | React.ReactNode - | string - } -export type ButtonTextProps = TextProps & VariantProps - -export function Button({children, style, type, size, ...rest}: ButtonProps) { - const {baseStyles, hoverStyles} = React.useMemo(() => { - const baseStyles = [] - const hoverStyles = [] - - switch (type) { - case 'primary': - baseStyles.push({ - backgroundColor: tokens.color.blue_500, - }) - break - case 'secondary': - baseStyles.push({ - backgroundColor: tokens.color.gray_200, - }) - hoverStyles.push({ - backgroundColor: tokens.color.gray_100, - }) - break - default: - } - - switch (size) { - case 'large': - baseStyles.push( - atoms.py_md, - atoms.px_xl, - atoms.rounded_md, - atoms.gap_sm, - ) - break - case 'small': - baseStyles.push( - atoms.py_sm, - atoms.px_md, - atoms.rounded_sm, - atoms.gap_xs, - ) - break - default: - } - - return { - baseStyles, - hoverStyles, - } - }, [type, size]) - - const [state, setState] = React.useState({ - pressed: false, - hovered: false, - focused: false, - }) - - const onPressIn = React.useCallback(() => { - setState(s => ({ - ...s, - pressed: true, - })) - }, [setState]) - const onPressOut = React.useCallback(() => { - setState(s => ({ - ...s, - pressed: false, - })) - }, [setState]) - const onHoverIn = React.useCallback(() => { - setState(s => ({ - ...s, - hovered: true, - })) - }, [setState]) - const onHoverOut = React.useCallback(() => { - setState(s => ({ - ...s, - hovered: false, - })) - }, [setState]) - const onFocus = React.useCallback(() => { - setState(s => ({ - ...s, - focused: true, - })) - }, [setState]) - const onBlur = React.useCallback(() => { - setState(s => ({ - ...s, - focused: false, - })) - }, [setState]) - - return ( - <Pressable - {...rest} - style={state => [ - atoms.flex_row, - atoms.align_center, - ...baseStyles, - ...(state.hovered ? hoverStyles : []), - typeof style === 'function' ? style(state) : style, - ]} - onPressIn={onPressIn} - onPressOut={onPressOut} - onHoverIn={onHoverIn} - onHoverOut={onHoverOut} - onFocus={onFocus} - onBlur={onBlur}> - {typeof children === 'string' ? ( - <ButtonText type={type} size={size}> - {children} - </ButtonText> - ) : typeof children === 'function' ? ( - children({state, type, size}) - ) : ( - children - )} - </Pressable> - ) -} - -export function ButtonText({ - children, - style, - type, - size, - ...rest -}: ButtonTextProps) { - const textStyles = React.useMemo(() => { - const base = [] - - switch (type) { - case 'primary': - base.push({color: tokens.color.white}) - break - case 'secondary': - base.push({ - color: tokens.color.gray_700, - }) - break - default: - } - - switch (size) { - case 'small': - base.push(atoms.text_sm, {paddingBottom: 1}) - break - case 'large': - base.push(atoms.text_md, {paddingBottom: 1}) - break - default: - } - - return base - }, [type, size]) - - return ( - <Text - {...rest} - style={[ - atoms.flex_1, - atoms.font_semibold, - atoms.text_center, - ...textStyles, - style, - ]}> - {children} - </Text> - ) -} diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index 2c5ba5dfb..9c562f67d 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -20,6 +20,11 @@ import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' import {Logo} from '#/view/icons/Logo' +import {IS_DEV} from '#/env' +import {atoms} from '#/alf' +import {Link as Link2} from '#/components/Link' +import {ColorPalette_Stroke2_Corner0_Rounded as ColorPalette} from '#/components/icons/ColorPalette' + export function FeedsTabBar( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) { @@ -68,7 +73,7 @@ export function FeedsTabBar( headerHeight.value = e.nativeEvent.layout.height }}> <View style={[pal.view, styles.topBar]}> - <View style={[pal.view]}> + <View style={[pal.view, {width: 100}]}> <TouchableOpacity testID="viewHeaderDrawerBtn" onPress={onPressAvi} @@ -88,7 +93,21 @@ export function FeedsTabBar( <View> <Logo width={30} /> </View> - <View style={[pal.view, {width: 18}]}> + <View + style={[ + atoms.flex_row, + atoms.justify_end, + atoms.align_center, + atoms.gap_md, + pal.view, + {width: 100}, + ]}> + {IS_DEV && ( + <Link2 to="/sys/debug"> + <ColorPalette size="md" /> + </Link2> + )} + {hasSession && ( <Link testID="viewHeaderHomeFeedPrefsBtn" diff --git a/src/view/icons/Logo.tsx b/src/view/icons/Logo.tsx index 15ab5a11c..9212381a9 100644 --- a/src/view/icons/Logo.tsx +++ b/src/view/icons/Logo.tsx @@ -1,4 +1,5 @@ import React from 'react' +import {StyleSheet, TextProps} from 'react-native' import Svg, { Path, Defs, @@ -14,12 +15,14 @@ const ratio = 57 / 64 type Props = { fill?: PathProps['fill'] -} & SvgProps + style?: TextProps['style'] +} & Omit<SvgProps, 'style'> export const Logo = React.forwardRef(function LogoImpl(props: Props, ref) { const {fill, ...rest} = props const gradient = fill === 'sky' - const _fill = gradient ? 'url(#sky)' : fill || colors.blue3 + const styles = StyleSheet.flatten(props.style) + const _fill = gradient ? 'url(#sky)' : fill || styles?.color || colors.blue3 // @ts-ignore it's fiiiiine const size = parseInt(rest.width || 32) return ( @@ -29,7 +32,7 @@ export const Logo = React.forwardRef(function LogoImpl(props: Props, ref) { ref={ref} viewBox="0 0 64 57" {...rest} - style={{width: size, height: size * ratio}}> + style={[{width: size, height: size * ratio}, styles]}> {gradient && ( <Defs> <LinearGradient id="sky" x1="0" y1="0" x2="0" y2="1"> diff --git a/src/view/screens/DebugNew.tsx b/src/view/screens/DebugNew.tsx deleted file mode 100644 index 0b7c5f03b..000000000 --- a/src/view/screens/DebugNew.tsx +++ /dev/null @@ -1,541 +0,0 @@ -import React from 'react' -import {View} from 'react-native' -import {CenteredView, ScrollView} from '#/view/com/util/Views' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' - -import {useSetColorMode} from '#/state/shell' -import * as tokens from '#/alf/tokens' -import {atoms as a, useTheme, useBreakpoints, ThemeProvider as Alf} from '#/alf' -import {Button, ButtonText} from '#/view/com/Button' -import {Text, H1, H2, H3, H4, H5, H6} from '#/view/com/Typography' - -function ThemeSelector() { - const setColorMode = useSetColorMode() - - return ( - <View style={[a.flex_row, a.gap_md]}> - <Button - type="secondary" - size="small" - onPress={() => setColorMode('system')}> - System - </Button> - <Button - type="secondary" - size="small" - onPress={() => setColorMode('light')}> - Light - </Button> - <Button - type="secondary" - size="small" - onPress={() => setColorMode('dark')}> - Dark - </Button> - </View> - ) -} - -function BreakpointDebugger() { - const t = useTheme() - const breakpoints = useBreakpoints() - - return ( - <View> - <H3 style={[a.pb_md]}>Breakpoint Debugger</H3> - <Text style={[a.pb_md]}> - Current breakpoint: {!breakpoints.gtMobile && <Text>mobile</Text>} - {breakpoints.gtMobile && !breakpoints.gtTablet && <Text>tablet</Text>} - {breakpoints.gtTablet && <Text>desktop</Text>} - </Text> - <Text - style={[a.p_md, t.atoms.bg_contrast_100, {fontFamily: 'monospace'}]}> - {JSON.stringify(breakpoints, null, 2)} - </Text> - </View> - ) -} - -function ThemedSection() { - const t = useTheme() - - return ( - <View style={[t.atoms.bg, a.gap_md, a.p_xl]}> - <H3 style={[a.font_bold]}>theme.atoms.text</H3> - <View style={[a.flex_1, t.atoms.border, a.border_t]} /> - <H3 style={[a.font_bold, t.atoms.text_contrast_700]}> - theme.atoms.text_contrast_700 - </H3> - <View style={[a.flex_1, t.atoms.border, a.border_t]} /> - <H3 style={[a.font_bold, t.atoms.text_contrast_500]}> - theme.atoms.text_contrast_500 - </H3> - <View style={[a.flex_1, t.atoms.border_contrast_500, a.border_t]} /> - - <View style={[a.flex_row, a.gap_md]}> - <View - style={[ - a.flex_1, - t.atoms.bg, - a.align_center, - a.justify_center, - {height: 60}, - ]}> - <Text>theme.bg</Text> - </View> - <View - style={[ - a.flex_1, - t.atoms.bg_contrast_100, - a.align_center, - a.justify_center, - {height: 60}, - ]}> - <Text>theme.bg_contrast_100</Text> - </View> - </View> - <View style={[a.flex_row, a.gap_md]}> - <View - style={[ - a.flex_1, - t.atoms.bg_contrast_200, - a.align_center, - a.justify_center, - {height: 60}, - ]}> - <Text>theme.bg_contrast_200</Text> - </View> - <View - style={[ - a.flex_1, - t.atoms.bg_contrast_300, - a.align_center, - a.justify_center, - {height: 60}, - ]}> - <Text>theme.bg_contrast_300</Text> - </View> - </View> - <View style={[a.flex_row, a.gap_md]}> - <View - style={[ - a.flex_1, - t.atoms.bg_positive, - a.align_center, - a.justify_center, - {height: 60}, - ]}> - <Text>theme.bg_positive</Text> - </View> - <View - style={[ - a.flex_1, - t.atoms.bg_negative, - a.align_center, - a.justify_center, - {height: 60}, - ]}> - <Text>theme.bg_negative</Text> - </View> - </View> - </View> - ) -} - -export function DebugScreen() { - const t = useTheme() - - return ( - <ScrollView> - <CenteredView style={[t.atoms.bg]}> - <View style={[a.p_xl, a.gap_xxl, {paddingBottom: 200}]}> - <ThemeSelector /> - - <Alf theme="light"> - <ThemedSection /> - </Alf> - <Alf theme="dark"> - <ThemedSection /> - </Alf> - - <H1>Heading 1</H1> - <H2>Heading 2</H2> - <H3>Heading 3</H3> - <H4>Heading 4</H4> - <H5>Heading 5</H5> - <H6>Heading 6</H6> - - <Text style={[a.text_xxl]}>atoms.text_xxl</Text> - <Text style={[a.text_xl]}>atoms.text_xl</Text> - <Text style={[a.text_lg]}>atoms.text_lg</Text> - <Text style={[a.text_md]}>atoms.text_md</Text> - <Text style={[a.text_sm]}>atoms.text_sm</Text> - <Text style={[a.text_xs]}>atoms.text_xs</Text> - <Text style={[a.text_xxs]}>atoms.text_xxs</Text> - - <View style={[a.gap_md, a.align_start]}> - <Button> - {({state}) => ( - <View style={[a.p_md, a.rounded_full, t.atoms.bg_contrast_300]}> - <Text>Unstyled button, state: {JSON.stringify(state)}</Text> - </View> - )} - </Button> - - <Button type="primary" size="small"> - Button - </Button> - <Button type="secondary" size="small"> - Button - </Button> - - <Button type="primary" size="large"> - Button - </Button> - <Button type="secondary" size="large"> - Button - </Button> - - <Button type="secondary" size="small"> - {({type, size}) => ( - <> - <FontAwesomeIcon icon={['fas', 'plus']} size={12} /> - <ButtonText type={type} size={size}> - With an icon - </ButtonText> - </> - )} - </Button> - <Button type="primary" size="large"> - {({state: _state, ...rest}) => ( - <> - <FontAwesomeIcon icon={['fas', 'plus']} /> - <ButtonText {...rest}>With an icon</ButtonText> - </> - )} - </Button> - </View> - - <View style={[a.gap_md]}> - <View style={[a.flex_row, a.gap_md]}> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_0}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_100}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_200}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_300}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_400}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_500}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_600}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_700}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_800}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_900}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_1000}, - ]} - /> - </View> - - <View style={[a.flex_row, a.gap_md]}> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_0}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_100}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_200}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_300}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_400}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_500}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_600}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_700}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_800}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_900}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_1000}, - ]} - /> - </View> - <View style={[a.flex_row, a.gap_md]}> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_0}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_100}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_200}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_300}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_400}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_500}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_600}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_700}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_800}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_900}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_1000}, - ]} - /> - </View> - <View style={[a.flex_row, a.gap_md]}> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.red_0}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.red_100}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.red_200}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.red_300}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.red_400}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.red_500}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.red_600}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.red_700}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.red_800}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.red_900}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.red_1000}, - ]} - /> - </View> - </View> - - <View> - <H3 style={[a.pb_md, a.font_bold]}>Spacing</H3> - - <View style={[a.gap_md]}> - <View style={[a.flex_row, a.align_center]}> - <Text style={{width: 80}}>xxs (2px)</Text> - <View style={[a.flex_1, a.pt_xxs, t.atoms.bg_contrast_300]} /> - </View> - - <View style={[a.flex_row, a.align_center]}> - <Text style={{width: 80}}>xs (4px)</Text> - <View style={[a.flex_1, a.pt_xs, t.atoms.bg_contrast_300]} /> - </View> - - <View style={[a.flex_row, a.align_center]}> - <Text style={{width: 80}}>sm (8px)</Text> - <View style={[a.flex_1, a.pt_sm, t.atoms.bg_contrast_300]} /> - </View> - - <View style={[a.flex_row, a.align_center]}> - <Text style={{width: 80}}>md (12px)</Text> - <View style={[a.flex_1, a.pt_md, t.atoms.bg_contrast_300]} /> - </View> - - <View style={[a.flex_row, a.align_center]}> - <Text style={{width: 80}}>lg (18px)</Text> - <View style={[a.flex_1, a.pt_lg, t.atoms.bg_contrast_300]} /> - </View> - - <View style={[a.flex_row, a.align_center]}> - <Text style={{width: 80}}>xl (24px)</Text> - <View style={[a.flex_1, a.pt_xl, t.atoms.bg_contrast_300]} /> - </View> - - <View style={[a.flex_row, a.align_center]}> - <Text style={{width: 80}}>xxl (32px)</Text> - <View style={[a.flex_1, a.pt_xxl, t.atoms.bg_contrast_300]} /> - </View> - </View> - </View> - - <BreakpointDebugger /> - </View> - </CenteredView> - </ScrollView> - ) -} diff --git a/src/view/screens/Storybook/Breakpoints.tsx b/src/view/screens/Storybook/Breakpoints.tsx new file mode 100644 index 000000000..1b846d517 --- /dev/null +++ b/src/view/screens/Storybook/Breakpoints.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a, useTheme, useBreakpoints} from '#/alf' +import {Text, H3} from '#/components/Typography' + +export function Breakpoints() { + const t = useTheme() + const breakpoints = useBreakpoints() + + return ( + <View> + <H3 style={[a.pb_md]}>Breakpoint Debugger</H3> + <Text style={[a.pb_md]}> + Current breakpoint: {!breakpoints.gtMobile && <Text>mobile</Text>} + {breakpoints.gtMobile && !breakpoints.gtTablet && <Text>tablet</Text>} + {breakpoints.gtTablet && <Text>desktop</Text>} + </Text> + <Text + style={[a.p_md, t.atoms.bg_contrast_100, {fontFamily: 'monospace'}]}> + {JSON.stringify(breakpoints, null, 2)} + </Text> + </View> + ) +} diff --git a/src/view/screens/Storybook/Buttons.tsx b/src/view/screens/Storybook/Buttons.tsx new file mode 100644 index 000000000..fbdc84eb4 --- /dev/null +++ b/src/view/screens/Storybook/Buttons.tsx @@ -0,0 +1,124 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a} from '#/alf' +import { + Button, + ButtonVariant, + ButtonColor, + ButtonIcon, + ButtonText, +} from '#/components/Button' +import {H1} from '#/components/Typography' +import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight' +import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' + +export function Buttons() { + return ( + <View style={[a.gap_md]}> + <H1>Buttons</H1> + + <View style={[a.flex_row, a.flex_wrap, a.gap_md, a.align_start]}> + {['primary', 'secondary', 'negative'].map(color => ( + <View key={color} style={[a.gap_md, a.align_start]}> + {['solid', 'outline', 'ghost'].map(variant => ( + <React.Fragment key={variant}> + <Button + variant={variant as ButtonVariant} + color={color as ButtonColor} + size="large" + label="Click here"> + Button + </Button> + <Button + disabled + variant={variant as ButtonVariant} + color={color as ButtonColor} + size="large" + label="Click here"> + Button + </Button> + </React.Fragment> + ))} + </View> + ))} + + <View style={[a.flex_row, a.gap_md, a.align_start]}> + <View style={[a.gap_md, a.align_start]}> + {['gradient_sky', 'gradient_midnight', 'gradient_sunrise'].map( + name => ( + <React.Fragment key={name}> + <Button + variant="gradient" + color={name as ButtonColor} + size="large" + label="Click here"> + Button + </Button> + <Button + disabled + variant="gradient" + color={name as ButtonColor} + size="large" + label="Click here"> + Button + </Button> + </React.Fragment> + ), + )} + </View> + <View style={[a.gap_md, a.align_start]}> + {['gradient_sunset', 'gradient_nordic', 'gradient_bonfire'].map( + name => ( + <React.Fragment key={name}> + <Button + variant="gradient" + color={name as ButtonColor} + size="large" + label="Click here"> + Button + </Button> + <Button + disabled + variant="gradient" + color={name as ButtonColor} + size="large" + label="Click here"> + Button + </Button> + </React.Fragment> + ), + )} + </View> + </View> + + <Button + variant="gradient" + color="gradient_sky" + size="large" + label="Link out"> + <ButtonText>Link out</ButtonText> + <ButtonIcon icon={ArrowTopRight} /> + </Button> + + <Button + variant="gradient" + color="gradient_sky" + size="small" + label="Link out"> + <ButtonText>Link out</ButtonText> + <ButtonIcon icon={ArrowTopRight} /> + </Button> + + <Button + variant="gradient" + color="gradient_sky" + size="small" + label="Link out"> + <ButtonIcon icon={Globe} /> + <ButtonText>See the world</ButtonText> + </Button> + </View> + </View> + ) +} diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx new file mode 100644 index 000000000..db568c6bd --- /dev/null +++ b/src/view/screens/Storybook/Dialogs.tsx @@ -0,0 +1,90 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a} from '#/alf' +import {Button} from '#/components/Button' +import {H3, P} from '#/components/Typography' +import * as Dialog from '#/components/Dialog' +import * as Prompt from '#/components/Prompt' +import {useDialogStateControlContext} from '#/state/dialogs' + +export function Dialogs() { + const control = Dialog.useDialogControl() + const prompt = Prompt.usePromptControl() + const {closeAllDialogs} = useDialogStateControlContext() + + return ( + <View style={[a.gap_md]}> + <Button + variant="outline" + color="secondary" + size="small" + onPress={() => { + control.open() + prompt.open() + }} + label="Open basic dialog"> + Open basic dialog + </Button> + + <Button + variant="solid" + color="primary" + size="small" + onPress={() => prompt.open()} + label="Open prompt"> + Open prompt + </Button> + + <Prompt.Outer control={prompt}> + <Prompt.Title>This is a prompt</Prompt.Title> + <Prompt.Description> + This is a generic prompt component. It accepts a title and a + description, as well as two actions. + </Prompt.Description> + <Prompt.Actions> + <Prompt.Cancel>Cancel</Prompt.Cancel> + <Prompt.Action>Confirm</Prompt.Action> + </Prompt.Actions> + </Prompt.Outer> + + <Dialog.Outer + control={control} + nativeOptions={{sheet: {snapPoints: ['90%']}}}> + <Dialog.Handle /> + + <Dialog.ScrollableInner + accessibilityDescribedBy="dialog-description" + accessibilityLabelledBy="dialog-title"> + <View style={[a.relative, a.gap_md, a.w_full]}> + <H3 nativeID="dialog-title">Dialog</H3> + <P nativeID="dialog-description"> + A scrollable dialog with an input within it. + </P> + <Dialog.Input value="" onChangeText={() => {}} label="Type here" /> + + <Button + variant="outline" + color="secondary" + size="small" + onPress={closeAllDialogs} + label="Close all dialogs"> + Close all dialogs + </Button> + <View style={{height: 1000}} /> + <View style={[a.flex_row, a.justify_end]}> + <Button + variant="outline" + color="primary" + size="small" + onPress={() => control.close()} + label="Open basic dialog"> + Close basic dialog + </Button> + </View> + </View> + </Dialog.ScrollableInner> + </Dialog.Outer> + </View> + ) +} diff --git a/src/view/screens/Storybook/Forms.tsx b/src/view/screens/Storybook/Forms.tsx new file mode 100644 index 000000000..9396cca67 --- /dev/null +++ b/src/view/screens/Storybook/Forms.tsx @@ -0,0 +1,215 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a} from '#/alf' +import {H1, H3} from '#/components/Typography' +import * as TextField from '#/components/forms/TextField' +import {DateField, Label} from '#/components/forms/DateField' +import * as Toggle from '#/components/forms/Toggle' +import * as ToggleButton from '#/components/forms/ToggleButton' +import {Button} from '#/components/Button' +import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' + +export function Forms() { + const [toggleGroupAValues, setToggleGroupAValues] = React.useState(['a']) + const [toggleGroupBValues, setToggleGroupBValues] = React.useState(['a', 'b']) + const [toggleGroupCValues, setToggleGroupCValues] = React.useState(['a', 'b']) + const [toggleGroupDValues, setToggleGroupDValues] = React.useState(['warn']) + + const [value, setValue] = React.useState('') + const [date, setDate] = React.useState('2001-01-01') + + return ( + <View style={[a.gap_4xl, a.align_start]}> + <H1>Forms</H1> + + <View style={[a.gap_md, a.align_start, a.w_full]}> + <H3>InputText</H3> + + <TextField.Input + value={value} + onChangeText={setValue} + label="Text field" + /> + + <TextField.Root> + <TextField.Icon icon={Globe} /> + <TextField.Input + value={value} + onChangeText={setValue} + label="Text field" + /> + </TextField.Root> + + <View style={[a.w_full]}> + <TextField.Label>Text field</TextField.Label> + <TextField.Root> + <TextField.Icon icon={Globe} /> + <TextField.Input + value={value} + onChangeText={setValue} + label="Text field" + /> + <TextField.Suffix label="@gmail.com">@gmail.com</TextField.Suffix> + </TextField.Root> + </View> + + <View style={[a.w_full]}> + <TextField.Label>Textarea</TextField.Label> + <TextField.Input + multiline + numberOfLines={4} + value={value} + onChangeText={setValue} + label="Text field" + /> + </View> + + <H3>DateField</H3> + + <View style={[a.w_full]}> + <Label>Date</Label> + <DateField + testID="date" + value={date} + onChangeDate={date => { + console.log(date) + setDate(date) + }} + label="Input" + /> + </View> + </View> + + <View style={[a.gap_md, a.align_start, a.w_full]}> + <H3>Toggles</H3> + + <Toggle.Item name="a" label="Click me"> + <Toggle.Checkbox /> + <Toggle.Label>Uncontrolled toggle</Toggle.Label> + </Toggle.Item> + + <Toggle.Group + label="Toggle" + type="checkbox" + maxSelections={2} + values={toggleGroupAValues} + onChange={setToggleGroupAValues}> + <View style={[a.gap_md]}> + <Toggle.Item name="a" label="Click me"> + <Toggle.Switch /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="b" label="Click me"> + <Toggle.Switch /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="c" label="Click me"> + <Toggle.Switch /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="d" disabled label="Click me"> + <Toggle.Switch /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="e" isInvalid label="Click me"> + <Toggle.Switch /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + + <Toggle.Group + label="Toggle" + type="checkbox" + maxSelections={2} + values={toggleGroupBValues} + onChange={setToggleGroupBValues}> + <View style={[a.gap_md]}> + <Toggle.Item name="a" label="Click me"> + <Toggle.Checkbox /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="b" label="Click me"> + <Toggle.Checkbox /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="c" label="Click me"> + <Toggle.Checkbox /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="d" disabled label="Click me"> + <Toggle.Checkbox /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="e" isInvalid label="Click me"> + <Toggle.Checkbox /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + + <Toggle.Group + label="Toggle" + type="radio" + values={toggleGroupCValues} + onChange={setToggleGroupCValues}> + <View style={[a.gap_md]}> + <Toggle.Item name="a" label="Click me"> + <Toggle.Radio /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="b" label="Click me"> + <Toggle.Radio /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="c" label="Click me"> + <Toggle.Radio /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="d" disabled label="Click me"> + <Toggle.Radio /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="e" isInvalid label="Click me"> + <Toggle.Radio /> + <Toggle.Label>Click me</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + </View> + + <Button + variant="gradient" + color="gradient_nordic" + size="small" + label="Reset all toggles" + onPress={() => { + setToggleGroupAValues(['a']) + setToggleGroupBValues(['a', 'b']) + setToggleGroupCValues(['a']) + }}> + Reset all toggles + </Button> + + <View style={[a.gap_md, a.align_start, a.w_full]}> + <H3>ToggleButton</H3> + + <ToggleButton.Group + label="Preferences" + values={toggleGroupDValues} + onChange={setToggleGroupDValues}> + <ToggleButton.Button name="hide" label="Hide"> + Hide + </ToggleButton.Button> + <ToggleButton.Button name="warn" label="Warn"> + Warn + </ToggleButton.Button> + <ToggleButton.Button name="show" label="Show"> + Show + </ToggleButton.Button> + </ToggleButton.Group> + </View> + </View> + ) +} diff --git a/src/view/screens/Storybook/Icons.tsx b/src/view/screens/Storybook/Icons.tsx new file mode 100644 index 000000000..73466e077 --- /dev/null +++ b/src/view/screens/Storybook/Icons.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import {H1} from '#/components/Typography' +import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' +import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight' +import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' + +export function Icons() { + const t = useTheme() + return ( + <View style={[a.gap_md]}> + <H1>Icons</H1> + + <View style={[a.flex_row, a.gap_xl]}> + <Globe size="xs" fill={t.atoms.text.color} /> + <Globe size="sm" fill={t.atoms.text.color} /> + <Globe size="md" fill={t.atoms.text.color} /> + <Globe size="lg" fill={t.atoms.text.color} /> + <Globe size="xl" fill={t.atoms.text.color} /> + </View> + + <View style={[a.flex_row, a.gap_xl]}> + <ArrowTopRight size="xs" fill={t.atoms.text.color} /> + <ArrowTopRight size="sm" fill={t.atoms.text.color} /> + <ArrowTopRight size="md" fill={t.atoms.text.color} /> + <ArrowTopRight size="lg" fill={t.atoms.text.color} /> + <ArrowTopRight size="xl" fill={t.atoms.text.color} /> + </View> + + <View style={[a.flex_row, a.gap_xl]}> + <CalendarDays size="xs" fill={t.atoms.text.color} /> + <CalendarDays size="sm" fill={t.atoms.text.color} /> + <CalendarDays size="md" fill={t.atoms.text.color} /> + <CalendarDays size="lg" fill={t.atoms.text.color} /> + <CalendarDays size="xl" fill={t.atoms.text.color} /> + </View> + </View> + ) +} diff --git a/src/view/screens/Storybook/Links.tsx b/src/view/screens/Storybook/Links.tsx new file mode 100644 index 000000000..c3b1c0e0f --- /dev/null +++ b/src/view/screens/Storybook/Links.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a} from '#/alf' +import {ButtonText} from '#/components/Button' +import {Link} from '#/components/Link' +import {H1, H3} from '#/components/Typography' + +export function Links() { + return ( + <View style={[a.gap_md, a.align_start]}> + <H1>Links</H1> + + <View style={[a.gap_md, a.align_start]}> + <Link + to="https://blueskyweb.xyz" + warnOnMismatchingTextChild + style={[a.text_md]}> + External + </Link> + <Link to="https://blueskyweb.xyz" style={[a.text_md]}> + <H3>External with custom children</H3> + </Link> + <Link + to="https://blueskyweb.xyz" + warnOnMismatchingTextChild + style={[a.text_lg]}> + https://blueskyweb.xyz + </Link> + <Link + to="https://bsky.app/profile/bsky.app" + warnOnMismatchingTextChild + style={[a.text_md]}> + Internal + </Link> + + <Link + variant="solid" + color="primary" + size="large" + label="View @bsky.app's profile" + to="https://bsky.app/profile/bsky.app"> + <ButtonText>Link as a button</ButtonText> + </Link> + </View> + </View> + ) +} diff --git a/src/view/screens/Storybook/Palette.tsx b/src/view/screens/Storybook/Palette.tsx new file mode 100644 index 000000000..b521fe860 --- /dev/null +++ b/src/view/screens/Storybook/Palette.tsx @@ -0,0 +1,336 @@ +import React from 'react' +import {View} from 'react-native' + +import * as tokens from '#/alf/tokens' +import {atoms as a} from '#/alf' + +export function Palette() { + return ( + <View style={[a.gap_md]}> + <View style={[a.flex_row, a.gap_md]}> + <View + style={[a.flex_1, {height: 60, backgroundColor: tokens.color.gray_0}]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_25}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_50}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_100}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_200}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_300}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_400}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_500}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_600}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_700}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_800}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_900}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_950}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_975}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.gray_1000}, + ]} + /> + </View> + + <View style={[a.flex_row, a.gap_md]}> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_25}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_50}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_100}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_200}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_300}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_400}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_500}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_600}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_700}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_800}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_900}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_950}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.blue_975}, + ]} + /> + </View> + <View style={[a.flex_row, a.gap_md]}> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_25}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_50}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_100}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_200}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_300}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_400}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_500}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_600}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_700}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_800}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_900}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_950}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.green_975}, + ]} + /> + </View> + <View style={[a.flex_row, a.gap_md]}> + <View + style={[a.flex_1, {height: 60, backgroundColor: tokens.color.red_25}]} + /> + <View + style={[a.flex_1, {height: 60, backgroundColor: tokens.color.red_50}]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_100}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_200}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_300}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_400}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_500}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_600}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_700}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_800}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_900}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_950}, + ]} + /> + <View + style={[ + a.flex_1, + {height: 60, backgroundColor: tokens.color.red_975}, + ]} + /> + </View> + </View> + ) +} diff --git a/src/view/screens/Storybook/Shadows.tsx b/src/view/screens/Storybook/Shadows.tsx new file mode 100644 index 000000000..f92112395 --- /dev/null +++ b/src/view/screens/Storybook/Shadows.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import {H1, Text} from '#/components/Typography' + +export function Shadows() { + const t = useTheme() + + return ( + <View style={[a.gap_md]}> + <H1>Shadows</H1> + + <View style={[a.flex_row, a.gap_5xl]}> + <View + style={[ + a.flex_1, + a.justify_center, + a.px_lg, + a.py_2xl, + t.atoms.bg, + t.atoms.shadow_sm, + ]}> + <Text>shadow_sm</Text> + </View> + + <View + style={[ + a.flex_1, + a.justify_center, + a.px_lg, + a.py_2xl, + t.atoms.bg, + t.atoms.shadow_md, + ]}> + <Text>shadow_md</Text> + </View> + + <View + style={[ + a.flex_1, + a.justify_center, + a.px_lg, + a.py_2xl, + t.atoms.bg, + t.atoms.shadow_lg, + ]}> + <Text>shadow_lg</Text> + </View> + </View> + </View> + ) +} diff --git a/src/view/screens/Storybook/Spacing.tsx b/src/view/screens/Storybook/Spacing.tsx new file mode 100644 index 000000000..d7faf93a8 --- /dev/null +++ b/src/view/screens/Storybook/Spacing.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import {Text, H1} from '#/components/Typography' + +export function Spacing() { + const t = useTheme() + return ( + <View style={[a.gap_md]}> + <H1>Spacing</H1> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>2xs (2px)</Text> + <View style={[a.flex_1, a.pt_2xs, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>xs (4px)</Text> + <View style={[a.flex_1, a.pt_xs, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>sm (8px)</Text> + <View style={[a.flex_1, a.pt_sm, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>md (12px)</Text> + <View style={[a.flex_1, a.pt_md, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>lg (16px)</Text> + <View style={[a.flex_1, a.pt_lg, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>xl (20px)</Text> + <View style={[a.flex_1, a.pt_xl, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>2xl (24px)</Text> + <View style={[a.flex_1, a.pt_2xl, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>3xl (28px)</Text> + <View style={[a.flex_1, a.pt_3xl, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>4xl (32px)</Text> + <View style={[a.flex_1, a.pt_4xl, t.atoms.bg_contrast_300]} /> + </View> + + <View style={[a.flex_row, a.align_center]}> + <Text style={{width: 80}}>5xl (40px)</Text> + <View style={[a.flex_1, a.pt_5xl, t.atoms.bg_contrast_300]} /> + </View> + </View> + ) +} diff --git a/src/view/screens/Storybook/Theming.tsx b/src/view/screens/Storybook/Theming.tsx new file mode 100644 index 000000000..a05443473 --- /dev/null +++ b/src/view/screens/Storybook/Theming.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import {Palette} from './Palette' + +export function Theming() { + const t = useTheme() + + return ( + <View style={[t.atoms.bg, a.gap_lg, a.p_xl]}> + <Palette /> + + <Text style={[a.font_bold, a.pt_xl, a.px_md]}>theme.atoms.text</Text> + + <View style={[a.flex_1, t.atoms.border, a.border_t]} /> + <Text style={[a.font_bold, t.atoms.text_contrast_600, a.px_md]}> + theme.atoms.text_contrast_600 + </Text> + + <View style={[a.flex_1, t.atoms.border, a.border_t]} /> + <Text style={[a.font_bold, t.atoms.text_contrast_500, a.px_md]}> + theme.atoms.text_contrast_500 + </Text> + + <View style={[a.flex_1, t.atoms.border, a.border_t]} /> + <Text style={[a.font_bold, t.atoms.text_contrast_400, a.px_md]}> + theme.atoms.text_contrast_400 + </Text> + + <View style={[a.flex_1, t.atoms.border_contrast, a.border_t]} /> + + <View style={[a.w_full, a.gap_md]}> + <View style={[t.atoms.bg, a.justify_center, a.p_md]}> + <Text>theme.atoms.bg</Text> + </View> + <View style={[t.atoms.bg_contrast_25, a.justify_center, a.p_md]}> + <Text>theme.atoms.bg_contrast_25</Text> + </View> + <View style={[t.atoms.bg_contrast_50, a.justify_center, a.p_md]}> + <Text>theme.atoms.bg_contrast_50</Text> + </View> + <View style={[t.atoms.bg_contrast_100, a.justify_center, a.p_md]}> + <Text>theme.atoms.bg_contrast_100</Text> + </View> + <View style={[t.atoms.bg_contrast_200, a.justify_center, a.p_md]}> + <Text>theme.atoms.bg_contrast_200</Text> + </View> + <View style={[t.atoms.bg_contrast_300, a.justify_center, a.p_md]}> + <Text>theme.atoms.bg_contrast_300</Text> + </View> + </View> + </View> + ) +} diff --git a/src/view/screens/Storybook/Typography.tsx b/src/view/screens/Storybook/Typography.tsx new file mode 100644 index 000000000..2e1f04a66 --- /dev/null +++ b/src/view/screens/Storybook/Typography.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a} from '#/alf' +import {Text, H1, H2, H3, H4, H5, H6, P} from '#/components/Typography' + +export function Typography() { + return ( + <View style={[a.gap_md]}> + <H1>H1 Heading</H1> + <H2>H2 Heading</H2> + <H3>H3 Heading</H3> + <H4>H4 Heading</H4> + <H5>H5 Heading</H5> + <H6>H6 Heading</H6> + <P>P Paragraph</P> + + <Text style={[a.text_5xl]}>atoms.text_5xl</Text> + <Text style={[a.text_4xl]}>atoms.text_4xl</Text> + <Text style={[a.text_3xl]}>atoms.text_3xl</Text> + <Text style={[a.text_2xl]}>atoms.text_2xl</Text> + <Text style={[a.text_xl]}>atoms.text_xl</Text> + <Text style={[a.text_lg]}>atoms.text_lg</Text> + <Text style={[a.text_md]}>atoms.text_md</Text> + <Text style={[a.text_sm]}>atoms.text_sm</Text> + <Text style={[a.text_xs]}>atoms.text_xs</Text> + <Text style={[a.text_2xs]}>atoms.text_2xs</Text> + </View> + ) +} diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx new file mode 100644 index 000000000..d8898f20e --- /dev/null +++ b/src/view/screens/Storybook/index.tsx @@ -0,0 +1,78 @@ +import React from 'react' +import {View} from 'react-native' +import {CenteredView, ScrollView} from '#/view/com/util/Views' + +import {atoms as a, useTheme, ThemeProvider} from '#/alf' +import {useSetColorMode} from '#/state/shell' +import {Button} from '#/components/Button' + +import {Theming} from './Theming' +import {Typography} from './Typography' +import {Spacing} from './Spacing' +import {Buttons} from './Buttons' +import {Links} from './Links' +import {Forms} from './Forms' +import {Dialogs} from './Dialogs' +import {Breakpoints} from './Breakpoints' +import {Shadows} from './Shadows' +import {Icons} from './Icons' + +export function Storybook() { + const t = useTheme() + const setColorMode = useSetColorMode() + + return ( + <ScrollView> + <CenteredView style={[t.atoms.bg]}> + <View style={[a.p_xl, a.gap_5xl, {paddingBottom: 200}]}> + <View style={[a.flex_row, a.align_start, a.gap_md]}> + <Button + variant="outline" + color="primary" + size="small" + label='Set theme to "system"' + onPress={() => setColorMode('system')}> + System + </Button> + <Button + variant="solid" + color="secondary" + size="small" + label='Set theme to "system"' + onPress={() => setColorMode('light')}> + Light + </Button> + <Button + variant="solid" + color="secondary" + size="small" + label='Set theme to "system"' + onPress={() => setColorMode('dark')}> + Dark + </Button> + </View> + + <ThemeProvider theme="light"> + <Theming /> + </ThemeProvider> + <ThemeProvider theme="dim"> + <Theming /> + </ThemeProvider> + <ThemeProvider theme="dark"> + <Theming /> + </ThemeProvider> + + <Typography /> + <Spacing /> + <Shadows /> + <Buttons /> + <Icons /> + <Links /> + <Forms /> + <Dialogs /> + <Breakpoints /> + </View> + </CenteredView> + </ScrollView> + ) +} diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 51c03ae3d..5320aebfc 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -28,6 +28,7 @@ import {isAndroid} from 'platform/detection' import {useSession} from '#/state/session' import {useCloseAnyActiveElement} from '#/state/util' import * as notifications from 'lib/notifications/notifications' +import {Outlet as PortalOutlet} from '#/components/Portal' function ShellInner() { const isDrawerOpen = useIsDrawerOpen() @@ -94,6 +95,7 @@ function ShellInner() { </View> <Composer winHeight={winDim.height} /> <ModalsContainer /> + <PortalOutlet /> <Lightbox /> </> ) diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 20bc0dff1..1ada883c9 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -15,6 +15,7 @@ import {useAuxClick} from 'lib/hooks/useAuxClick' import {t} from '@lingui/macro' import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' import {useCloseAllActiveElements} from '#/state/util' +import {Outlet as PortalOutlet} from '#/components/Portal' function ShellInner() { const isDrawerOpen = useIsDrawerOpen() @@ -41,6 +42,7 @@ function ShellInner() { </View> <Composer winHeight={0} /> <ModalsContainer /> + <PortalOutlet /> <Lightbox /> {!isDesktop && isDrawerOpen && ( <TouchableOpacity diff --git a/web/index.html b/web/index.html index 5a56e5f76..46b20be1f 100644 --- a/web/index.html +++ b/web/index.html @@ -43,6 +43,25 @@ height: calc(100% + env(safe-area-inset-top)); } + /* Remove autofill styles on Webkit */ + input:-webkit-autofill, + input:-webkit-autofill:hover, + input:-webkit-autofill:focus, + textarea:-webkit-autofill, + textarea:-webkit-autofill:hover, + textarea:-webkit-autofill:focus, + select:-webkit-autofill, + select:-webkit-autofill:hover, + select:-webkit-autofill:focus { + border: 0; + -webkit-text-fill-color: transparent; + -webkit-box-shadow: none; + } + /* Force left-align date/time inputs on iOS mobile */ + input::-webkit-date-and-time-value { + text-align: left; + } + /* Color theming */ :root { --text: black; diff --git a/yarn.lock b/yarn.lock index 760e0b331..cc3c36031 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6966,6 +6966,31 @@ "@svgr/plugin-svgo" "^5.5.0" loader-utils "^2.0.0" +"@tamagui/compose-refs@1.84.1": + version "1.84.1" + resolved "https://registry.yarnpkg.com/@tamagui/compose-refs/-/compose-refs-1.84.1.tgz#244735edc3ac2e617389297f005d5bc25872465f" + integrity sha512-oZ0rUmQABlGm/QKQITxAW9WLV3qjyq1ehgoWcZVmtc1Kc/hkFQe2J+wRQV726CmTAnuUgUXi3eoNMwBVoZksfQ== + +"@tamagui/constants@1.84.1": + version "1.84.1" + resolved "https://registry.yarnpkg.com/@tamagui/constants/-/constants-1.84.1.tgz#62e41837dbe844d14e255f3eea9c2583044d2509" + integrity sha512-QmvyCqtEIugqXutQI35GJQ1hlpSapYCdOHx9QlgsOWjAY34pu55MaY/tDrQeQ0AUmI/qx30vy7TsCJxB4QFEoQ== + +"@tamagui/focus-scope@^1.84.1": + version "1.84.1" + resolved "https://registry.yarnpkg.com/@tamagui/focus-scope/-/focus-scope-1.84.1.tgz#e9f061184048c75f87da023f54b9c5abccdd460d" + integrity sha512-0E1Wc3jmKhafETfH1dUuJYmGK1bDNA/9TySbOeTjTToxUoL3V0G2W5JSwSMCDqR1Bl+xrGlGwzXTUhouw8qSog== + dependencies: + "@tamagui/compose-refs" "1.84.1" + "@tamagui/use-event" "1.84.1" + +"@tamagui/use-event@1.84.1": + version "1.84.1" + resolved "https://registry.yarnpkg.com/@tamagui/use-event/-/use-event-1.84.1.tgz#a095a1bde9c40c4a397226c57c3fa32f6018f504" + integrity sha512-U88WCxvMz7ZSfMFMJEFbG3tJjK/Lf+PHlmtYvlx1V+YiqRBoj5+milzoM8PclENn5vZMiJW0ozYRgzI/cdE7Eg== + dependencies: + "@tamagui/constants" "1.84.1" + "@tanstack/query-core@5.8.1": version "5.8.1" resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.8.1.tgz#5215a028370d9b2f32e83787a0ea119e2f977996" |