diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index 4f6e6a9fbd2..6e5c8673fb4 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -51,7 +51,7 @@ import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; import {Virtualizer} from 'react-aria-components/Virtualizer'; -export interface ListViewProps extends Omit, 'className' | 'style' | 'children' | 'selectionBehavior' | 'dragAndDropHooks' | 'layout' | 'render' | 'keyboardNavigationBehavior' | keyof GlobalDOMAttributes>, DOMProps, UnsafeStyles, ListViewStylesProps, SlotProps { +export interface ListViewProps extends Omit, 'className' | 'style' | 'children' | 'selectionBehavior' | 'dragAndDropHooks' | 'layout' | 'render' | 'keyboardNavigationBehavior' | 'orientation' | keyof GlobalDOMAttributes>, DOMProps, UnsafeStyles, ListViewStylesProps, SlotProps { /** Spectrum-defined styles, returned by the `style()` macro. */ styles?: StylesPropWithHeight, /** The current loading state of the ListView. */ @@ -864,4 +864,3 @@ export function ListViewItem(props: ListViewItemProps): ReactNode { ); } - diff --git a/packages/dev/s2-docs/pages/react-aria/GridList.mdx b/packages/dev/s2-docs/pages/react-aria/GridList.mdx index 0be7e40e264..28a89997092 100644 --- a/packages/dev/s2-docs/pages/react-aria/GridList.mdx +++ b/packages/dev/s2-docs/pages/react-aria/GridList.mdx @@ -672,6 +672,43 @@ function Example(props) { } ``` +## Layouts + +Use the `layout` and `orientation` props to arrange items in horizontal and vertical stacks and grids. This affects keyboard navigation and drag and drop behavior. + +```tsx render docs={docs.exports.GridList} links={docs.links} props={['layout', 'orientation', 'keyboardNavigationBehavior']} initialProps={{layout: 'grid', orientation: 'horizontal', keyboardNavigationBehavior: 'tab'}} wide +"use client"; +import {Text} from 'react-aria-components'; +import {GridList, GridListItem} from 'vanilla-starter/GridList'; + +///- begin collapse -/// +let photos = [ + {id: 1, title: 'Desert Sunset', description: 'PNG • 2/3/2024', src: 'https://images.unsplash.com/photo-1705034598432-1694e203cdf3?q=80&w=600&auto=format&fit=crop'}, + {id: 2, title: 'Hiking Trail', description: 'JPEG • 1/10/2022', src: 'https://images.unsplash.com/photo-1722233987129-61dc344db8b6?q=80&w=600&auto=format&fit=crop'}, + {id: 3, title: 'Lion', description: 'JPEG • 8/28/2021', src: 'https://images.unsplash.com/photo-1629812456605-4a044aa38fbc?q=80&w=600&auto=format&fit=crop'}, + {id: 4, title: 'Mountain Sunrise', description: 'PNG • 3/15/2015', src: 'https://images.unsplash.com/photo-1722172118908-1a97c312ce8c?q=80&w=600&auto=format&fit=crop'}, + {id: 5, title: 'Giraffe tongue', description: 'PNG • 11/27/2019', src: 'https://images.unsplash.com/photo-1574870111867-089730e5a72b?q=80&w=600&auto=format&fit=crop'}, + {id: 6, title: 'Golden Hour', description: 'WEBP • 7/24/2024', src: 'https://images.unsplash.com/photo-1718378037953-ab21bf2cf771?q=80&w=600&auto=format&fit=crop'}, +]; +///- end collapse -/// + + + {item => ( + + + {item.title} + {item.description} + + )} + +``` + ## Drag and drop GridList supports drag and drop interactions when the `dragAndDropHooks` prop is provided using the hook. Users can drop data on the list as a whole, on individual items, insert new items between existing ones, or reorder items. React Aria supports drag and drop via mouse, touch, keyboard, and screen reader interactions. See the [drag and drop guide](dnd?component=GridList) to learn more. diff --git a/packages/dev/s2-docs/pages/react-aria/Virtualizer.mdx b/packages/dev/s2-docs/pages/react-aria/Virtualizer.mdx index c6663a908ad..a24c1187cec 100644 --- a/packages/dev/s2-docs/pages/react-aria/Virtualizer.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Virtualizer.mdx @@ -12,7 +12,7 @@ export const description = 'Renders a scrollable collection of data using custom {docs.exports.Virtualizer.description} -```tsx render docs={docs.exports.ListLayoutOptions} links={docs.links} props={['rowHeight', 'gap', 'padding']} initialProps={{rowHeight: 32, gap: 4, padding: 4}} propsObject="layoutOptions" wide +```tsx render docs={docs.exports.ListLayoutOptions} links={docs.links} props={['rowSize', 'gap', 'padding']} initialProps={{rowSize: 32, gap: 4, padding: 4}} propsObject="layoutOptions" wide "use client"; import {Virtualizer, ListLayout} from 'react-aria-components'; import {ListBox, ListBoxItem} from 'vanilla-starter/ListBox'; @@ -49,9 +49,9 @@ Virtualizer uses obje ### List -`ListLayout` supports layout of items in a vertical stack. Rows can be fixed or variable height. When using variable heights, set the `estimatedRowHeight` to a reasonable guess for how tall the rows will be on average. This allows the size of the scrollbar to be calculated. +`ListLayout` places items along its orientation. Rows can be fixed or variable in size. When using a variable size, set the `estimatedRowSize` to a reasonable guess for how tall or wide the rows will be on average. This allows the size of the scrollbar to be calculated. -```tsx render docs={docs.exports.ListLayoutOptions} links={docs.links} props={['gap', 'padding']} initialProps={{estimatedRowHeight: 75, gap: 4, padding: 4}} propsObject="layoutOptions" wide +```tsx render docs={docs.exports.ListLayoutOptions} links={docs.links} props={['gap', 'padding']} initialProps={{estimatedRowSize: 75, gap: 4, padding: 4}} propsObject="layoutOptions" wide "use client"; import {Virtualizer, ListLayout} from 'react-aria-components'; import {ListBox, ListBoxItem} from 'vanilla-starter/ListBox'; @@ -80,6 +80,256 @@ for (let i = 0; i < 5000; i++) { ``` + +Use the `orientation` option to arrange items horizontally or vertically. Provide the same `orientation` on the collection component so keyboard navigation matches the layout. + +```tsx render docs={docs.exports.ListLayoutOptions} links={docs.links} props={['gap', 'padding']} initialProps={{estimatedRowSize: 100, gap: 8, padding: 8, orientation: 'horizontal'}} propsObject="layoutOptions" wide +"use client"; +import {Virtualizer, ListLayout} from 'react-aria-components'; +import {ListBox, ListBoxItem} from 'vanilla-starter/ListBox'; + +///- begin collapse -/// +let imageOptions = [ + { + "id": "8SXaMMWCTGc", + "title": "A Ficus Lyrata Leaf in the sunlight (2/2) (IG: @clay.banks)", + "user": "Clay Banks", + "image": "https://images.unsplash.com/photo-1580133318324-f2f76d987dd8?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "0.6666" + }, + { + "id": "pYjCqqDEOFo", + "title": "beach of Italy", + "user": "alan bajura", + "image": "https://images.unsplash.com/photo-1737100522891-e8946ac97fd1?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "0.6666666666666666" + }, + { + "id": "CF-2tl6MQj0", + "title": "A winding road in the middle of a forest", + "user": "Artem Stoliar", + "image": "https://images.unsplash.com/photo-1738249034651-1896f689be58?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "1.3333333333333333" + }, + { + "id": "OW97sLU0cOw", + "title": "A green and purple aurora over a snow covered forest", + "user": "Janosch Diggelmann", + "image": "https://images.unsplash.com/photo-1738189669835-61808a9d5981?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "0.6669921875" + }, + { + "id": "WfeLZ02IhkM", + "title": "A blue and white firework is seen from above", + "user": "Janosch Diggelmann", + "image": "https://images.unsplash.com/photo-1738168601630-1c1f3ef5a95a?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "1.3353596757852078" + }, + { + "id": "w1GpST72Bg8", + "title": "A snow covered mountain with a sky background", + "user": "Daniil Silantev", + "image": "https://images.unsplash.com/photo-1738165170747-ecc6e3a4d97c?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "1.4978580171358629" + }, + { + "id": "0iN0KIt6lYI", + "title": "\"Pastel Sunset\"", + "user": "Marek Piwnicki", + "image": "https://images.unsplash.com/photo-1737917818689-f3b3708de5d7?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "0.6249763481551561" + }, + { + "id": "-mFKPfXXUG0", + "title": "Leave the weight behind! You must make yourself light to strive upwards — to reach the light. (A serene winter landscape featuring a dense collection of bare, white trees.)", + "user": "Simon Berger", + "image": "https://images.unsplash.com/photo-1737972970322-cc2e255021bd?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "1" + }, + { + "id": "MOk6URQ28R4", + "title": "A snow covered tree with a sky background", + "user": "Daniil Silantev", + "image": "https://images.unsplash.com/photo-1738081359113-a7a33c509cf9?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "0.666598611678236" + }, + { + "id": "y36Nj_edtRE", + "title": "A lake surrounded by trees covered in snow", + "user": "Daniel Seßler", + "image": "https://images.unsplash.com/photo-1736018545810-3de4c7ec25fa?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "0.667" + }, + { + "id": "NvBV-YwlgBw", + "title": "The night sky with stars above a rock formation", + "user": "Dennis Haug", + "image": "https://images.unsplash.com/photo-1735528655501-cf671a3323c3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "1" + }, + { + "id": "UthQdrPFxt0", + "title": "A pine tree covered in snow in a forest", + "user": "Anita Austvika", + "image": "https://images.unsplash.com/photo-1737312905026-5dfdff1097bc?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "0.6666666666666666" + }, + { + "id": "2k74xaf8dfc", + "title": "The sun shines through the trees in the forest", + "user": "Joyce G", + "image": "https://images.unsplash.com/photo-1736185597807-371cae1c7e4e?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "0.6666666666666666" + }, + { + "id": "Yje5kgfvCm0", + "title": "A blurry photo of a field of flowers", + "user": "Eugene Golovesov", + "image": "https://images.unsplash.com/photo-1736483065204-e55e62092780?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "0.6661569826707442" + }, + { + "id": "G2bsj2LVttI", + "title": "A foggy road lined with trees and grass", + "user": "Ingmar H", + "image": "https://images.unsplash.com/photo-1737903071772-4d20348b4d81?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "0.7499509707785841" + }, + { + "id": "ppyNBOkfiuY", + "title": "A close up of a green palm tree", + "user": "Junel Mujar", + "image": "https://images.unsplash.com/photo-1736849544918-6ddb5cfc2c42?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "0.7507507507507507" + }, + { + "id": "UcWUMqIsld8", + "title": "A green leaf floating on top of a body of water", + "user": "Allec Gomes", + "image": "https://images.unsplash.com/photo-1737559217439-a5703e9b65cb?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "0.6666666666666666" + }, + { + "id": "xHqOVq9w8OI", + "title": "green-leafed plant", + "user": "Joshua Michaels", + "image": "https://images.unsplash.com/photo-1563364664-399838d1394c?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "1.504" + }, + { + "id": "uWx3_XEc-Jw", + "title": "A view of a mountain covered in fog", + "user": "iuliu illes", + "image": "https://images.unsplash.com/photo-1737403428945-c584529b7b17?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "1.3430962343096233" + }, + { + "id": "2_3lhGt8i-Y", + "title": "A field with tall grass and fog in the background", + "user": "Ingmar H", + "image": "https://images.unsplash.com/photo-1737439987404-a3ee9fb95351?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "0.6666666666666666" + }, + { + "id": "FV-__IOxb08", + "title": "A close up of a wave on a sandy beach", + "user": "Jonathan Borba", + "image": "https://images.unsplash.com/photo-1726502102472-2108ef2a5cae?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "0.6666666666666666" + }, + { + "id": "_BS-vK3boOU", + "title": "Desert textures", + "user": "Braden Jarvis", + "image": "https://images.unsplash.com/photo-1722359546494-8e3a00f88e95?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "0.7135258358662614" + }, + { + "id": "LjAcS9lJdBg", + "title": "Tew Falls, waterfall, in Hamilton, Canada.", + "user": "Andre Portolesi", + "image": "https://images.unsplash.com/photo-1705021246536-aecfad654893?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "0.8" + }, + { + "id": "hlj6xJG30FE", + "title": "Find me on Instagram! @intricateexplorer", + "user": "Intricate Explorer", + "image": "https://images.unsplash.com/photo-1631641551473-fbe46919289d?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "1.4992510164776376" + }, + { + "id": "vMoZvKeZOhw", + "title": "Salt Marshes, Isle of Harris, Scotland by Nils Leonhardt. Visit my website: https://nilsleonhardt.com/storytelling-harris/ Instagram: @am.basteir", + "user": "Nils Leonhardt", + "image": "https://images.unsplash.com/photo-1585951301678-8fd6f3b32c7e?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "0.6666666666666666" + }, + { + "id": "wCLCK9LDDjI", + "title": "An aerial view of a snow covered forest", + "user": "Lukas Hädrich", + "image": "https://images.unsplash.com/photo-1737405555489-78b3755eaa81?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "1.5" + }, + { + "id": "OdDx3_NB-Wk", + "title": "A close up of a tall grass with a sky in the background", + "user": "Ingmar H", + "image": "https://images.unsplash.com/photo-1737301519296-062cd324dbfa?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "0.6666666666666666" + }, + { + "id": "Gn-FOw1geFc", + "title": "Larches on Maple Pass, Washington", + "user": "noelle", + "image": "https://images.unsplash.com/photo-1737496538329-a59d10148a08?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "0.6666666666666666" + }, + { + "id": "VhKJHOz2tJ8", + "title": "IC 1805 La nébuleuse du coeur", + "user": "arnaud girault", + "image": "https://images.unsplash.com/photo-1737478598284-b9bc11cb1e9b?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "1.504158004158004" + }, + { + "id": "w5QmH_uqB0U", + "title": "A pile of shells sitting on top of a sandy beach", + "user": "Toa Heftiba", + "image": "https://images.unsplash.com/photo-1725366351350-a64a1be919ef?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wzNDA4NDh8MHwxfHRvcGljfHw2c01WalRMU2tlUXx8fHx8Mnx8MTczODM2NzE4M3w&ixlib=rb-4.0.3&q=80&w=400", + "aspectRatio": "0.6666666666666666" + } +]; +///- end collapse -/// + +for (let i = 0; imageOptions.length < 500; i++) { + imageOptions.push({...imageOptions[i % 30], id: String(i)}); +} + + + {/*- end highlight -*/} + + {(item) => ( + + + + )} + + +``` + ### Grid `GridLayout` supports layout of items in an equal size grid. The items are sized between a minimum and maximum size depending on the width of the container. Make sure to set `layout="grid"` on the `ListBox` or `GridList` component as well so that keyboard navigation behavior is correct. @@ -426,7 +676,7 @@ for (let i = 0; images.length < 500; i++) { `TableLayout` provides layout of items in rows and columns, supporting virtualization of both horizontal and vertical scrolling. It should be used with the [Table](Table) component. Rows can be fixed or variable height. When using variable heights, set the `estimatedRowHeight` to a reasonable guess for how tall the rows will be on average. This allows the size of the scrollbar to be calculated. -```tsx render docs={docs.exports.ListLayoutOptions} links={docs.links} props={['rowHeight', 'headingHeight', 'padding', 'gap']} initialProps={{rowHeight: 32, headingHeight: 32, padding: 4, gap: 4}} propsObject="layoutOptions" wide +```tsx render docs={docs.exports.TableLayoutProps} links={docs.links} props={['rowHeight', 'headingHeight', 'padding', 'gap']} initialProps={{rowHeight: 32, headingHeight: 32, padding: 4, gap: 4}} propsObject="layoutOptions" wide "use client"; import {Virtualizer, TableLayout} from 'react-aria-components'; import {Cell, Column, Row, Table, TableBody, TableHeader} from 'vanilla-starter/Table'; @@ -501,4 +751,4 @@ for (let i = 0; i < 1000; i++) { ### TableLayout - + diff --git a/packages/react-aria-components/exports/index.ts b/packages/react-aria-components/exports/index.ts index 0b987f11550..f474cd21b8c 100644 --- a/packages/react-aria-components/exports/index.ts +++ b/packages/react-aria-components/exports/index.ts @@ -203,5 +203,5 @@ export type {ListOptions as ListDataOptions, ListData} from 'react-stately/useLi export type {TreeOptions as TreeDataOptions, TreeData} from 'react-stately/useTreeData'; export type {AsyncListOptions, AsyncListData, AsyncListLoadFunction, AsyncListLoadOptions, AsyncListStateUpdate} from 'react-stately/useAsyncList'; export type {AutocompleteState} from 'react-stately/private/autocomplete/useAutocompleteState'; -export type {ListLayoutOptions, GridLayoutOptions, WaterfallLayoutOptions} from 'react-stately/useVirtualizerState'; +export type {ListLayoutOptions, GridLayoutOptions, TableLayoutProps, WaterfallLayoutOptions} from 'react-stately/useVirtualizerState'; export type {RangeValue, ValidationResult, RouterConfig} from '@react-types/shared'; diff --git a/packages/react-stately/src/layout/ListLayout.ts b/packages/react-stately/src/layout/ListLayout.ts index 2fd0a574f5a..09de7fb02c0 100644 --- a/packages/react-stately/src/layout/ListLayout.ts +++ b/packages/react-stately/src/layout/ListLayout.ts @@ -25,25 +25,25 @@ export interface ListLayoutOptions { */ orientation?: Orientation, /** - * The fixed height of a row in px. + * The fixed size of a row in px with respect to the applied orientation. * @default 48 */ - rowHeight?: number, - /** The estimated height of a row, when row heights are variable. */ - estimatedRowHeight?: number, + rowSize?: number, + /** The estimated size of a row in px with respect to the applied orientation, when row sizes are variable. */ + estimatedRowSize?: number, /** - * The fixed height of a section header in px. + * The fixed size of a section header in px with respect to the applied orientation. * @default 48 */ - headingHeight?: number, - /** The estimated height of a section header, when the height is variable. */ - estimatedHeadingHeight?: number, + headingSize?: number, + /** The estimated size of a section header in px with respect to the applied orientation, when heading sizes are variable. */ + estimatedHeadingSize?: number, /** - * The fixed height of a loader element in px. This loader is specifically for + * The fixed size of a loader element in px with respect to the applied orientation. This loader is specifically for * "load more" elements rendered when loading more rows at the root level or inside nested row/sections. * @default 48 */ - loaderHeight?: number, + loaderSize?: number, /** * The thickness of the drop indicator. * @default 2 @@ -58,7 +58,34 @@ export interface ListLayoutOptions { * The padding around the list. * @default 0 */ - padding?: number + padding?: number, + /** + * The fixed height of a row in px. + * @default 48 + * @deprecated Use `rowSize` instead. + */ + rowHeight?: number, + /** The estimated height of a row, when row heights are variable. + * @deprecated Use `estimatedRowSize` instead. + */ + estimatedRowHeight?: number, + /** + * The fixed height of a section header in px. + * @default 48 + * @deprecated Use `headingSize` instead. + */ + headingHeight?: number, + /** The estimated height of a section header, when the height is variable. + * @deprecated Use `estimatedHeadingSize` instead. + */ + estimatedHeadingHeight?: number, + /** + * The fixed height of a loader element in px. This loader is specifically for + * "load more" elements rendered when loading more rows at the root level or inside nested row/sections. + * @default 48 + * @deprecated Use `loaderSize` instead. + */ + loaderHeight?: number } // A wrapper around LayoutInfo that supports hierarchy @@ -74,16 +101,16 @@ const DEFAULT_HEIGHT = 48; /** * ListLayout is a virtualizer Layout implementation - * that arranges its items in a vertical stack. It supports both fixed - * and variable height items. + * that arranges its items in a stack along its applied orientation. + * It supports both fixed and variable size items. */ export class ListLayout extends Layout, O> implements DropTargetDelegate { - protected rowHeight: number | null; + protected rowSize: number | null; protected orientation: Orientation; - protected estimatedRowHeight: number | null; - protected headingHeight: number | null; - protected estimatedHeadingHeight: number | null; - protected loaderHeight: number | null; + protected estimatedRowSize: number | null; + protected headingSize: number | null; + protected estimatedHeadingSize: number | null; + protected loaderSize: number | null; protected dropIndicatorThickness: number; protected gap: number; protected padding: number; @@ -103,12 +130,12 @@ export class ListLayout exte */ constructor(options: ListLayoutOptions = {}) { super(); - this.rowHeight = options.rowHeight ?? null; + this.rowSize = options?.rowSize ?? options?.rowHeight ?? null; this.orientation = options.orientation ?? 'vertical'; - this.estimatedRowHeight = options.estimatedRowHeight ?? null; - this.headingHeight = options.headingHeight ?? null; - this.estimatedHeadingHeight = options.estimatedHeadingHeight ?? null; - this.loaderHeight = options.loaderHeight ?? null; + this.estimatedRowSize = options?.estimatedRowSize ?? options?.estimatedRowHeight ?? null; + this.headingSize = options?.headingSize ?? options?.headingHeight ?? null; + this.estimatedHeadingSize = options?.estimatedHeadingSize ?? options?.estimatedHeadingHeight ?? null; + this.loaderSize = options?.loaderSize ?? options?.loaderHeight ?? null; this.dropIndicatorThickness = options.dropIndicatorThickness || 2; this.gap = options.gap || 0; this.padding = options.padding || 0; @@ -126,6 +153,31 @@ export class ListLayout exte return this.virtualizer!.collection; } + /** @deprecated Use `rowSize` instead. */ + protected get rowHeight(): number | null { + return this.rowSize; + } + + /** @deprecated Use `estimatedRowSize` instead. */ + protected get estimatedRowHeight(): number | null { + return this.estimatedRowSize; + } + + /** @deprecated Use `headingSize` instead. */ + protected get headingHeight(): number | null { + return this.headingSize; + + } + /** @deprecated Use `estimatedHeadingSize` instead. */ + protected get estimatedHeadingHeight(): number | null { + return this.estimatedHeadingSize; + } + + /** @deprecated Use `loaderSize` instead. */ + protected get loaderHeight(): number | null { + return this.loaderSize; + } + getLayoutInfo(key: Key): LayoutInfo | null { this.ensureLayoutInfo(key); return this.layoutNodes.get(key)?.layoutInfo || null; @@ -139,7 +191,7 @@ export class ListLayout exte // Adjust rect to keep number of visible rows consistent. // (only if height > 1 or width > 1 for getDropTargetFromPoint) if (visibleRect[heightProperty] > 1) { - let rowHeight = (this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT) + this.gap; + let rowHeight = (this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT) + this.gap; visibleRect[offsetProperty] = Math.floor(visibleRect[offsetProperty] / rowHeight) * rowHeight; visibleRect[heightProperty] = Math.ceil(visibleRect[heightProperty] / rowHeight) * rowHeight; } @@ -208,21 +260,21 @@ export class ListLayout exte // Also invalidate if fixed sizes/gaps change. let options = invalidationContext.layoutOptions; return invalidationContext.sizeChanged - || this.rowHeight !== (options?.rowHeight ?? this.rowHeight) + || this.rowSize !== (options?.rowSize ?? options?.rowHeight ?? this.rowSize) || this.orientation !== (options?.orientation ?? this.orientation) - || this.headingHeight !== (options?.headingHeight ?? this.headingHeight) - || this.loaderHeight !== (options?.loaderHeight ?? this.loaderHeight) + || this.headingSize !== (options?.headingSize ?? options?.headingHeight ?? this.headingSize) + || this.loaderSize !== (options?.loaderSize ?? options?.loaderHeight ?? this.loaderSize) || this.gap !== (options?.gap ?? this.gap) || this.padding !== (options?.padding ?? this.padding); } shouldInvalidateLayoutOptions(newOptions: O, oldOptions: O): boolean { - return newOptions.rowHeight !== oldOptions.rowHeight + return (newOptions?.rowSize ?? newOptions?.rowHeight) !== (oldOptions?.rowSize ?? oldOptions?.rowHeight) || newOptions.orientation !== oldOptions.orientation - || newOptions.estimatedRowHeight !== oldOptions.estimatedRowHeight - || newOptions.headingHeight !== oldOptions.headingHeight - || newOptions.estimatedHeadingHeight !== oldOptions.estimatedHeadingHeight - || newOptions.loaderHeight !== oldOptions.loaderHeight + || (newOptions?.estimatedRowSize ?? newOptions?.estimatedRowHeight) !== (oldOptions?.estimatedRowSize ?? oldOptions?.estimatedRowHeight) + || (newOptions?.headingSize ?? newOptions?.headingHeight) !== (oldOptions?.headingSize ?? oldOptions?.headingHeight) + || (newOptions?.estimatedHeadingSize ?? newOptions?.estimatedHeadingHeight) !== (oldOptions?.estimatedHeadingSize ?? oldOptions?.estimatedHeadingHeight) + || (newOptions?.loaderSize ?? newOptions?.loaderHeight) !== (oldOptions?.loaderSize ?? oldOptions?.loaderHeight) || newOptions.dropIndicatorThickness !== oldOptions.dropIndicatorThickness || newOptions.gap !== oldOptions.gap || newOptions.padding !== oldOptions.padding; @@ -240,12 +292,12 @@ export class ListLayout exte } let options = invalidationContext.layoutOptions; - this.rowHeight = options?.rowHeight ?? this.rowHeight; + this.rowSize = options?.rowSize ?? options?.rowHeight ?? this.rowSize; this.orientation = options?.orientation ?? this.orientation; - this.estimatedRowHeight = options?.estimatedRowHeight ?? this.estimatedRowHeight; - this.headingHeight = options?.headingHeight ?? this.headingHeight; - this.estimatedHeadingHeight = options?.estimatedHeadingHeight ?? this.estimatedHeadingHeight; - this.loaderHeight = options?.loaderHeight ?? this.loaderHeight; + this.estimatedRowSize = options?.estimatedRowSize ?? options?.estimatedRowHeight ?? this.estimatedRowSize; + this.headingSize = options?.headingSize ?? options?.headingHeight ?? this.headingSize; + this.estimatedHeadingSize = options?.estimatedHeadingSize ?? options?.estimatedHeadingHeight ?? this.estimatedHeadingSize; + this.loaderSize = options?.loaderSize ?? options?.loaderHeight ?? this.loaderSize; this.dropIndicatorThickness = options?.dropIndicatorThickness ?? this.dropIndicatorThickness; this.gap = options?.gap ?? this.gap; this.padding = options?.padding ?? this.padding; @@ -284,7 +336,7 @@ export class ListLayout exte for (let node of collectionNodes) { let offsetProperty = this.orientation === 'horizontal' ? 'x' : 'y'; let maxOffsetProperty = this.orientation === 'horizontal' ? 'maxX' : 'maxY'; - let rowHeight = (this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT) + this.gap; + let rowHeight = (this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT) + this.gap; // Skip rows before the valid rectangle unless they are already cached. if (node.type === 'item' && offset + rowHeight < this.requestedRect[offsetProperty] && !this.isValid(node, offset)) { offset += rowHeight; @@ -377,10 +429,10 @@ export class ListLayout exte // room for the loader alongside rendering the emptyState if (this.orientation === 'horizontal') { rect.height = this.virtualizer!.contentSize.height - this.padding - y; - rect.width = node.props.isLoading ? this.loaderHeight ?? this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT : 0; + rect.width = node.props.isLoading ? this.loaderSize ?? this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT : 0; } else { rect.width = this.virtualizer!.contentSize.width - this.padding - x; - rect.height = node.props.isLoading ? this.loaderHeight ?? this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT : 0; + rect.height = node.props.isLoading ? this.loaderSize ?? this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT : 0; } return { @@ -409,7 +461,7 @@ export class ListLayout exte continue; } - let rowHeight = (this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT) + this.gap; + let rowHeight = (this.rowSize ?? this.estimatedRowSize ?? DEFAULT_HEIGHT) + this.gap; // Skip rows before the valid rectangle unless they are already cached. if (offset + rowHeight < this.requestedRect[offsetProperty] && !this.isValid(node, offset)) { @@ -444,7 +496,7 @@ export class ListLayout exte let widthProperty = this.orientation === 'horizontal' ? 'height' : 'width'; let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; let width = this.virtualizer!.visibleRect[widthProperty] - this.padding - (this.orientation === 'horizontal' ? y : x); - let rectHeight = this.headingHeight; + let rectHeight = this.headingSize; let isEstimated = false; // If no explicit height is available, use an estimated height. @@ -460,7 +512,7 @@ export class ListLayout exte rectHeight = previousLayoutNode!.layoutInfo.rect[heightProperty]; isEstimated = width !== previousLayoutInfo.rect[widthProperty] || curNode !== lastNode || previousLayoutInfo.estimatedSize; } else { - rectHeight = (node.rendered ? this.estimatedHeadingHeight : 0); + rectHeight = (node.rendered ? this.estimatedHeadingSize : 0); isEstimated = true; } } @@ -485,7 +537,7 @@ export class ListLayout exte let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; let width = this.virtualizer!.visibleRect[widthProperty] - this.padding - (this.orientation === 'horizontal' ? y : x); - let rectHeight = this.rowHeight; + let rectHeight = this.rowSize; let isEstimated = false; // If no explicit height is available, use an estimated height. @@ -498,7 +550,7 @@ export class ListLayout exte rectHeight = previousLayoutNode.layoutInfo.rect[heightProperty]; isEstimated = width !== previousLayoutNode.layoutInfo.rect[widthProperty] || node !== previousLayoutNode.node || previousLayoutNode.layoutInfo.estimatedSize; } else { - rectHeight = this.estimatedRowHeight; + rectHeight = this.estimatedRowSize; isEstimated = true; } } diff --git a/packages/react-stately/src/layout/TableLayout.ts b/packages/react-stately/src/layout/TableLayout.ts index 1dab4f942ab..f6b72220116 100644 --- a/packages/react-stately/src/layout/TableLayout.ts +++ b/packages/react-stately/src/layout/TableLayout.ts @@ -22,7 +22,29 @@ import {Size} from '../virtualizer/Size'; import {ITableCollection as TableCollection} from '../table/TableCollection'; import {TableColumnLayout} from '../table/TableColumnLayout'; -export interface TableLayoutProps extends ListLayoutOptions { +export interface TableLayoutProps extends Omit { + /** + * The fixed height of a row in px. + * @default 48 + */ + rowHeight?: number, + /** The estimated height of a row, when row heights are variable. + */ + estimatedRowHeight?: number, + /** + * The fixed height of a section header in px. + * @default 48 + */ + headingHeight?: number, + /** The estimated height of a section header, when the height is variable. + */ + estimatedHeadingHeight?: number, + /** + * The fixed height of a loader element in px. This loader is specifically for + * "load more" elements rendered when loading more rows at the root level or inside nested row/sections. + * @default 48 + */ + loaderHeight?: number, columnWidths?: Map } @@ -39,7 +61,7 @@ export class TableLayout exten private lastPersistedKeys: Set | null = null; private persistedIndices: Map = new Map(); - constructor(options?: ListLayoutOptions) { + constructor(options?: TableLayoutProps) { super(options); this.stickyColumnIndices = []; } @@ -49,6 +71,27 @@ export class TableLayout exten return this.virtualizer!.collection as TableCollection; } + // Preserve the old rowHeight/other "height" properties since Table doesn't support a "horizontal" orientation + protected get rowHeight(): number | null { + return super.rowHeight; + } + + protected get estimatedRowHeight(): number | null { + return super.estimatedRowHeight; + } + + protected get headingHeight(): number | null { + return super.headingHeight; + } + + protected get estimatedHeadingHeight(): number | null { + return super.estimatedHeadingHeight; + } + + protected get loaderHeight(): number | null { + return super.loaderHeight; + } + private columnsChanged(newCollection: TableCollection, oldCollection: TableCollection | null) { return !oldCollection || newCollection.columns !== oldCollection.columns && diff --git a/starters/docs/src/GridList.css b/starters/docs/src/GridList.css index 4b74fb78712..74e558b148c 100644 --- a/starters/docs/src/GridList.css +++ b/starters/docs/src/GridList.css @@ -58,6 +58,26 @@ display: grid; grid-template-columns: auto; align-items: center; + + &[data-orientation=horizontal] { + display: flex; + flex-direction: row; + justify-content: normal; + + .react-aria-GridListItem { + flex-shrink: 0; + width: var(--grid-item-size); + } + } + } + + &[data-layout=grid][data-orientation=horizontal] { + grid-auto-flow: column; + grid-template-rows: auto auto; + grid-template-columns: none; + grid-auto-columns: var(--grid-item-size); + justify-content: normal; + max-height: none; } &[data-focus-visible] { diff --git a/starters/tailwind/src/GridList.tsx b/starters/tailwind/src/GridList.tsx index e80c9d345ed..cc368f73eb9 100644 --- a/starters/tailwind/src/GridList.tsx +++ b/starters/tailwind/src/GridList.tsx @@ -18,8 +18,11 @@ import { twMerge } from 'tailwind-merge'; export function GridList( { children, ...props }: GridListProps ) { + let isHorizontal = (props as {orientation?: 'horizontal' | 'vertical'}).orientation === 'horizontal'; return ( - + {children} ); @@ -27,11 +30,19 @@ export function GridList( const itemStyles = tv({ extend: focusRing, - base: 'relative flex gap-3 cursor-default select-none py-2 px-3 text-sm text-neutral-900 dark:text-neutral-200 border-t dark:border-t-neutral-700 border-transparent first:border-t-0 first:rounded-t-lg last:rounded-b-lg last:mb-0 -outline-offset-2', + base: [ + 'relative flex gap-3 cursor-default select-none py-2 px-3 text-sm text-neutral-900 dark:text-neutral-200 border-transparent -outline-offset-2', + '[[data-orientation=vertical]_&]:border-t [[data-orientation=vertical]_&]:dark:border-t-neutral-700 [[data-orientation=vertical]_&]:first:border-t-0 [[data-orientation=vertical]_&]:first:rounded-t-lg [[data-orientation=vertical]_&]:last:rounded-b-lg', + '[[data-orientation=horizontal]_&]:border-l [[data-orientation=horizontal]_&]:dark:border-l-neutral-700 [[data-orientation=horizontal]_&]:first:border-l-0 [[data-orientation=horizontal]_&]:first:rounded-s-lg [[data-orientation=horizontal]_&]:last:rounded-e-lg [[data-orientation=horizontal]_&]:flex-shrink-0' + ].join(' '), variants: { isSelected: { false: 'hover:bg-neutral-100 pressed:bg-neutral-100 dark:hover:bg-neutral-700/60 dark:pressed:bg-neutral-700/60', - true: 'bg-blue-100 dark:bg-blue-700/30 hover:bg-blue-200 pressed:bg-blue-200 dark:hover:bg-blue-700/40 dark:pressed:bg-blue-700/40 border-y-blue-200 dark:border-y-blue-900 z-20' + true: [ + 'bg-blue-100 dark:bg-blue-700/30 hover:bg-blue-200 pressed:bg-blue-200 dark:hover:bg-blue-700/40 dark:pressed:bg-blue-700/40 z-20', + '[[data-orientation=vertical]_&]:border-y-blue-200 [[data-orientation=vertical]_&]:dark:border-y-blue-900', + '[[data-orientation=horizontal]_&]:border-x-blue-200 [[data-orientation=horizontal]_&]:dark:border-x-blue-900 ' + ].join(' ') }, isDisabled: { true: 'text-neutral-300 dark:text-neutral-600 forced-colors:text-[GrayText] z-10' diff --git a/starters/tailwind/stories/GridList.stories.tsx b/starters/tailwind/stories/GridList.stories.tsx index fbb287b1702..a67197b59ca 100644 --- a/starters/tailwind/stories/GridList.stories.tsx +++ b/starters/tailwind/stories/GridList.stories.tsx @@ -8,6 +8,14 @@ const meta: Meta = { parameters: { layout: 'centered' }, + argTypes: { + keyboardNavigationBehavior: { + control: { + type: 'radio' + }, + options: ['arrow', 'tab'] + } + }, tags: ['autodocs'] }; @@ -24,7 +32,21 @@ export const Example = (args: any) => ( Example.args = { onAction: null, - selectionMode: 'multiple' + selectionMode: 'multiple', + keyboardNavigationBehavior: 'arrow' +}; + +export const Horizontal = (args: any) => ( + + Chocolate + Mint + Strawberry + Vanilla + +); + +Horizontal.args = { + ...Example.args }; export const DisabledItems = (args: any) => ;