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
zio/stable
Eric Bailey 2024-01-18 20:28:04 -06:00 committed by GitHub
parent 9cbd3c0937
commit 66b8774ecb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 4683 additions and 968 deletions

View File

@ -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>

After

Width:  |  Height:  |  Size: 260 B

View File

@ -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>

After

Width:  |  Height:  |  Size: 512 B

View File

@ -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>

After

Width:  |  Height:  |  Size: 1011 B

View File

@ -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>

After

Width:  |  Height:  |  Size: 1005 B

View File

@ -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;

View File

@ -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",

View File

@ -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>

View File

@ -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>

View File

@ -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"

View File

@ -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,
},
/*
@ -182,11 +213,30 @@ export const atoms = {
borderBottomWidth: 1,
},
/*
* 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,
},
py_xxs: {
paddingTop: tokens.space.xxs,
paddingBottom: tokens.space.xxs,
px_3xl: {
paddingLeft: tokens.space._3xl,
paddingRight: tokens.space._3xl,
},
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,
},
pt_xxs: {
paddingTop: tokens.space.xxs,
py_3xl: {
paddingTop: tokens.space._3xl,
paddingBottom: tokens.space._3xl,
},
py_4xl: {
paddingTop: tokens.space._4xl,
paddingBottom: tokens.space._4xl,
},
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,
},
pb_xxs: {
paddingBottom: tokens.space.xxs,
pt_3xl: {
paddingTop: tokens.space._3xl,
},
pt_4xl: {
paddingTop: tokens.space._4xl,
},
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,
},
pl_xxs: {
paddingLeft: tokens.space.xxs,
pb_3xl: {
paddingBottom: tokens.space._3xl,
},
pb_4xl: {
paddingBottom: tokens.space._4xl,
},
pb_5xl: {
paddingBottom: tokens.space._5xl,
},
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,
},
mx_xxs: {
marginLeft: tokens.space.xxs,
marginRight: tokens.space.xxs,
m_3xl: {
margin: tokens.space._3xl,
},
m_4xl: {
margin: tokens.space._4xl,
},
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,
},
my_xxs: {
marginTop: tokens.space.xxs,
marginBottom: tokens.space.xxs,
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_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,
},
mb_xxs: {
marginBottom: tokens.space.xxs,
mt_3xl: {
marginTop: tokens.space._3xl,
},
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,
},
ml_xxs: {
marginLeft: tokens.space.xxs,
mb_3xl: {
marginBottom: tokens.space._3xl,
},
mb_4xl: {
marginBottom: tokens.space._4xl,
},
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,
},
mr_xxs: {
marginRight: tokens.space.xxs,
ml_3xl: {
marginLeft: tokens.space._3xl,
},
ml_4xl: {
marginLeft: tokens.space._4xl,
},
ml_5xl: {
marginLeft: tokens.space._5xl,
},
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

View File

@ -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

View File

@ -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,
export const lightPalette: Palette = {
primary: tokens.color.blue_500,
positive: tokens.color.green_500,
negative: tokens.color.red_500,
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,
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,
},
bg_positive: {
backgroundColor: tokens.color.green_500,
},
bg_negative: {
backgroundColor: tokens.color.red_400,
backgroundColor: lightPalette.contrast_300,
},
border: {
borderColor: tokens.color.gray_200,
borderColor: lightPalette.contrast_200,
},
border_contrast_500: {
borderColor: tokens.color.gray_500,
border_contrast: {
borderColor: lightPalette.contrast_400,
},
shadow_sm: {
...atoms.shadow_sm,
shadowColor: lightPalette.black,
},
shadow_md: {
...atoms.shadow_md,
shadowColor: lightPalette.black,
},
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,
},
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: darkPalette.contrast_200,
},
border_contrast: {
borderColor: darkPalette.contrast_400,
},
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,
},
bg_positive: {
backgroundColor: tokens.color.green_400,
},
bg_negative: {
backgroundColor: tokens.color.red_400,
backgroundColor: darkPalette.contrast_400,
},
border: {
borderColor: tokens.color.gray_800,
borderColor: darkPalette.contrast_100,
},
border_contrast_500: {
borderColor: tokens.color.gray_500,
border_contrast: {
borderColor: darkPalette.contrast_300,
},
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,
},
},
}

View File

@ -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

View File

@ -0,0 +1,3 @@
import {StyleSheet} from 'react-native'
export const flatten = StyleSheet.flatten

View File

@ -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>
)
}

View File

@ -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(),
}
}

View File

@ -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
}

View File

@ -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>
// )
// }

View File

@ -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
}>

View File

@ -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>
)
}

View File

@ -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
}

View File

@ -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>
)
}

View File

@ -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}]}
/>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -0,0 +1,7 @@
export type DateFieldProps = {
value: string
onChangeDate: (date: string) => void
label: string
isInvalid?: boolean
testID?: string
}

View File

@ -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)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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],
)
}

View File

@ -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',
})

View File

@ -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',
})

View File

@ -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',
})

View File

@ -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',
})

View File

@ -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>
)
})
}

View File

@ -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,
}
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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"

View File

@ -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">

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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 />
</>
)

View File

@ -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

View File

@ -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;

View File

@ -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"