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) => { 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); 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! + +