Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions packages/dev/s2-docs/pages/react-aria/blog/ColorEditorExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

'use client';
import {ColorPicker} from 'react-aria-components';
import {ColorArea, ColorSlider, ColorField, ColorSwatch, Picker, PickerItem} from '@react-spectrum/s2';
import {style} from '@react-spectrum/s2/style' with {type: 'macro'};
import {getColorChannels} from '@react-stately/color';
import {useState} from 'react';
import type {ColorSpace} from 'react-aria-components';
// @ts-ignore
import intlMessages from './intl/*.json';
import {useLocalizedStringFormatter} from '@react-aria/i18n';

interface ColorEditorProps {
hideAlphaChannel?: boolean;
}

function ColorEditor({hideAlphaChannel = false}: ColorEditorProps) {
let [format, setFormat] = useState<ColorSpace | 'hex'>('hex');
let formatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/color');

return (
<div className={style({display: 'flex', flexDirection: 'column', gap: 4, minWidth: 380})}>
<div className={style({display: 'flex', gap: 12})}>
<ColorArea colorSpace="hsb" xChannel="saturation" yChannel="brightness" />
<ColorSlider colorSpace="hsb" channel="hue" orientation="vertical" />
{!hideAlphaChannel && (
<ColorSlider channel="alpha" orientation="vertical" />
)}
</div>
<div className={style({display: 'flex', gap: 4})}>
<Picker
aria-label={formatter.format('colorFormat')}
isQuiet
styles={style({width: 70})}
value={format}
onChange={(key) => setFormat(key as typeof format)}>
<PickerItem id="hex">{formatter.format('hex')}</PickerItem>
<PickerItem id="rgb">{formatter.format('rgb')}</PickerItem>
<PickerItem id="hsl">{formatter.format('hsl')}</PickerItem>
<PickerItem id="hsb">{formatter.format('hsb')}</PickerItem>
</Picker>
{format === 'hex'
? <ColorField styles={style({width: 120})} aria-label={formatter.format('hex')} />
: getColorChannels(format).map(channel => (
<ColorField styles={style({width: 70})} key={channel} colorSpace={format} channel={channel} />
))}
{!hideAlphaChannel && (
<ColorField styles={style({width: 70})} channel="alpha" />
)}
</div>
</div>
);
}

export function ColorEditorExample() {
return (
<div
role="group"
aria-label="Example"
className={style({
backgroundColor: 'layer-1',
borderRadius: 'xl',
marginY: 32,
padding: {
default: 12,
lg: 24
}
})}>
<ColorPicker defaultValue="#5100FF">
{({color}) => (
<div className={style({display: 'flex', flexWrap: 'wrap', gap: 24})}>
<ColorEditor />
<div className={style({display: 'flex', flexDirection: 'column', gap: 8, marginTop: 16})}>
<ColorSwatch color={color} size="L" />
<span className={style({font: 'body'})}>{color.getColorName(navigator.language || 'en-US')}</span>
</div>
</div>
)}
</ColorPicker>
</div>
);
}
43 changes: 28 additions & 15 deletions packages/dev/s2-docs/pages/react-aria/blog/SubmenuAnimation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,21 +114,33 @@ export function SubmenuAnimation(): JSX.Element {
}, []);

