Rough sketch

This commit is contained in:
Eric Bailey
2025-08-12 21:36:09 -05:00
parent cc00102f18
commit d347717086
12 changed files with 355 additions and 126 deletions
+13
View File
@@ -0,0 +1,13 @@
import {createContext, useContext} from 'react'
import {type ToastApi} from '#/components/Toast/types'
export type ToastContext = ToastApi
export const Context = createContext<ToastContext>({
show() {},
})
export function useToast() {
return useContext(Context)
}
+7
View File
@@ -0,0 +1,7 @@
import {createPortalGroup} from '#/components/Portal'
const portal = createPortalGroup()
export const Provider = portal.Provider
export const Outlet = portal.Outlet
export const Portal = portal.Portal
+88 -3
View File
@@ -1,5 +1,5 @@
import {useEffect, useMemo, useRef, useState} from 'react'
import {AccessibilityInfo} from 'react-native'
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {AccessibilityInfo, View} from 'react-native'
import {
Gesture,
GestureDetector,
@@ -18,15 +18,100 @@ import Animated, {
} from 'react-native-reanimated'
import RootSiblings from 'react-native-root-siblings'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {nanoid} from 'nanoid/non-secure'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {atoms as a} from '#/alf'
import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const'
import {Context} from '#/components/Toast/Context'
import * as Portal from '#/components/Toast/Portal'
import {Toast} from '#/components/Toast/Toast'
import {type ToastApi, type ToastType} from '#/components/Toast/types'
import {
type ToastApi,
type ToastProps,
type ToastType,
} from '#/components/Toast/types'
export {useToast} from '#/components/Toast/Context'
export {Outlet} from '#/components/Toast/Portal'
const TOAST_ANIMATION_DURATION = 300
export function ToastProvider({children}: {children: React.ReactNode}) {
const {top} = useSafeAreaInsets()
const [toasts, setToasts] = useState<
{
id: string
toast: ToastProps
timeout: NodeJS.Timeout
}[]
>([])
const show = useCallback<ToastApi['show']>(props => {
AccessibilityInfo.announceForAccessibility(props.a11yLabel)
setToasts(prevToasts => {
const id = nanoid()
return [
...prevToasts,
{
id,
toast: props,
timeout: setTimeout(() => {
setToasts(currentToasts => currentToasts.filter(t => t.id !== id))
}, props.duration || DEFAULT_TOAST_DURATION),
},
]
})
}, [])
useEffect(() => {
return () => {
setToasts(toasts => {
toasts.map(({timeout}) => {
clearTimeout(timeout)
})
return toasts
})
}
}, [])
const ctx = useMemo(
() => ({
show,
}),
[show],
)
return (
<Context.Provider value={ctx}>
<Portal.Provider>
{children}
{toasts.length ? (
<Portal.Portal>
<View
style={[
a.absolute,
a.gap_sm,
{
flexDirection: 'column-reverse',
top: top,
left: a.px_lg.paddingLeft,
right: a.px_lg.paddingLeft,
},
]}>
{toasts.map(({id, toast}) => (
<Toast key={id} type={toast.type} content={toast.content} />
))}
</View>
</Portal.Portal>
) : null}
</Portal.Provider>
</Context.Provider>
)
}
export function ToastContainer() {
return null
}
+92 -2
View File
@@ -2,15 +2,26 @@
* Note: relies on styles in #/styles.css
*/
import {useEffect, useState} from 'react'
import {useCallback, useEffect, useMemo, useState} from 'react'
import {AccessibilityInfo, Pressable, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {nanoid} from 'nanoid/non-secure'
import {atoms as a, useBreakpoints} from '#/alf'
import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const'
import {Context} from '#/components/Toast/Context'
import * as Portal from '#/components/Toast/Portal'
import {Toast} from '#/components/Toast/Toast'
import {type ToastApi, type ToastType} from '#/components/Toast/types'
import {
type ToastApi,
type ToastProps,
type ToastType,
} from '#/components/Toast/types'
export {useToast} from '#/components/Toast/Context'
export {Outlet} from '#/components/Toast/Portal'
const TOAST_ANIMATION_STYLES = {
entering: {
@@ -31,6 +42,85 @@ let globalSetActiveToast: GlobalSetActiveToast | undefined
let toastTimeout: NodeJS.Timeout | undefined
type ToastContainerProps = {}
export function ToastProvider({children}: {children: React.ReactNode}) {
const {bottom} = useSafeAreaInsets()
const {gtPhone} = useBreakpoints()
const [toasts, setToasts] = useState<
{
id: string
toast: ToastProps
timeout: NodeJS.Timeout
}[]
>([])
const show = useCallback<ToastApi['show']>(props => {
setToasts(prevToasts => {
const id = nanoid()
return [
...prevToasts,
{
id,
toast: props,
timeout: setTimeout(() => {
setToasts(currentToasts => currentToasts.filter(t => t.id !== id))
}, props.duration || DEFAULT_TOAST_DURATION),
},
]
})
}, [])
useEffect(() => {
return () => {
setToasts(toasts => {
toasts.map(({timeout}) => {
clearTimeout(timeout)
})
return toasts
})
}
}, [])
const ctx = useMemo(
() => ({
show,
}),
[show],
)
return (
<Context.Provider value={ctx}>
<Portal.Provider>
{children}
{toasts.length && (
<Portal.Portal>
<View
style={[
a.fixed,
a.gap_sm,
{
flexDirection: 'column-reverse',
left: a.px_xl.paddingLeft,
right: a.px_xl.paddingLeft,
bottom: Math.max(bottom, a.px_xl.paddingLeft),
},
gtPhone && [
{
maxWidth: 380,
},
],
]}>
{toasts.map(({id, toast}) => (
<Toast key={id} content={toast.content} type={toast.type} />
))}
</View>
</Portal.Portal>
)}
</Portal.Provider>
</Context.Provider>
)
}
export const ToastContainer: React.FC<ToastContainerProps> = ({}) => {
const {_} = useLingui()
const {gtPhone} = useBreakpoints()
+23 -21
View File
@@ -1,24 +1,26 @@
export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info'
export type ToastApi = {
show: (props: {
/**
* The type of toast to show. This determines the styling and icon used.
*/
type: ToastType
/**
* A string, `Text`, or `Span` components to render inside the toast. This
* allows additional formatting of the content, but should not be used for
* interactive elements link links or buttons.
*/
content: React.ReactNode | string
/**
* Accessibility label for the toast, used for screen readers.
*/
a11yLabel: string
/**
* Defaults to `DEFAULT_TOAST_DURATION` from `#components/Toast/const`.
*/
duration?: number
}) => void
export type ToastProps = {
/**
* The type of toast to show. This determines the styling and icon used.
*/
type: ToastType
/**
* A string, `Text`, or `Span` components to render inside the toast. This
* allows additional formatting of the content, but should not be used for
* interactive elements link links or buttons.
*/
content: React.ReactNode | string
/**
* Accessibility label for the toast, used for screen readers.
*/
a11yLabel: string
/**
* Defaults to `DEFAULT_TOAST_DURATION` from `#components/Toast/const`.
*/
duration?: number
}
export type ToastApi = {
show: (props: ToastProps) => void
}