From a8ccac8c3f493d458b748df189df57af3f8c8195 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Wed, 5 Nov 2025 13:08:10 -0600 Subject: [PATCH 1/4] fix: XL avatar size in ActionButton (#9149) --- packages/@react-spectrum/s2/src/ActionButton.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ActionButton.tsx b/packages/@react-spectrum/s2/src/ActionButton.tsx index 8ddaea0b68d..9d28e68100f 100644 --- a/packages/@react-spectrum/s2/src/ActionButton.tsx +++ b/packages/@react-spectrum/s2/src/ActionButton.tsx @@ -251,12 +251,12 @@ export const btnStyles = style, number> = { XS: 14, S: 16, M: 20, L: 22, - X: 26 + XL: 26 } as const; export const ActionButtonContext = createContext, FocusableRefValue>>(null); From 98c71930363fd49780fc9bb365fc33d3685d7076 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Wed, 5 Nov 2025 13:15:58 -0600 Subject: [PATCH 2/4] fix(RAC): ListBox DnD with actions (#9150) * fix: listbox dnd with actions * add story --- .../react-aria-components/src/ListBox.tsx | 2 +- .../stories/ListBox.stories.tsx | 60 ++++++++++++++ .../test/ListBox.test.js | 80 +++++++++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index a728276303c..636b0141c43 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -381,7 +381,7 @@ export const ListBoxItem = /*#__PURE__*/ createLeafComponent(ItemNode, function let draggableItem: DraggableItemResult | null = null; if (dragState && dragAndDropHooks) { - draggableItem = dragAndDropHooks.useDraggableItem!({key: item.key}, dragState); + draggableItem = dragAndDropHooks.useDraggableItem!({key: item.key, hasAction: states.hasAction}, dragState); } let droppableItem: DroppableItemResult | null = null; diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index 74a049009ac..ddec7357799 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -748,3 +748,63 @@ AsyncListBoxVirtualized.story = { delay: 50 } }; + +export let VirtualizedListBoxDndOnAction: ListBoxStory = () => { + let items: {id: number, name: string}[] = []; + for (let i = 0; i < 100; i++) { + items.push({id: i, name: `Item ${i}`}); + } + + let list = useListData({ + initialItems: items + }); + + let {dragAndDropHooks} = useDragAndDrop({ + getItems: (keys) => { + return [...keys].map(key => ({'text/plain': list.getItem(key)?.name ?? ''})); + }, + onReorder(e) { + if (e.target.dropPosition === 'before') { + list.moveBefore(e.target.key, e.keys); + } else if (e.target.dropPosition === 'after') { + list.moveAfter(e.target.key, e.keys); + } + }, + renderDropIndicator(target) { + return ({width: '100%', height: 2, background: isDropTarget ? 'blue' : 'gray', margin: '2px 0'})} />; + } + }); + + return ( +
+
+

Instructions:

+
    +
  • Enter: Triggers onAction
  • +
  • Alt+Enter: Starts drag mode
  • +
  • Space: Toggles selection
  • +
+
+
+ + + {item => {item.name}} + + +
+
+ ); +}; + diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index b0173fc5a03..4d55d7ed3da 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -1242,6 +1242,86 @@ describe('ListBox', () => { keyPress('Escape'); act(() => jest.runAllTimers()); }); + + it('should support onAction with drag and drop in virtualized list', async () => { + let items = []; + for (let i = 0; i < 20; i++) { + items.push({id: i, name: 'Item ' + i}); + } + + jest.restoreAllMocks(); + jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); + jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); + + let onAction = jest.fn(); + let onReorder = jest.fn(); + + function VirtualizedDraggableListBox() { + let {dragAndDropHooks} = useDragAndDrop({ + getItems: (keys) => [...keys].map((key) => ({'text/plain': key})), + onReorder, + renderDropIndicator: (target) => Drop + }); + + return ( + + + {item => {item.name}} + + + ); + } + + let {getAllByRole} = render(); + let options = getAllByRole('option'); + + // Focus first item + await user.tab(); + expect(document.activeElement).toBe(options[0]); + + // Pressing Enter should trigger onAction, and not start drag + keyPress('Enter'); + act(() => jest.runAllTimers()); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenCalledWith(0); + expect(onReorder).not.toHaveBeenCalled(); + + // Should not be in drag mode + options = getAllByRole('option'); + expect(options.filter(opt => opt.classList.contains('react-aria-DropIndicator'))).toHaveLength(0); + + // Now test that Alt+Enter starts drag mode + expect(document.activeElement).toBe(options[0]); + fireEvent.keyDown(document.activeElement, {key: 'Enter', altKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'Enter', altKey: true}); + act(() => jest.runAllTimers()); + + // Verify we're in drag mode + options = getAllByRole('option'); + let dropIndicators = options.filter(opt => opt.classList.contains('react-aria-DropIndicator')); + expect(dropIndicators.length).toBeGreaterThan(0); + expect(document.activeElement).toHaveAttribute('aria-label'); + expect(document.activeElement.getAttribute('aria-label')).toContain('Insert'); + + // onAction should not have been called again + expect(onAction).toHaveBeenCalledTimes(1); + + // Complete the drop + keyPress('ArrowDown'); + expect(document.activeElement.getAttribute('aria-label')).toContain('Insert'); + keyPress('Enter'); + act(() => jest.runAllTimers()); + + expect(onReorder).toHaveBeenCalledTimes(1); + + // Verify we're no longer in drag mode + options = getAllByRole('option'); + expect(options.filter(opt => opt.classList.contains('react-aria-DropIndicator'))).toHaveLength(0); + }); }); describe('inside modals', () => { From 3949d9591d615714812d77dfa3ed23d6f92e9eca Mon Sep 17 00:00:00 2001 From: chirokas <157580465+chirokas@users.noreply.github.com> Date: Thu, 6 Nov 2025 03:43:02 +0800 Subject: [PATCH 3/4] fix: createLeafComponent type (#9133) --- packages/@react-aria/collections/src/CollectionBuilder.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/collections/src/CollectionBuilder.tsx b/packages/@react-aria/collections/src/CollectionBuilder.tsx index 8c8a7bd12d3..e7585b8ab81 100644 --- a/packages/@react-aria/collections/src/CollectionBuilder.tsx +++ b/packages/@react-aria/collections/src/CollectionBuilder.tsx @@ -165,8 +165,9 @@ function useSSRCollectionNode(CollectionNodeClass: Collection return {children}; } -export function createLeafComponent(CollectionNodeClass: CollectionNodeClass | string, render: (props: P, ref: ForwardedRef) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; -export function createLeafComponent(CollectionNodeClass: CollectionNodeClass | string, render: (props: P, ref: ForwardedRef, node: Node) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function createLeafComponent(CollectionNodeClass: CollectionNodeClass | string, render: (props: P, ref: ForwardedRef) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; +export function createLeafComponent(CollectionNodeClass: CollectionNodeClass | string, render: (props: P, ref: ForwardedRef, node: Node) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null; export function createLeafComponent