return (
<div ref={ref} role="img" aria-label="Animation showing a submenu closing when the cursor leaves the trigger item to go to the submenu">
<svg
ref={mouseRef}
viewBox="0 0 12 19"
width={mouseWidth}
height={19}
aria-hidden="true"
style={{position: 'absolute', filter: 'drop-shadow(0 1px 1px #aaa)', transform: 'translate(-1000px, -1000px)'}}>
<g transform="matrix(1, 0, 0, 1, -150, -63.406998)">
<path d="M150 79.422V63.407l11.591 11.619h-6.781l-.411.124Z" fill="#fff" fillRule="evenodd" />
<path d="m159.084 80.1-3.6 1.535-4.684-11.093 3.686-1.553Z" fill="#fff" fillRule="evenodd" />
<path d="m157.751 79.416-1.844.774-3.1-7.374 1.841-.775Z" fillRule="evenodd" />
<path d="M151 65.814V77l2.969-2.866.431-.134h4.768Z" fillRule="evenodd" />
</g>
</svg>
<div
role="group"
aria-label="Example"
className={style({
backgroundColor: 'layer-1',
borderRadius: 'xl',
marginY: 32,
padding: {
default: 12,
lg: 24
}
})}>
<div ref={ref} role="img" aria-label="Animation showing a submenu closing when the cursor leaves the trigger item to go to the submenu">
<svg
ref={mouseRef}
viewBox="0 0 12 19"
width={mouseWidth}
height={19}
aria-hidden="true"
style={{position: 'absolute', filter: 'drop-shadow(0 1px 1px #aaa)', transform: 'translate(-1000px, -1000px)'}}>
<g transform="matrix(1, 0, 0, 1, -150, -63.406998)">
<path d="M150 79.422V63.407l11.591 11.619h-6.781l-.411.124Z" fill="#fff" fillRule="evenodd" />
<path d="m159.084 80.1-3.6 1.535-4.684-11.093 3.686-1.553Z" fill="#fff" fillRule="evenodd" />
<path d="m157.751 79.416-1.844.774-3.1-7.374 1.841-.775Z" fillRule="evenodd" />
<path d="M151 65.814V77l2.969-2.866.431-.134h4.768Z" fillRule="evenodd" />
</g>
</svg>

<svg
xmlns="http://www.w3.org/2000/svg"
Expand Down Expand Up @@ -352,6 +364,7 @@ export function SubmenuAnimation(): JSX.Element {
data-name="Option 2 arrow" />
</g>
</svg>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default Layout;
import docs from 'docs:@react-spectrum/s2';
import React from 'react';
import {Byline} from '../../../src/BlogList';
import {ColorEditorExample} from './ColorEditorExample';

export const tags = ['color picker', 'color', 'internationalization', 'localization', 'components', 'accessibility', 'react spectrum', 'react'];
export const description = 'Recently, we released a suite of color picker components in React Aria and React Spectrum. Since colors are inherently visual, ensuring these components are accessible to users with visual impairments presented a significant challenge. In this post, we\'ll discuss how we developed an algorithm that generates clear color descriptions for screen readers in multiple languages, while minimizing bundle size.';
Expand All @@ -37,7 +38,7 @@ Accessibility is at the core of all of our work on the React Spectrum team, and

Our initial implementation followed the typical ARIA patterns such as [slider](https://www.w3.org/WAI/ARIA/apg/patterns/slider/) to implement ColorArea, ColorSlider, and ColorWheel, and [listbox](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) to implement ColorSwatchPicker. This provided good support for mouse, touch, and keyboard input, but the screen reader experience left something to be desired. Out of the box, screen readers would only announce raw channel values like “Red: 182, Green: 96, Blue: 38”. I don’t know about you, but I can’t imagine what color that is just by hearing those numbers!

<video src={initialVideoUrl} style={{maxWidth: 'min(100%, 700px)', display: 'block', margin: '20px auto'}} controls preload="metadata" />
<video src={initialVideoUrl} style={{width: '100%', display: 'block', margin: '20px auto'}} controls preload="metadata" />

## Improving screen reader announcements

Expand Down Expand Up @@ -157,30 +158,14 @@ The order that the hue, chroma, and lightness descriptors are combined varies by

Check out the color picker below to see the results of this algorithm:

```tsx render
'use client';
import {ColorPicker} from 'react-aria-components';
import {ColorSwatch} from '@react-spectrum/s2';

// TODO: No ColorEditor in S2
<ColorPicker label="Fill" defaultValue="#5100FF">
{({color}) =>
<div style={{display: 'flex', flexWrap: 'wrap', gap: 24}}>
<div style={{display: 'flex', flexDirection: 'column', gap: 8}}>
<ColorSwatch size="L" />
<span>{color.getColorName(navigator.language || 'en-US')}</span>
</div>
</div>
}
</ColorPicker>
```
<ColorEditorExample />

## Final result

After developing this algorithm to generate color descriptions, we integrated it into all of our color picker components. Since the same description may be generated for a range of colors, our components also announce the precise numeric value of the channels being modified. For example, a hue slider may announce “260 degrees, blue purple, slider”. Numeric values are useful for fine adjustments, while the color descriptions provide an overall sense of the color, similar to how one would perceive it visually.

The video below shows interacting with a ColorArea with color descriptions. You can also try it yourself with a screen reader in the example above.

<video src={finalVideoUrl} style={{maxWidth: 'min(100%, 700px)', display: 'block', margin: '20px auto'}} controls preload="metadata" />
<video src={finalVideoUrl} style={{width: '100%', display: 'block', margin: '20px auto'}} controls preload="metadata" />

Check out our [ColorPicker](../ColorPicker.html) components in React Aria to build accessible, customizable, and styleable color pickers in your own applications.
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Mobile browsers often introduce delays before emulated mouse events like onClick

The CSS `:active` and `:hover` pseudo-classes are also affected by mouse event emulation. For example, when tapping down on a button and dragging your finger off, the active state persists even when your finger is not over it. This makes it appear like lifting your finger will activate the button when it will not. This is not how native buttons behave, so it can feel inconsistent with user expectations.

<video src={draggingVideoUrl} style={{maxWidth: 'min(100%, 700px)', display: 'block', margin: '20px auto'}} controls preload="metadata" />
<video src={draggingVideoUrl} style={{width: '100%', display: 'block', margin: '20px auto'}} controls preload="metadata" />

## Pointer events

Expand All @@ -83,7 +83,7 @@ Touch events can also be canceled by scrolling. If you touch a button and then s

Text selection gestures are another case where we need to determine the user's intent. On iOS, for example, a long press begins text selection. However, when pressing a button, you wouldn't usually expect text selection to start.

<video src={textSelectionVideoUrl} style={{maxWidth: 'min(100%, 700px)', display: 'block', margin: '20px auto'}} controls preload="metadata" />
<video src={textSelectionVideoUrl} style={{width: '100%', display: 'block', margin: '20px auto'}} controls preload="metadata" />

It is possible to add the `user-select: none` CSS property to the button to make it non-selectable, but even with that enabled, Safari still tries to select elements nearby. The only way to avoid this is to add `user-select: none` to the entire page. We wouldn't want to do this all the time though, because some elements should allow text selection to occur. React Aria automatically handles adding `user-select: none` to the page on touch start on a pressable element, and removes it after a short delay on press up. The delay is necessary because iOS may begin selecting even after touch up within some threshold.

Expand All @@ -107,7 +107,7 @@ Each of these events receive a unified `PressEvent` object rather than the under

With the [usePress](../usePress.html) hook, our buttons handle interactions consistently. Dragging your pointer off the button correctly removes the active state, text selection is canceled, and issues with emulated mouse events are avoided.

<video src={buttonVideoUrl} style={{maxWidth: 'min(100%, 700px)', display: 'block', margin: '20px auto'}} controls preload="metadata" />
<video src={buttonVideoUrl} style={{width: '100%', display: 'block', margin: '20px auto'}} controls preload="metadata" />

Try a live example for yourself in our [Button](../../s2/Button.html) docs!

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ The first thing that may come to mind when you think about implementing a hover

On touch devices, `:hover` is emulated for backward compatibility with older apps that weren't designed with touch in mind. Depending on the browser, `:hover` might never match, might match only while the user is touching an element, or may be sticky and act more like focus. On iOS for example, tapping once on an element shows the hover style, and tapping away from the element removes it.

<video src={hoverVideoUrl} loop autoPlay muted style={{maxWidth: 'min(100%, 700px)', display: 'block', margin: '20px auto'}} />
<video src={hoverVideoUrl} loop autoPlay muted style={{width: '100%', display: 'block', margin: '20px auto'}} />

This is not how you'd usually expect a button to behave, but browsers need to do this kind of emulation for apps that may only show or hide content on hover (e.g. navigation menus). If they did not, then perhaps this content would not be accessible at all to touch users. Unfortunately, there is no built-in way of opting out of this behavior, so we need to find another way to apply our hover styles.

Expand Down Expand Up @@ -76,7 +76,7 @@ We've wrapped all of this behavior into the [useHover](../useHover.html) hook in

The [Button](../../s2/Button.html) component, and all other components in React Spectrum that support hover states, use the [useHover](../useHover.html) hook to handle interactions, and apply a CSS class when they are hovered. This ensures that hover states are only applied when interacting with a mouse, which avoids unexpected behavior on touch devices.

<video src={hoveriPadVideoUrl} loop autoPlay muted style={{maxWidth: 'min(100%, 700px)', display: 'block', margin: '20px auto'}} />
<video src={hoveriPadVideoUrl} loop autoPlay muted style={{width: '100%', display: 'block', margin: '20px auto'}} />

Try a live example for yourself in our [Button](../../s2/Button.html) docs!

Expand All @@ -85,4 +85,3 @@ Try a live example for yourself in our [Button](../../s2/Button.html) docs!
As we've seen, cross-device interactions are difficult to handle across so many different types of devices. Even "simple" components like buttons are much more complicated than they seem at first. If you're building your own button component, I'd recommend checking out the [useButton](https://react-spectrum.adobe.com/react-aria/useButton.html) and [useHover](../useHover.html) hooks, which will help ensure that everything works as expected across a wide variety of devices.

In the [next part](building-a-button-part-3.html) of this series, we'll cover how React Spectrum and React Aria handle focus behavior across devices and browsers.

Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ There are many aspects of focus management, and perhaps we will cover more in fu

An important feature for keyboard users is a **focus ring**. This is a visual affordance for the currently focused element, which allows a keyboard user to know which element they are currently on. It may only be visible when navigating with a keyboard, however, so as not to distract mouse and touchscreen users.

<video src={focusRingVideoUrl} loop autoPlay muted style={{maxWidth: 'min(100%, 640px)', display: 'block', margin: '20px auto'}} />
<video src={focusRingVideoUrl} loop autoPlay muted style={{width: '100%', display: 'block', margin: '20px auto'}} />

As you can see in the above video, the focus ring appears around each button when it receives keyboard focus, but when the user interacts with a mouse it does not appear. To implement this, we attach global event listeners for pointer, keyboard, and focus events at the document level and keep track of the most recent input modality that the user was interacting with. If the user most recently interacted with a keyboard or assistive technology, we show the focus ring, otherwise we do not show it.

Expand Down Expand Up @@ -79,4 +79,3 @@ This focus normalization behavior is implemented by the [usePress](../usePress.h
## Conclusion

In this series, you've seen how complicated even "simple" components like buttons can be when you consider all of the interactions they can support. React Aria aims to simplify this complexity and provide consistent behavior out of the box, while giving you complete rendering and styling control for your own components. This lets you focus on your unique design requirements, and build high quality components much faster. If you're working on a design system, check it out!

Loading