Skip to content

Commit

Permalink
fix(snackbar): fix a11y focus issue
Browse files Browse the repository at this point in the history
  • Loading branch information
soykje committed Mar 27, 2024
1 parent c99e382 commit 71a7a7b
Show file tree
Hide file tree
Showing 4 changed files with 83 additions and 44 deletions.
51 changes: 7 additions & 44 deletions packages/components/snackbar/src/Snackbar.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,15 @@
import { type AriaToastRegionProps, useToastRegion } from '@react-aria/toast'
import {
type ToastOptions as SnackBarItemOptions,
ToastQueue,
useToastQueue,
} from '@react-stately/toast'
import {
cloneElement,
type ComponentPropsWithoutRef,
forwardRef,
type ReactElement,
type RefObject,
useEffect,
useRef,
} from 'react'
import { forwardRef, type ReactElement, type RefObject, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'

import { snackbarRegionVariant, type SnackbarRegionVariantProps } from './Snackbar.styles'
import { SnackbarItem, type SnackbarItemProps, type SnackbarItemValue } from './SnackbarItem'
import { SnackbarItemContext } from './SnackbarItemContext'
import { type SnackbarItemValue } from './SnackbarItem'
import { SnackbarRegion, type SnackbarRegionProps } from './SnackbarRegion'
import { useSnackbarGlobalStore } from './useSnackbarGlobalStore'

export interface SnackbarProps
extends ComponentPropsWithoutRef<'div'>,
AriaToastRegionProps,
SnackbarRegionVariantProps {
/**
* The component/template used to display each snackbar from the queue
* @default 'Snackbar.Item'
*/
children?: ReactElement<SnackbarItemProps, typeof SnackbarItem>
}

/**
* We define here a global queue thanks to dedicated util from React Spectrum.
* It is based on React `useSyncExternalStore` and allows us to consume data from
Expand Down Expand Up @@ -63,16 +42,14 @@ const GLOBAL_SNACKBAR_STORE = {
subscriptions: new Set<() => void>(),
}

export type SnackbarProps = Omit<SnackbarRegionProps, 'state'>

export const Snackbar = forwardRef<HTMLDivElement, SnackbarProps>(
(
{ children = <SnackbarItem />, position = 'bottom', className, ...rest },
forwardedRef
): ReactElement | null => {
(props, forwardedRef): ReactElement | null => {
const innerRef = useRef<HTMLDivElement>(null)
const ref = forwardedRef && typeof forwardedRef !== 'function' ? forwardedRef : innerRef

const state = useToastQueue(getGlobalSnackBarQueue())
const { regionProps } = useToastRegion(rest, state, ref)

const { provider, addProvider, deleteProvider } = useSnackbarGlobalStore(GLOBAL_SNACKBAR_STORE)

Expand All @@ -84,21 +61,7 @@ export const Snackbar = forwardRef<HTMLDivElement, SnackbarProps>(
}, [])

return ref === provider && state.visibleToasts.length > 0
? createPortal(
<div
{...regionProps}
ref={ref}
data-position={position}
className={snackbarRegionVariant({ position, className })}
>
{state.visibleToasts.map(toast => (
<SnackbarItemContext.Provider key={toast.key} value={{ toast, state }}>
{cloneElement(children, { key: toast.key })}
</SnackbarItemContext.Provider>
))}
</div>,
document.body
)
? createPortal(<SnackbarRegion ref={ref} state={state} {...props} />, document.body)
: null
}
)
Expand Down
4 changes: 4 additions & 0 deletions packages/components/snackbar/src/SnackbarItem.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ export const snackbarItemVariant = cva(
'max-w-[600px]',
'pointer-events-auto',
'absolute',
/**
* Focus
*/
'group-focus-visible:outline-none group-focus-visible:u-ring group-[&:not(:focus-visible)]:ring-inset',
/**
* Positionning
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ export const snackbarRegionVariant = cva(
['fixed inset-x-lg z-toast group', 'outline-none pointer-events-none', 'flex flex-col gap-lg'],
{
variants: {
/**
* Set snackbar item position
* @default 'bottom'
*/
position: {
top: 'top-lg items-center',
'top-right': 'top-lg items-end',
Expand Down
68 changes: 68 additions & 0 deletions packages/components/snackbar/src/SnackbarRegion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { type AriaToastRegionProps, useToastRegion } from '@react-aria/toast'
import {
cloneElement,
type ComponentPropsWithoutRef,
forwardRef,
type ReactElement,
useRef,
} from 'react'

import { SnackbarItem, type SnackbarItemProps } from './SnackbarItem'
import { SnackbarItemContext, type SnackbarItemState } from './SnackbarItemContext'
import { snackbarRegionVariant, type SnackbarRegionVariantProps } from './SnackbarRegion.styles'

export interface SnackbarRegionProps
extends ComponentPropsWithoutRef<'div'>,
AriaToastRegionProps,
SnackbarRegionVariantProps,
Pick<SnackbarItemState, 'state'> {
/**
* An accessibility label for the snackbar region.
* @default 'Notifications'
*/
'aria-label'?: string
/**
* Identifies the element (or elements) that labels the current element.
*/
'aria-labelledby'?: string
/**
* Identifies the element (or elements) that describes the object.
*/
'aria-describedby'?: string
/**
* Identifies the element (or elements) that provide a detailed, extended description for the object.
*/
'aria-details'?: string
/**
* The component/template used to display each snackbar from the queue
* @default 'Snackbar.Item'
*/
children?: ReactElement<SnackbarItemProps, typeof SnackbarItem>
}

export const SnackbarRegion = forwardRef<HTMLDivElement, SnackbarRegionProps>(
(
{ children = <SnackbarItem />, state, position = 'bottom', className, ...rest },
forwardedRef
): ReactElement => {
const innerRef = useRef<HTMLDivElement>(null)
const ref = forwardedRef && typeof forwardedRef !== 'function' ? forwardedRef : innerRef

const { regionProps } = useToastRegion(rest, state, ref)

return (
<div
{...regionProps}
ref={ref}
data-position={position}
className={snackbarRegionVariant({ position, className })}
>
{state.visibleToasts.map(toast => (
<SnackbarItemContext.Provider key={toast.key} value={{ toast, state }}>
{cloneElement(children, { key: toast.key })}
</SnackbarItemContext.Provider>
))}
</div>
)
}
)

0 comments on commit 71a7a7b

Please sign in to comment.