(CollectionNodeClass: CollectionNodeClass | string, render: (props: P, ref: ForwardedRef, node?: any) => ReactElement | null): (props: P & React.RefAttributes) => ReactElement | null { let Component = ({node}) => render(node.props, node.props.ref, node); let Result = (forwardRef as forwardRefType)((props: P, ref: ForwardedRef) => { From 8afa735f8c5a2d29d19a7230168c8fc74e0633bc Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Wed, 5 Nov 2025 17:24:47 -0600 Subject: [PATCH 4/4] docs: migrate blog posts (#9125) * init blog posts * more blog post content * more blog edits * add hero image to introducing-react-spectrum.mdx * add remaining videos * fix DragBetweenListsExample to use RAC * Revert "add remaining videos" This reverts commit 4e436bbb1d12cbbb7e54abdf435f2eb3e6189d01. * add posts to 'Blog' section * Revert "Revert "add remaining videos"" This reverts commit 6d2b667f7d0b903a0a938f9dfb1e9884c9ed4dbf. * fix broken links * fix video widths * add bylines * fix layout * fix nested p tags * fix video widths * improve DnD example * fix caption fonts * fix remaining font issues --- .../docs/pages/blog/building-a-combobox.mdx | 6 +- .../internationalized/number/NumberParser.mdx | 2 +- .../dev/s2-docs/pages/react-aria/Button.mdx | 2 +- .../pages/react-aria/blog/CalendarSystems.tsx | 37 ++ .../blog/DragBetweenListsExample.tsx | 126 +++++++ .../react-aria/blog/RangeCalendarExample.tsx | 27 ++ .../react-aria/blog/SubmenuAnimation.tsx | 357 ++++++++++++++++++ .../blog/accessible-color-descriptions.mdx | 186 +++++++++ .../blog/building-a-button-part-1.mdx | 120 ++++++ .../blog/building-a-button-part-2.mdx | 88 +++++ .../blog/building-a-button-part-3.mdx | 82 ++++ .../react-aria/blog/building-a-combobox.mdx | 150 ++++++++ ...-a-pointer-friendly-submenu-experience.mdx | 118 ++++++ .../blog/date-and-time-pickers-for-all.mdx | 192 ++++++++++ .../pages/react-aria/blog/drag-and-drop.mdx | 119 ++++++ ...w-we-internationalized-our-numberfield.mdx | 161 ++++++++ .../s2-docs/pages/react-aria/blog/index.mdx | 23 ++ .../blog/introducing-react-spectrum.mdx | 170 +++++++++ .../pages/react-aria/blog/rtl-date-time.mdx | 150 ++++++++ packages/dev/s2-docs/src/BlogList.tsx | 42 +++ packages/dev/s2-docs/src/Layout.tsx | 11 + 21 files changed, 2164 insertions(+), 5 deletions(-) create mode 100644 packages/dev/s2-docs/pages/react-aria/blog/CalendarSystems.tsx create mode 100644 packages/dev/s2-docs/pages/react-aria/blog/DragBetweenListsExample.tsx create mode 100644 packages/dev/s2-docs/pages/react-aria/blog/RangeCalendarExample.tsx create mode 100644 packages/dev/s2-docs/pages/react-aria/blog/SubmenuAnimation.tsx create mode 100644 packages/dev/s2-docs/pages/react-aria/blog/accessible-color-descriptions.mdx create mode 100644 packages/dev/s2-docs/pages/react-aria/blog/building-a-button-part-1.mdx create mode 100644 packages/dev/s2-docs/pages/react-aria/blog/building-a-button-part-2.mdx create mode 100644 packages/dev/s2-docs/pages/react-aria/blog/building-a-button-part-3.mdx create mode 100644 packages/dev/s2-docs/pages/react-aria/blog/building-a-combobox.mdx create mode 100644 packages/dev/s2-docs/pages/react-aria/blog/creating-a-pointer-friendly-submenu-experience.mdx create mode 100644 packages/dev/s2-docs/pages/react-aria/blog/date-and-time-pickers-for-all.mdx create mode 100644 packages/dev/s2-docs/pages/react-aria/blog/drag-and-drop.mdx create mode 100644 packages/dev/s2-docs/pages/react-aria/blog/how-we-internationalized-our-numberfield.mdx create mode 100644 packages/dev/s2-docs/pages/react-aria/blog/index.mdx create mode 100644 packages/dev/s2-docs/pages/react-aria/blog/introducing-react-spectrum.mdx create mode 100644 packages/dev/s2-docs/pages/react-aria/blog/rtl-date-time.mdx create mode 100644 packages/dev/s2-docs/src/BlogList.tsx diff --git a/packages/dev/docs/pages/blog/building-a-combobox.mdx b/packages/dev/docs/pages/blog/building-a-combobox.mdx index 81901e5ecb1..e45f0fb393f 100644 --- a/packages/dev/docs/pages/blog/building-a-combobox.mdx +++ b/packages/dev/docs/pages/blog/building-a-combobox.mdx @@ -19,14 +19,14 @@ export default BlogPostLayout; --- keywords: [combobox, accessibility, mobile, react spectrum, react, spectrum, interactions, touch] -description: After many months of research, development, and testing, we’re excited to announce that the React Spectrum [ComboBox](../react-spectrum/ComboBox.html) component and React Aria [useComboBox](../react-aria/useComboBox.html) hook are now available! In this post we'll take a deeper look into some of the challenges we faced when building an accessible and mobile friendly ComboBox. +description: After many months of research, development, and testing, we’re excited to announce that the React Spectrum [ComboBox](../../s2/ComboBox.html) component and React Aria [useComboBox](../useComboBox.html) hook are now available! In this post we'll take a deeper look into some of the challenges we faced when building an accessible and mobile friendly ComboBox. date: 2021-07-13 author: '[Daniel Lu](https://github.com/LFDanLu)' --- # Creating an accessible autocomplete experience -After many months of research, development, and extensive testing across browsers, devices, and assistive technology, we’re excited to announce that the React Spectrum [ComboBox](../react-spectrum/ComboBox.html) component and React Aria [useComboBox](../react-aria/useComboBox.html) hook are now available! We’ve focused on the following areas to help you build quality autocomplete experiences. +After many months of research, development, and extensive testing across browsers, devices, and assistive technology, we’re excited to announce that the React Spectrum [ComboBox](../../s2/ComboBox.html) component and React Aria [useComboBox](../useComboBox.html) hook are now available! We’ve focused on the following areas to help you build quality autocomplete experiences. - **Accessibility** — Our ComboBox has been tested with screen readers across desktop and mobile devices, and with many different input methods including mouse, touch, and keyboard. We encountered many screen reader differences, and worked hard to ensure announcements are clear and consistent. - **Mobile** — On small screens, the React Spectrum ComboBox is automatically displayed in a tray, which improves the user experience by giving them a larger area to scroll. We also optimized our experience for touch screen interactions and on screen keyboards. @@ -133,7 +133,7 @@ Special care was taken such that the messages themselves only contained relevant When the user then moves to a different option in the same section, only the newly focused item name is announced. Similarly, the total option count is only announced when number of options available in the listbox changes. Since many of these messages were added to fill in gaps in VoiceOver's announcement, we only trigger the `LiveAnnouncer` on Apple devices to avoid announcement overlap on other screen readers. -If you are interested in using this `LiveAnnouncer` yourself, check out [LiveAnnouncer](https://github.com/adobe/react-spectrum/blob/main/packages/@react-aria/live-announcer/src/LiveAnnouncer.tsx) in `@react-aria/live-announcer`. Otherwise, the [useComboBox](../react-aria/useComboBox.html) hook provides you with all of the custom messaging out of the box. See the video below for a sneak peek! +If you are interested in using this `LiveAnnouncer` yourself, check out [LiveAnnouncer](https://github.com/adobe/react-spectrum/blob/main/packages/@react-aria/live-announcer/src/LiveAnnouncer.tsx) in `@react-aria/live-announcer`. Otherwise, the [useComboBox](../useComboBox.html) hook provides you with all of the custom messaging out of the box. See the video below for a sneak peek!

+ + {item => {item.name}} + + + + +
+ ) +} \ No newline at end of file diff --git a/packages/dev/s2-docs/pages/react-aria/blog/DragBetweenListsExample.tsx b/packages/dev/s2-docs/pages/react-aria/blog/DragBetweenListsExample.tsx new file mode 100644 index 00000000000..389bb92f226 --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/blog/DragBetweenListsExample.tsx @@ -0,0 +1,126 @@ +'use client'; + +import {ListBox, ListBoxItem} from 'vanilla-starter/ListBox'; +import {Folder, File} from 'lucide-react'; +import {useDragAndDrop, isTextDropItem} from 'react-aria-components'; +import {useListData} from 'react-stately'; +import React from 'react'; + +function BidirectionalDnDListBox(props) { + let {list} = props; + let {dragAndDropHooks} = useDragAndDrop({ + acceptedDragTypes: ['custom-app-type-bidirectional'], + // Only allow move operations + getAllowedDropOperations: () => ['move'], + getItems(keys) { + return [...keys].map(key => { + let item = list.getItem(key); + // Setup the drag types and associated info for each dragged item. + return { + 'custom-app-type-bidirectional': JSON.stringify(item), + 'text/plain': item.name + }; + }); + }, + onInsert: async (e) => { + let { + items, + target + } = e; + let processedItems = await Promise.all( + items + .filter(isTextDropItem) + .map(async item => JSON.parse(await item.getText('custom-app-type-bidirectional'))) + ); + if (target.dropPosition === 'before') { + list.insertBefore(target.key, ...processedItems); + } else if (target.dropPosition === 'after') { + list.insertAfter(target.key, ...processedItems); + } + }, + onReorder: (e) => { + let { + keys, + target + } = e; + + if (target.dropPosition === 'before') { + list.moveBefore(target.key, [...keys]); + } else if (target.dropPosition === 'after') { + list.moveAfter(target.key, [...keys]); + } + }, + onRootDrop: async (e) => { + let { + items + } = e; + let processedItems = await Promise.all( + items + .filter(isTextDropItem) + .map(async item => JSON.parse(await item.getText('custom-app-type-bidirectional'))) + ); + list.append(...processedItems); + }, + /*- begin highlight -*/ + onDragEnd: (e) => { + let { + dropOperation, + keys, + isInternal + } = e; + // Only remove the dragged items if they aren't dropped inside the source list + if (dropOperation === 'move' && !isInternal) { + list.remove(...keys); + } + } + /*- end highlight -*/ + }); + + return ( + + {item => ( + + {item.type === 'folder' ? : } + {item.name} + + )} + + ); +} + +export default function DragBetweenListsExample() { + let list1 = useListData({ + initialItems: [ + {id: '1', type: 'file', name: 'Adobe Photoshop'}, + {id: '2', type: 'file', name: 'Adobe XD'}, + {id: '3', type: 'folder', name: 'Documents'}, + {id: '4', type: 'file', name: 'Adobe InDesign'}, + {id: '5', type: 'folder', name: 'Utilities'}, + {id: '6', type: 'file', name: 'Adobe AfterEffects'} + ] + }); + + let list2 = useListData({ + initialItems: [ + {id: '7', type: 'folder', name: 'Pictures'}, + {id: '8', type: 'file', name: 'Adobe Fresco'}, + {id: '9', type: 'folder', name: 'Apps'}, + {id: '10', type: 'file', name: 'Adobe Illustrator'}, + {id: '11', type: 'file', name: 'Adobe Lightroom'}, + {id: '12', type: 'file', name: 'Adobe Dreamweaver'} + ] + }); + + + return ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/packages/dev/s2-docs/pages/react-aria/blog/RangeCalendarExample.tsx b/packages/dev/s2-docs/pages/react-aria/blog/RangeCalendarExample.tsx new file mode 100644 index 00000000000..97f914f95cc --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/blog/RangeCalendarExample.tsx @@ -0,0 +1,27 @@ +'use client'; + +import {today, getLocalTimeZone} from '@internationalized/date'; +import {RangeCalendar} from '@react-spectrum/s2'; +import React from 'react'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +export default function RangeCalendarExample() { + let now = today(getLocalTimeZone()).set({day: 8}); + let disabledRanges = [ + [now, now.add({days: 2})], + [now.add({days: 10}), now.add({days: 14})], + [now.add({days: 23}), now.add({days: 28})], + ]; + + let isDateUnavailable = (date) => disabledRanges.some((interval) => date.compare(interval[0]) >= 0 && date.compare(interval[1]) <= 0); + + return ( +
+ +
+ ); +} diff --git a/packages/dev/s2-docs/pages/react-aria/blog/SubmenuAnimation.tsx b/packages/dev/s2-docs/pages/react-aria/blog/SubmenuAnimation.tsx new file mode 100644 index 00000000000..b4fb3b27dfa --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/blog/SubmenuAnimation.tsx @@ -0,0 +1,357 @@ +/* + * Copyright 2024 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 {animate} from '../../../../docs/pages/react-aria/home/utils'; +import React, {JSX, useEffect, useRef, useState} from 'react'; +import {useResizeObserver} from '@react-aria/utils'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +export function SubmenuAnimation(): JSX.Element { + let ref = useRef(null); + let [isSubmenuOpen, setIsSubmenuOpen] = useState(false); + let [hovered, setHovered] = useState('Option 1'); + let isAnimating = useRef(false); + let mouseRef = useRef(null); + let [mouseWidth, setMouseWidth] = useState(12); + let option1Ref = useRef(null); + let option2Ref = useRef(null); + let submenuOptionRef = useRef(null); + + let updateWidth = () => { + if (ref.current) { + setMouseWidth(Math.min(12, (window.innerWidth / 768) * 12)); + } + }; + + useResizeObserver({ref: ref, onResize: updateWidth}); + + useEffect(() => { + let startAnimation = () => { + let cancel = animate([ + { + time: 500, + perform() { + setTimeout(() => { + setHovered('Option 1'); + }, 500); + } + }, + { + time: 700, + perform() { + let option1Rect = option1Ref.current!.getBoundingClientRect(); + let option2Rect = option2Ref.current!.getBoundingClientRect(); + let x = option1Rect.left + option1Rect.width / 2 - ref.current!.getBoundingClientRect().left; + let y = option1Rect.top + option1Rect.height / 2 - ref.current!.getBoundingClientRect().top; + let y_target = option2Rect.top + option2Rect.height / 2 - ref.current!.getBoundingClientRect().top; + mouseRef.current!.animate({ + transform: [ + `translate(${x}px, ${y}px)`, + `translate(${x}px, ${y_target}px)` + ] + }, {duration: 1000, fill: 'forwards', easing: 'ease-in-out'}); + setTimeout(() => { + setHovered('Option 2'); + setIsSubmenuOpen(true); + }, 350); + } + }, + { + time: 700, + perform() {} + }, + { + time: 700, + perform() { + let option1Rect = option1Ref.current!.getBoundingClientRect(); + let option2Rect = option2Ref.current!.getBoundingClientRect(); + let submenuOptionRect = submenuOptionRef.current!.getBoundingClientRect(); + let x = option1Rect.left + option1Rect.width / 2 - ref.current!.getBoundingClientRect().left; + let y = option2Rect.top + option2Rect.height / 2 - ref.current!.getBoundingClientRect().top; + let x_target = submenuOptionRect.left + submenuOptionRect.width / 2 - ref.current!.getBoundingClientRect().left; + let y_target = submenuOptionRect.top + submenuOptionRect.height / 2 - ref.current!.getBoundingClientRect().top; + mouseRef.current!.animate({ + transform: [ + `translate(${x}px, ${y}px)`, + `translate(${x_target}px, ${y_target}px)` + ] + }, {duration: 1000, fill: 'forwards', easing: 'ease-in-out'}); + setTimeout(() => { + setHovered('Option 3'); + setIsSubmenuOpen(false); + }, 350); + } + }, + { + time: 700, + perform() {} + } + ]); + + return () => { + cancel(); + setIsSubmenuOpen(false); + setHovered('Option 1'); + mouseRef.current!.getAnimations().forEach(a => a.cancel()); + isAnimating.current = false; + }; + }; + + startAnimation(); + + let interval = setInterval(startAnimation, 4000); + return () => clearInterval(interval); + }, []); + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {'More Actions'} + + + + + + {'Option 3'} + + + + + {'Option 2'} + + + + + {'Option 1'} + + + {hovered === 'Option 1' && ( + + + + + )} + {hovered === 'Option 2' && ( + + + + + )} + {hovered === 'Option 3' && ( + + + + + )} + {isSubmenuOpen && ( + <> + + + + + + + + + {'Submenu Option 3'} + + + + + {'Submenu Option 4'} + + + + + {'Submenu Option 2'} + + + + + {'Submenu Option 1'} + + + + )} + + + +
+ ); +} diff --git a/packages/dev/s2-docs/pages/react-aria/blog/accessible-color-descriptions.mdx b/packages/dev/s2-docs/pages/react-aria/blog/accessible-color-descriptions.mdx new file mode 100644 index 00000000000..9f959fbb21b --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/blog/accessible-color-descriptions.mdx @@ -0,0 +1,186 @@ +{/* 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. */} + +import bundleSizeImageUrl from 'url:../../../../docs/pages/assets/bundle-size.webp'; +import initialVideoUrl from 'url:../../../../docs/pages/assets/color-picker-initial.mp4'; +import finalVideoUrl from 'url:../../../../docs/pages/assets/color-picker-final.mp4'; + +import {Layout} from '../../../src/Layout'; +export default Layout; + +import docs from 'docs:@react-spectrum/s2'; +import React from 'react'; +import {Byline} from '../../../src/BlogList'; + +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.'; +export const date = '2024-10-02'; +export const author = 'Devon Govett'; +export const authorLink = 'https://x.com/devongovett'; +export const section = 'Blog'; +export const isSubpage = true; + +# Accessible Color Descriptions for Improved Color Pickers + + +Recently, we released a suite of color picker components in React Aria and React Spectrum. These components help users choose a color in various ways, including a 2D [ColorArea](../ColorArea.html), channel-based [ColorSlider](../ColorSlider.html), circular [ColorWheel](../ColorWheel.html), preset [ColorSwatchPicker](../ColorSwatchPicker.html), and a hex value [ColorField](../ColorField.html). You can compose these individual pieces together to create a full [ColorPicker](../ColorPicker.html) with whatever custom layout or configuration you need. + +## Initial accessibility experience + +Accessibility is at the core of all of our work on the React Spectrum team, and ColorPicker was no exception. However, these components presented a significant challenge: colors are inherently visual, so how should we make them accessible for users with visual impairments? + +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! + +