Skip to content

Commit

Permalink
feat(skeleton): improve a11y
Browse files Browse the repository at this point in the history
  • Loading branch information
soykje committed Nov 6, 2024
1 parent 070ce8f commit 783272d
Show file tree
Hide file tree
Showing 9 changed files with 113 additions and 26 deletions.
15 changes: 15 additions & 0 deletions e2e/a11y/pages/Skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Skeleton } from '@spark-ui/skeleton'
import React from 'react'

export const A11ySkeleton = () => (
<section>
<Skeleton gap="lg">
<Skeleton.Rectangle height={128} />

<Skeleton.Group gap="lg">
<Skeleton.Circle size={64} />
<Skeleton.Line gap="md" lines={3} />
</Skeleton.Group>
</Skeleton>
</section>
)
1 change: 1 addition & 0 deletions e2e/a11y/routes/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const a11yComponents = {
'radio-group': 'radio-group',
rating: 'rating',
select: 'select',
skeleton: 'skeleton',
slider: 'slider',
snackbar: 'snackbar',
spinner: 'spinner',
Expand Down
2 changes: 2 additions & 0 deletions e2e/a11y/routes/elements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { A11yProgressTracker } from '../pages/ProgressTracker'
import { A11yRadioGroup } from '../pages/RadioGroup'
import { A11yRating } from '../pages/Rating'
import { A11ySelect } from '../pages/Select'
import { A11ySkeleton } from '../pages/Skeleton'
import { A11ySlider } from '../pages/Slider'
import { A11ySnackbar } from '../pages/Snackbar'
import { A11ySpinner } from '../pages/Spinner'
Expand Down Expand Up @@ -73,6 +74,7 @@ export const a11yElements: Record<A11yComponentsKey, ReactNode> = {
'radio-group': <A11yRadioGroup />,
rating: <A11yRating />,
select: <A11ySelect />,
skeleton: <A11ySkeleton />,
slider: <A11ySlider />,
snackbar: <A11ySnackbar />,
spinner: <A11ySpinner />,
Expand Down
17 changes: 15 additions & 2 deletions packages/components/skeleton/src/Skeleton.doc.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,19 @@ import { Skeleton } from '@spark-ui/skeleton'
subcomponents={{
'Skeleton.Group': {
of: Skeleton.Group,
description: 'A simple wrapper to group components for advanced layouts.',
description: 'A wrapper to group Skeleton components, for advanced layouts.',
},
'Skeleton.Circle': {
of: Skeleton.Circle,
description: 'A specific Skeleton component, for circular content.',
},
'Skeleton.Rectangle': {
of: Skeleton.Rectangle,
description: 'A specific Skeleton component, for rectangular content.',
},
'Skeleton.Line': {
of: Skeleton.Line,
description: 'A specific Skeleton component, for "line-shaped" content (such as text).',
},
}}
/>
Expand All @@ -54,7 +57,11 @@ import { Skeleton } from '@spark-ui/skeleton'

### Size

According to the specific item you implement you can use `width|height` (for `<Skeleton.Rectangle />`) or directly `size` (for `<Skeleton.Circle />`) to define the size of your item, individually.
For `<Skeleton.Rectangle />` component you can use `width|height` props to define the size.

For `<Skeleton.Circle />` component you can use `size` prop to define the size.

For `<Skeleton.Line />` component you can use `lines` prop to define the number of lines, which will be equivalent to size in this specific usecase.

<Canvas of={stories.Size} />

Expand Down Expand Up @@ -87,3 +94,9 @@ For advanced `<Skeleton />` layouts you can group items to fit your specific nee
## Accessibility

<A11yReport of="skeleton" />

The `<Skeleton />` itself shouldn't be visible in the accessibility tree. Still, we strongly recommend to use the `label` prop to allow screen readers to give some equivalent feedback to the user.

### Keyboard Interactions

The `<Skeleton />` shouldn't be accessible using keyboard navigation.
35 changes: 25 additions & 10 deletions packages/components/skeleton/src/Skeleton.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,31 @@ export const Default: StoryFn = _args => (
)

export const Size: StoryFn = _args => (
<Skeleton gap="xl">
<Skeleton.Circle size={32} />
<Skeleton.Circle size={64} />
<Skeleton.Circle size={128} />

<Skeleton.Group gap="lg">
<Skeleton.Rectangle height={64} />
<Skeleton.Rectangle height={128} />
</Skeleton.Group>
</Skeleton>
<div className="grid grid-cols-2 gap-xl md:grid-cols-3">
<div>
<Skeleton gap="xl">
<Skeleton.Circle size={32} />
<Skeleton.Circle size={64} />
<Skeleton.Circle size={128} />
</Skeleton>
</div>

<div>
<Skeleton gap="xl">
<Skeleton.Rectangle height={32} />
<Skeleton.Rectangle height={64} />
<Skeleton.Rectangle height={128} />
</Skeleton>
</div>

<div>
<Skeleton gap="xl">
<Skeleton.Line />
<Skeleton.Line lines={3} />
<Skeleton.Line lines={6} />
</Skeleton>
</div>
</div>
)

const gaps: ExcludeNull<SkeletonGroupProps['gap']>[] = ['sm', 'md', 'lg', 'xl', '2xl', '3xl']
Expand Down
29 changes: 26 additions & 3 deletions packages/components/skeleton/src/Skeleton.test.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { render } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'

import { Skeleton } from '.'

describe('Skeleton', () => {
it('should render skeleton with children components', () => {
const { container } = render(
<Skeleton>
<Skeleton label="Loading...">
<Skeleton.Rectangle width="100%" height={128} />
<Skeleton.Circle size={64} />
<Skeleton.Line />
</Skeleton>
)

expect(document.querySelector('[data-spark-component="skeleton"]')).toBeInTheDocument()
expect(screen.getByText('Loading...')).toBeInTheDocument()

expect(container.querySelectorAll('[data-part="rectangle"]')).toHaveLength(1)
expect(container.querySelectorAll('[data-part="circle"]')).toHaveLength(1)
Expand All @@ -29,4 +30,26 @@ describe('Skeleton', () => {

expect(container.querySelectorAll('[data-part="line"]')).toHaveLength(5)
})

it('should not be reachable on keyboard navigation', async () => {
const user = userEvent.setup()

render(
<div>
<button type="button">Previous button</button>
<Skeleton>
<Skeleton.Rectangle height={128} />
</Skeleton>
<button type="button">Next button</button>
</div>
)

await user.tab()
expect(screen.getByText('Previous button')).toHaveFocus()

await user.tab()

expect(document.querySelector('[data-spark-component="skeleton"]')).not.toHaveFocus()
expect(screen.getByText('Next button')).toHaveFocus()
})
})
9 changes: 8 additions & 1 deletion packages/components/skeleton/src/Skeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { VisuallyHidden } from '@spark-ui/visually-hidden'
import { ComponentPropsWithoutRef, forwardRef, PropsWithChildren } from 'react'

import { type SkeletonStyleProps, skeletonStyles } from './Skeleton.styles'
Expand All @@ -12,17 +13,23 @@ export interface SkeletonProps
* @default true
*/
isAnimated?: boolean
/**
* Adds an accessible fallback label.
*/
label?: string
}

export const Skeleton = forwardRef<HTMLDivElement, PropsWithChildren<SkeletonProps>>(
({ isAnimated = true, className, children, ...rest }, forwardedRef) => (
({ isAnimated = true, label, className, children, ...rest }, forwardedRef) => (
<SkeletonGroup
ref={forwardedRef}
data-spark-component="skeleton"
className={skeletonStyles({ isAnimated, className })}
{...rest}
>
{children}

{label && <VisuallyHidden>{label}</VisuallyHidden>}
</SkeletonGroup>
)
)
Expand Down
2 changes: 1 addition & 1 deletion packages/components/skeleton/src/SkeletonItem.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { cva, VariantProps } from 'class-variance-authority'
export const skeletonItemStyles = cva(['bg-neutral/dim-4', 'min-h-lg min-w-lg'], {
variants: {
shape: {
line: ['flex', 'w-full last:w-5/6', 'rounded-lg'],
line: ['flex', 'w-full [&:last-child:not(:first-child)]:w-5/6', 'rounded-lg'],
rectangle: ['flex', 'rounded-sm'],
circle: ['inline-flex flex-none', 'rounded-full'],
},
Expand Down
29 changes: 20 additions & 9 deletions packages/components/skeleton/src/SkeletonItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,14 @@ const getSizeValue = (size?: number | string): string | undefined => {

const SkeletonItem = forwardRef<HTMLDivElement, SkeletonItemProps>(
({ shape, className, ...rest }, forwardedRef) => {
return <div ref={forwardedRef} className={skeletonItemStyles({ shape, className })} {...rest} />
return (
<div
ref={forwardedRef}
aria-hidden="true"
className={skeletonItemStyles({ shape, className })}
{...rest}
/>
)
}
)

Expand Down Expand Up @@ -67,17 +74,21 @@ export const SkeletonCircle = ({ size, ...rest }: SkeletonCircleProps) => (

export const SkeletonLine = ({
lines = 1,
gap,
gap: gapProp,
alignment = 'start',
className,
...rest
}: SkeletonLineProps) => (
<div className={skeletonLineStyles({ alignment, gap, className })} data-part="linegroup">
{[...new Array(lines)].map((_, index) => (
<SkeletonItem key={`line_${index}`} {...rest} shape="line" data-part="line" />
))}
</div>
)
}: SkeletonLineProps) => {
const gap = gapProp || (lines > 1 ? 'md' : undefined)

return (
<div className={skeletonLineStyles({ alignment, gap, className })} data-part="linegroup">
{[...new Array(lines)].map((_, index) => (
<SkeletonItem key={`line_${index}`} {...rest} shape="line" data-part="line" />
))}
</div>
)
}

SkeletonRectangle.displayName = 'Skeleton.Rectangle'
SkeletonCircle.displayName = 'Skeleton.Circle'
Expand Down

0 comments on commit 783272d

Please sign in to comment.