From 05346a6a6f16f2e853ef01bed9149381eebedf26 Mon Sep 17 00:00:00 2001 From: komkanit Date: Tue, 21 Oct 2025 01:49:08 +0700 Subject: [PATCH 1/7] feat: Allow Tooltip to remain visible when trigger is clicked (#9027) * feat(tooltip): add closeOnPress prop to keep tooltip visible after clicked * remove default value for closeOnPress prop in TooltipTrigger stories * add default value on closeOnPress props Co-authored-by: Robert Snow --------- Co-authored-by: Robert Snow --- .../tooltip/src/useTooltipTrigger.ts | 7 +++- .../tooltip/src/TooltipTrigger.tsx | 7 +++- .../stories/TooltipTrigger.stories.tsx | 6 ++- .../tooltip/test/TooltipTrigger.test.js | 42 +++++++++++++++++++ packages/@react-types/tooltip/src/index.d.ts | 8 +++- 5 files changed, 65 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/tooltip/src/useTooltipTrigger.ts b/packages/@react-aria/tooltip/src/useTooltipTrigger.ts index 4fa5bd7ec1d..d31c8ae9024 100644 --- a/packages/@react-aria/tooltip/src/useTooltipTrigger.ts +++ b/packages/@react-aria/tooltip/src/useTooltipTrigger.ts @@ -36,7 +36,8 @@ export interface TooltipTriggerAria { export function useTooltipTrigger(props: TooltipTriggerProps, state: TooltipTriggerState, ref: RefObject) : TooltipTriggerAria { let { isDisabled, - trigger + trigger, + closeOnPress = true } = props; let tooltipId = useId(); @@ -102,6 +103,10 @@ export function useTooltipTrigger(props: TooltipTriggerProps, state: TooltipTrig }; let onPressStart = () => { + // if closeOnPress is false, we should not close the tooltip + if (!closeOnPress) { + return; + } // no matter how the trigger is pressed, we should close the tooltip isFocused.current = false; isHovered.current = false; diff --git a/packages/@react-spectrum/tooltip/src/TooltipTrigger.tsx b/packages/@react-spectrum/tooltip/src/TooltipTrigger.tsx index 0478fff35f5..0cb42d22ab7 100644 --- a/packages/@react-spectrum/tooltip/src/TooltipTrigger.tsx +++ b/packages/@react-spectrum/tooltip/src/TooltipTrigger.tsx @@ -22,6 +22,7 @@ import {useTooltipTriggerState} from '@react-stately/tooltip'; const DEFAULT_OFFSET = -1; // Offset needed to reach 4px/5px (med/large) distance between tooltip and trigger button const DEFAULT_CROSS_OFFSET = 0; +const DEFAULT_CLOSE_ON_PRESS = true; // Whether the tooltip should close when the trigger is pressed function TooltipTrigger(props: SpectrumTooltipTriggerProps) { let { @@ -29,7 +30,8 @@ function TooltipTrigger(props: SpectrumTooltipTriggerProps) { crossOffset = DEFAULT_CROSS_OFFSET, isDisabled, offset = DEFAULT_OFFSET, - trigger: triggerAction + trigger: triggerAction, + closeOnPress = DEFAULT_CLOSE_ON_PRESS } = props; let [trigger, tooltip] = React.Children.toArray(children) as [ReactElement, ReactElement]; @@ -40,7 +42,8 @@ function TooltipTrigger(props: SpectrumTooltipTriggerProps) { let {triggerProps, tooltipProps} = useTooltipTrigger({ isDisabled, - trigger: triggerAction + trigger: triggerAction, + closeOnPress }, state, tooltipTriggerRef); let [borderRadius, setBorderRadius] = useState(0); diff --git a/packages/@react-spectrum/tooltip/stories/TooltipTrigger.stories.tsx b/packages/@react-spectrum/tooltip/stories/TooltipTrigger.stories.tsx index 0a70eb40117..48becc8c2bf 100644 --- a/packages/@react-spectrum/tooltip/stories/TooltipTrigger.stories.tsx +++ b/packages/@react-spectrum/tooltip/stories/TooltipTrigger.stories.tsx @@ -72,6 +72,9 @@ const argTypes = { }, children: { control: {disable: true} + }, + closeOnPress: { + control: 'boolean' } }; @@ -113,7 +116,8 @@ export default { , Change Name ], - onOpenChange: action('openChange') + onOpenChange: action('openChange'), + closeOnPress: true }, argTypes: argTypes } as Meta; diff --git a/packages/@react-spectrum/tooltip/test/TooltipTrigger.test.js b/packages/@react-spectrum/tooltip/test/TooltipTrigger.test.js index 86eb44719c7..5a898b7450e 100644 --- a/packages/@react-spectrum/tooltip/test/TooltipTrigger.test.js +++ b/packages/@react-spectrum/tooltip/test/TooltipTrigger.test.js @@ -330,6 +330,48 @@ describe('TooltipTrigger', function () { expect(queryByRole('tooltip')).toBeNull(); }); + it('does not close if the trigger is clicked when closeOnPress is false', async () => { + let {getByRole, getByLabelText} = render( + + + + Helpful information. + + + ); + await user.click(document.body); + + let button = getByLabelText('trigger'); + await user.hover(button); + expect(onOpenChange).toHaveBeenCalledWith(true); + let tooltip = getByRole('tooltip'); + expect(tooltip).toBeVisible(); + await user.click(button); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(tooltip).toBeVisible(); + }); + + it('does not close if the trigger is clicked with the keyboard when closeOnPress is false', async () => { + let {getByRole, getByLabelText} = render( + + + + Helpful information. + + + ); + + let button = getByLabelText('trigger'); + await user.tab(); + expect(onOpenChange).toHaveBeenCalledWith(true); + let tooltip = getByRole('tooltip'); + expect(tooltip).toBeVisible(); + fireEvent.keyDown(button, {key: 'Enter'}); + fireEvent.keyUp(button, {key: 'Enter'}); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(tooltip).toBeVisible(); + }); + describe('delay', () => { it('opens immediately for focus', () => { let {getByRole, getByLabelText} = render( diff --git a/packages/@react-types/tooltip/src/index.d.ts b/packages/@react-types/tooltip/src/index.d.ts index c544fc0bac7..73730df859f 100644 --- a/packages/@react-types/tooltip/src/index.d.ts +++ b/packages/@react-types/tooltip/src/index.d.ts @@ -36,7 +36,13 @@ export interface TooltipTriggerProps extends OverlayTriggerProps { * By default, opens for both focus and hover. Can be made to open only for focus. * @default 'hover' */ - trigger?: 'hover' | 'focus' + trigger?: 'hover' | 'focus', + + /** + * Whether the tooltip should close when the trigger is pressed. + * @default true + */ + closeOnPress?: boolean } export interface SpectrumTooltipTriggerProps extends Omit, PositionProps { From d169afae8b1590d22f7e1ce34c845cf25102650d Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Tue, 21 Oct 2025 06:22:41 +1100 Subject: [PATCH 2/7] chore: fix overlay positioning (#8848) * chore: fix overlay positioning This reverts commit 155970a02ce76b57c5dcba660d18272be9624d80. * incorporate viewport, bounding box, and container descendent of boundary * fix flip for when overlay should based on height not on scrolling --------- Co-authored-by: Reid Barber --- .../overlays/src/calculatePosition.ts | 139 ++++++++++++------ .../overlays/test/calculatePosition.test.ts | 25 +++- .../overlays/test/useOverlayPosition.test.tsx | 69 ++++++--- 3 files changed, 169 insertions(+), 64 deletions(-) diff --git a/packages/@react-aria/overlays/src/calculatePosition.ts b/packages/@react-aria/overlays/src/calculatePosition.ts index ad872c7d7ea..e5df4569701 100644 --- a/packages/@react-aria/overlays/src/calculatePosition.ts +++ b/packages/@react-aria/overlays/src/calculatePosition.ts @@ -103,14 +103,18 @@ const TOTAL_SIZE = { const PARSED_PLACEMENT_CACHE = {}; -let visualViewport = typeof document !== 'undefined' ? window.visualViewport : null; +let getVisualViewport = () => typeof document !== 'undefined' ? window.visualViewport : null; -function getContainerDimensions(containerNode: Element): Dimensions { +function getContainerDimensions(containerNode: Element, visualViewport: VisualViewport | null): Dimensions { let width = 0, height = 0, totalWidth = 0, totalHeight = 0, top = 0, left = 0; let scroll: Position = {}; let isPinchZoomedIn = (visualViewport?.scale ?? 1) > 1; - if (containerNode.tagName === 'BODY') { + // In the case where the container is `html` or `body` and the container doesn't have something like `position: relative`, + // then position absolute will be positioned relative to the viewport, also known as the `initial containing block`. + // That's why we use the visual viewport instead. + + if (containerNode.tagName === 'BODY' || containerNode.tagName === 'HTML') { let documentElement = document.documentElement; totalWidth = documentElement.clientWidth; totalHeight = documentElement.clientHeight; @@ -179,10 +183,13 @@ function getDelta( let boundarySize = boundaryDimensions[AXIS_SIZE[axis]]; // Calculate the edges of the boundary (accomodating for the boundary padding) and the edges of the overlay. // Note that these values are with respect to the visual viewport (aka 0,0 is the top left of the viewport) - let boundaryStartEdge = boundaryDimensions.scroll[AXIS[axis]] + padding; - let boundaryEndEdge = boundarySize + boundaryDimensions.scroll[AXIS[axis]] - padding; - let startEdgeOffset = offset - containerScroll + containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]]; - let endEdgeOffset = offset - containerScroll + size + containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]]; + + let boundaryStartEdge = containerOffsetWithBoundary[axis] + boundaryDimensions.scroll[AXIS[axis]] + padding; + let boundaryEndEdge = containerOffsetWithBoundary[axis] + boundaryDimensions.scroll[AXIS[axis]] + boundarySize - padding; + // transformed value of the left edge of the overlay + let startEdgeOffset = offset - containerScroll + boundaryDimensions.scroll[AXIS[axis]] + containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]]; + // transformed value of the right edge of the overlay + let endEdgeOffset = offset - containerScroll + size + boundaryDimensions.scroll[AXIS[axis]] + containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]]; // If any of the overlay edges falls outside of the boundary, shift the overlay the required amount to align one of the overlay's // edges with the closest boundary edge. @@ -234,7 +241,8 @@ function computePosition( containerOffsetWithBoundary: Offset, isContainerPositioned: boolean, arrowSize: number, - arrowBoundaryOffset: number + arrowBoundaryOffset: number, + containerDimensions: Dimensions ) { let {placement, crossPlacement, axis, crossAxis, size, crossSize} = placementInfo; let position: Position = {}; @@ -255,9 +263,9 @@ function computePosition( position[crossAxis]! += crossOffset; - // overlay top overlapping arrow with button bottom + // overlay top or left overlapping arrow with button bottom or right const minPosition = childOffset[crossAxis] - overlaySize[crossSize] + arrowSize + arrowBoundaryOffset; - // overlay bottom overlapping arrow with button top + // overlay bottom or right overlapping arrow with button top or left const maxPosition = childOffset[crossAxis] + childOffset[crossSize] - arrowSize - arrowBoundaryOffset; position[crossAxis] = clamp(position[crossAxis]!, minPosition, maxPosition); @@ -266,8 +274,8 @@ function computePosition( // If the container is positioned (non-static), then we use the container's actual // height, as `bottom` will be relative to this height. But if the container is static, // then it can only be the `document.body`, and `bottom` will be relative to _its_ - // container, which should be as large as boundaryDimensions. - const containerHeight = (isContainerPositioned ? containerOffsetWithBoundary[size] : boundaryDimensions[TOTAL_SIZE[size]]); + // container. + let containerHeight = (isContainerPositioned ? containerDimensions[size] : containerDimensions[TOTAL_SIZE[size]]); position[FLIPPED_DIRECTION[axis]] = Math.floor(containerHeight - childOffset[axis] + offset); } else { position[axis] = Math.floor(childOffset[axis] + childOffset[size] + offset); @@ -283,42 +291,72 @@ function getMaxHeight( margins: Position, padding: number, overlayHeight: number, - heightGrowthDirection: HeightGrowthDirection + heightGrowthDirection: HeightGrowthDirection, + containerDimensions: Dimensions, + isContainerDescendentOfBoundary: boolean, + visualViewport: VisualViewport | null ) { - const containerHeight = (isContainerPositioned ? containerOffsetWithBoundary.height : boundaryDimensions[TOTAL_SIZE.height]); - // For cases where position is set via "bottom" instead of "top", we need to calculate the true overlay top with respect to the boundary. Reverse calculate this with the same method - // used in computePosition. - let overlayTop = position.top != null ? containerOffsetWithBoundary.top + position.top : containerOffsetWithBoundary.top + (containerHeight - (position.bottom ?? 0) - overlayHeight); + // For cases where position is set via "bottom" instead of "top", we need to calculate the true overlay top + // with respect to the container. + let overlayTop = (position.top != null ? position.top : (containerDimensions[TOTAL_SIZE.height] - (position.bottom ?? 0) - overlayHeight)) - (containerDimensions.scroll.top ?? 0); + // calculate the dimentions of the "boundingRect" which is most restrictive top/bottom of the boundaryRect and the visual view port + let boundaryToContainerTransformOffset = isContainerDescendentOfBoundary ? containerOffsetWithBoundary.top : 0; + let boundingRect = { + // This should be boundary top in container coord system vs viewport top in container coord system + // For the viewport top, there are several cases + // 1. pinchzoom case where we want the viewports offset top as top here + // 2. case where container is offset from the boundary and is contained by the boundary. In this case the top we want here is NOT 0, we want to take boundary's top even though is is a negative number OR the visual viewport, whichever is more restrictive + top: Math.max(boundaryDimensions.top + boundaryToContainerTransformOffset, (visualViewport?.offsetTop ?? boundaryDimensions.top) + boundaryToContainerTransformOffset), + bottom: Math.min((boundaryDimensions.top + boundaryDimensions.height + boundaryToContainerTransformOffset), (visualViewport?.offsetTop ?? 0) + (visualViewport?.height ?? 0)) + }; + let maxHeight = heightGrowthDirection !== 'top' ? // We want the distance between the top of the overlay to the bottom of the boundary Math.max(0, - (boundaryDimensions.height + boundaryDimensions.top + (boundaryDimensions.scroll.top ?? 0)) // this is the bottom of the boundary + boundingRect.bottom // this is the bottom of the boundary - overlayTop // this is the top of the overlay - ((margins.top ?? 0) + (margins.bottom ?? 0) + padding) // save additional space for margin and padding ) // We want the distance between the bottom of the overlay to the top of the boundary : Math.max(0, (overlayTop + overlayHeight) // this is the bottom of the overlay - - (boundaryDimensions.top + (boundaryDimensions.scroll.top ?? 0)) // this is the top of the boundary + - boundingRect.top // this is the top of the boundary - ((margins.top ?? 0) + (margins.bottom ?? 0) + padding) // save additional space for margin and padding ); - return Math.min(boundaryDimensions.height - (padding * 2), maxHeight); + return maxHeight; } function getAvailableSpace( - boundaryDimensions: Dimensions, + boundaryDimensions: Dimensions, // boundary containerOffsetWithBoundary: Offset, - childOffset: Offset, - margins: Position, - padding: number, - placementInfo: ParsedPlacement + childOffset: Offset, // trigger, position based of container's non-viewport 0,0 + margins: Position, // overlay + padding: number, // overlay <-> boundary + placementInfo: ParsedPlacement, + containerDimensions: Dimensions, + isContainerDescendentOfBoundary: boolean ) { let {placement, axis, size} = placementInfo; if (placement === axis) { - return Math.max(0, childOffset[axis] - boundaryDimensions[axis] - (boundaryDimensions.scroll[axis] ?? 0) + containerOffsetWithBoundary[axis] - (margins[axis] ?? 0) - margins[FLIPPED_DIRECTION[axis]] - padding); + return Math.max(0, + childOffset[axis] // trigger start + - (containerDimensions.scroll[axis] ?? 0) // transform trigger position to be with respect to viewport 0,0 + - (boundaryDimensions[axis] + (isContainerDescendentOfBoundary ? containerOffsetWithBoundary[axis] : 0)) // boundary start + - (margins[axis] ?? 0) // margins usually for arrows or other decorations + - margins[FLIPPED_DIRECTION[axis]] + - padding); // padding between overlay and boundary } - return Math.max(0, boundaryDimensions[size] + boundaryDimensions[axis] + boundaryDimensions.scroll[axis] - containerOffsetWithBoundary[axis] - childOffset[axis] - childOffset[size] - (margins[axis] ?? 0) - margins[FLIPPED_DIRECTION[axis]] - padding); + return Math.max(0, + (boundaryDimensions[size] + + boundaryDimensions[axis] + + (isContainerDescendentOfBoundary ? containerOffsetWithBoundary[axis] : 0)) + - childOffset[axis] + - childOffset[size] + + (containerDimensions.scroll[axis] ?? 0) + - (margins[axis] ?? 0) + - margins[FLIPPED_DIRECTION[axis]] + - padding); } export function calculatePositionInternal( @@ -337,11 +375,13 @@ export function calculatePositionInternal( isContainerPositioned: boolean, userSetMaxHeight: number | undefined, arrowSize: number, - arrowBoundaryOffset: number + arrowBoundaryOffset: number, + isContainerDescendentOfBoundary: boolean, + visualViewport: VisualViewport | null ): PositionResult { let placementInfo = parsePlacement(placementInput); let {size, crossAxis, crossSize, placement, crossPlacement} = placementInfo; - let position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset); + let position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset, containerDimensions); let normalizedOffset = offset; let space = getAvailableSpace( boundaryDimensions, @@ -349,20 +389,25 @@ export function calculatePositionInternal( childOffset, margins, padding + offset, - placementInfo + placementInfo, + containerDimensions, + isContainerDescendentOfBoundary ); // Check if the scroll size of the overlay is greater than the available space to determine if we need to flip - if (flip && scrollSize[size] > space) { + if (flip && overlaySize[size] > space) { let flippedPlacementInfo = parsePlacement(`${FLIPPED_DIRECTION[placement]} ${crossPlacement}` as Placement); - let flippedPosition = computePosition(childOffset, boundaryDimensions, overlaySize, flippedPlacementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset); + let flippedPosition = computePosition(childOffset, boundaryDimensions, overlaySize, flippedPlacementInfo, offset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset, containerDimensions); + let flippedSpace = getAvailableSpace( boundaryDimensions, containerOffsetWithBoundary, childOffset, margins, padding + offset, - flippedPlacementInfo + flippedPlacementInfo, + containerDimensions, + isContainerDescendentOfBoundary ); // If the available space for the flipped position is greater than the original available space, flip. @@ -400,7 +445,10 @@ export function calculatePositionInternal( margins, padding, overlaySize.height, - heightGrowthDirection + heightGrowthDirection, + containerDimensions, + isContainerDescendentOfBoundary, + visualViewport ); if (userSetMaxHeight && userSetMaxHeight < maxHeight) { @@ -409,7 +457,7 @@ export function calculatePositionInternal( overlaySize.height = Math.min(overlaySize.height, maxHeight); - position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, normalizedOffset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset); + position = computePosition(childOffset, boundaryDimensions, overlaySize, placementInfo, normalizedOffset, crossOffset, containerOffsetWithBoundary, isContainerPositioned, arrowSize, arrowBoundaryOffset, containerDimensions); delta = getDelta(crossAxis, position[crossAxis]!, overlaySize[crossSize], boundaryDimensions, containerDimensions, padding, containerOffsetWithBoundary); position[crossAxis]! += delta; @@ -484,6 +532,7 @@ export function calculatePosition(opts: PositionOpts): PositionResult { arrowBoundaryOffset = 0 } = opts; + let visualViewport = getVisualViewport(); let container = overlayNode instanceof HTMLElement ? getContainingBlock(overlayNode) : document.documentElement; let isViewportContainer = container === document.documentElement; const containerPositionStyle = window.getComputedStyle(container).position; @@ -502,17 +551,19 @@ export function calculatePosition(opts: PositionOpts): PositionResult { overlaySize.height += (margins.top ?? 0) + (margins.bottom ?? 0); let scrollSize = getScroll(scrollNode); - let boundaryDimensions = getContainerDimensions(boundaryElement); - let containerDimensions = getContainerDimensions(container); + + // Note that due to logic inside getContainerDimensions, for cases where the boundary element is the body, we will return + // a height/width that matches the visual viewport size rather than the body's height/width (aka for zoom it will be zoom adjusted size) + // and a top/left that is adjusted as well (will return the top/left of the zoomed in viewport, or 0,0 for a non-zoomed body) + // Otherwise this returns the height/width of a arbitrary boundary element, and its top/left with respect to the viewport (NOTE THIS MEANS IT DOESNT INCLUDE SCROLL) + let boundaryDimensions = getContainerDimensions(boundaryElement, visualViewport); + let containerDimensions = getContainerDimensions(container, visualViewport); // If the container is the HTML element wrapping the body element, the retrieved scrollTop/scrollLeft will be equal to the // body element's scroll. Set the container's scroll values to 0 since the overlay's edge position value in getDelta don't then need to be further offset // by the container scroll since they are essentially the same containing element and thus in the same coordinate system - let containerOffsetWithBoundary: Offset = boundaryElement.tagName === 'BODY' ? getOffset(container, false) : getPosition(container, boundaryElement, false); - if (container.tagName === 'HTML' && boundaryElement.tagName === 'BODY') { - containerDimensions.scroll.top = 0; - containerDimensions.scroll.left = 0; - } + let containerOffsetWithBoundary: Offset = getPosition(boundaryElement, container, false); + let isContainerDescendentOfBoundary = boundaryElement.contains(container); return calculatePositionInternal( placement, childOffset, @@ -529,7 +580,9 @@ export function calculatePosition(opts: PositionOpts): PositionResult { isContainerPositioned, maxHeight, arrowSize, - arrowBoundaryOffset + arrowBoundaryOffset, + isContainerDescendentOfBoundary, + visualViewport ); } diff --git a/packages/@react-aria/overlays/test/calculatePosition.test.ts b/packages/@react-aria/overlays/test/calculatePosition.test.ts index 96907043448..3f9b4db7d86 100644 --- a/packages/@react-aria/overlays/test/calculatePosition.test.ts +++ b/packages/@react-aria/overlays/test/calculatePosition.test.ts @@ -109,14 +109,14 @@ describe('calculatePosition', function () { // The tests are all based on top/left positioning. Convert to bottom/right positioning if needed. let pos: {right?: number, top?: number, left?: number, bottom?: number} = {}; if ((placementAxis === 'left' && !flip) || (placementAxis === 'right' && flip)) { - pos.right = boundaryDimensions.width - (expected[0] + overlaySize.width); + pos.right = containerDimensions.width - (expected[0] + overlaySize.width); pos.top = expected[1]; } else if ((placementAxis === 'right' && !flip) || (placementAxis === 'left' && flip)) { pos.left = expected[0]; pos.top = expected[1]; } else if (placementAxis === 'top') { pos.left = expected[0]; - pos.bottom = boundaryDimensions.height - providerOffset - (expected[1] + overlaySize.height); + pos.bottom = containerDimensions.height - (expected[1] + overlaySize.height); } else if (placementAxis === 'bottom') { pos.left = expected[0]; pos.top = expected[1]; @@ -138,13 +138,16 @@ describe('calculatePosition', function () { }; const container = createElementWithDimensions('div', containerDimensions); + Object.assign(container.style, { + position: 'relative' + }); const target = createElementWithDimensions('div', targetDimension); const overlay = createElementWithDimensions('div', overlaySize, margins); const parentElement = document.createElement('div'); parentElement.appendChild(container); parentElement.appendChild(target); - parentElement.appendChild(overlay); + container.appendChild(overlay); document.documentElement.appendChild(parentElement); @@ -330,6 +333,22 @@ describe('calculatePosition', function () { testCases.forEach(function (testCase) { const {placement} = testCase; + beforeEach(() => { + window.visualViewport = { + offsetTop: 0, + height: 600, + offsetLeft: 0, + scale: 1, + width: 0, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => true, + onresize: () => {}, + onscroll: () => {}, + pageLeft: 0, + pageTop: 0 + } as VisualViewport; + }); describe(`placement = ${placement}`, function () { describe('no viewport offset', function () { diff --git a/packages/@react-aria/overlays/test/useOverlayPosition.test.tsx b/packages/@react-aria/overlays/test/useOverlayPosition.test.tsx index c81c34a94c1..0c2a8dfdf4b 100644 --- a/packages/@react-aria/overlays/test/useOverlayPosition.test.tsx +++ b/packages/@react-aria/overlays/test/useOverlayPosition.test.tsx @@ -14,7 +14,8 @@ import {fireEvent, render} from '@react-spectrum/test-utils-internal'; import React, {useRef} from 'react'; import {useOverlayPosition} from '../'; -function Example({triggerTop = 250, ...props}) { + +function Example({triggerTop = 250, containerStyle = {width: 600, height: 600} as React.CSSProperties, ...props}) { let targetRef = useRef(null); let containerRef = useRef(null); let overlayRef = useRef(null); @@ -23,7 +24,7 @@ function Example({triggerTop = 250, ...props}) { return (
Trigger
-
+
placement: {placement} @@ -36,6 +37,13 @@ function Example({triggerTop = 250, ...props}) { let original = window.HTMLElement.prototype.getBoundingClientRect; HTMLElement.prototype.getBoundingClientRect = function () { let rect = original.apply(this); + if (this.tagName === 'BODY') { + return { + ...rect, + height: this.clientHeight, + width: this.clientWidth + }; + } return { ...rect, left: parseInt(this.style.left, 10) || 0, @@ -49,6 +57,21 @@ HTMLElement.prototype.getBoundingClientRect = function () { describe('useOverlayPosition', function () { beforeEach(() => { + window.visualViewport = { + offsetTop: 0, + height: 768, + offsetLeft: 0, + scale: 1, + width: 500, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => true, + onresize: () => {}, + onscroll: () => {}, + pageLeft: 0, + pageTop: 0 + } as VisualViewport; + document.body.style.margin = '0'; // jsdom defaults to having a margin of 8px, we should fix this down the line Object.defineProperty(HTMLElement.prototype, 'clientHeight', {configurable: true, value: 768}); Object.defineProperty(HTMLElement.prototype, 'clientWidth', {configurable: true, value: 500}); @@ -89,7 +112,7 @@ describe('useOverlayPosition', function () { position: absolute; z-index: 100000; left: 12px; - bottom: 518px; + bottom: 350px; max-height: 238px; `); @@ -112,6 +135,7 @@ describe('useOverlayPosition', function () { expect(overlay).toHaveTextContent('placement: bottom'); Object.defineProperty(HTMLElement.prototype, 'clientHeight', {configurable: true, value: 1000}); + Object.defineProperty(window.visualViewport, 'height', {configurable: true, value: 1000}); fireEvent(window, new Event('resize')); expect(overlay).toHaveStyle(` @@ -226,6 +250,21 @@ describe('useOverlayPosition with positioned container', () => { let realGetBoundingClientRect = window.HTMLElement.prototype.getBoundingClientRect; let realGetComputedStyle = window.getComputedStyle; beforeEach(() => { + window.visualViewport = { + offsetTop: 0, + height: 768, + offsetLeft: 0, + scale: 1, + width: 500, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => true, + onresize: () => {}, + onscroll: () => {}, + pageLeft: 0, + pageTop: 0 + } as VisualViewport; + document.body.style.margin = '0'; Object.defineProperty(HTMLElement.prototype, 'clientHeight', {configurable: true, value: 768}); Object.defineProperty(HTMLElement.prototype, 'clientWidth', {configurable: true, value: 500}); stubs.push( @@ -238,19 +277,7 @@ describe('useOverlayPosition with positioned container', () => { } }), jest.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { - if (this.attributes.getNamedItem('data-testid')?.value === 'container') { - // Say, overlay is positioned somewhere - let real = realGetBoundingClientRect.apply(this); - return { - ...real, - top: 150, - left: 0, - width: 400, - height: 400 - }; - } else { - return realGetBoundingClientRect.apply(this); - } + return realGetBoundingClientRect.apply(this); }), jest.spyOn(window, 'getComputedStyle').mockImplementation(element => { if (element.attributes.getNamedItem('data-testid')?.value === 'container') { @@ -260,6 +287,12 @@ describe('useOverlayPosition with positioned container', () => { } else { return realGetComputedStyle(element); } + }), + jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) { + return parseInt(this.style.width, 10) || 0; + }), + jest.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) { + return parseInt(this.style.height, 10) || 0; }) ); }); @@ -270,7 +303,7 @@ describe('useOverlayPosition with positioned container', () => { }); it('should position the overlay relative to the trigger', function () { - let res = render(); + let res = render(); let overlay = res.getByTestId('overlay'); let arrow = res.getByTestId('arrow'); @@ -291,7 +324,7 @@ describe('useOverlayPosition with positioned container', () => { }); it('should position the overlay relative to the trigger at top', function () { - let res = render(); + let res = render(); let overlay = res.getByTestId('overlay'); let arrow = res.getByTestId('arrow'); From aefa248b1f64b2845f3f1032edf43b2d852696f8 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 20 Oct 2025 12:39:18 -0700 Subject: [PATCH 3/7] docs: Add examples for Autocomplete and Select with TagGroup (#9068) * Update Autocomplete examples * Add Select + TagGroup example --- .../s2-docs/pages/react-aria/Autocomplete.mdx | 1092 ++++++++++++++++- .../dev/s2-docs/pages/react-aria/GridList.mdx | 3 +- .../s2-docs/pages/react-aria/MultiSelect.css | 19 + .../dev/s2-docs/pages/react-aria/Select.mdx | 120 +- packages/dev/s2-docs/src/CodeBlock.tsx | 2 +- packages/dev/s2-docs/src/ExampleSwitcher.tsx | 14 +- packages/dev/s2-docs/src/VisualExample.tsx | 2 +- starters/docs/src/Autocomplete.css | 17 - starters/docs/src/Autocomplete.tsx | 40 - starters/docs/src/Button.css | 9 + starters/docs/src/CommandPalette.css | 19 + starters/docs/src/CommandPalette.tsx | 55 + starters/docs/src/Form.css | 1 - starters/docs/src/ListBox.css | 1 - starters/docs/src/Menu.css | 2 + starters/docs/src/Select.css | 5 + starters/docs/src/Table.css | 30 +- starters/docs/src/Tree.css | 74 +- starters/tailwind/src/Autocomplete.tsx | 64 - starters/tailwind/src/CommandPalette.tsx | 55 + starters/tailwind/src/Menu.tsx | 2 +- starters/tailwind/src/SearchField.tsx | 5 +- 22 files changed, 1418 insertions(+), 213 deletions(-) create mode 100644 packages/dev/s2-docs/pages/react-aria/MultiSelect.css delete mode 100644 starters/docs/src/Autocomplete.css delete mode 100644 starters/docs/src/Autocomplete.tsx create mode 100644 starters/docs/src/CommandPalette.css create mode 100644 starters/docs/src/CommandPalette.tsx delete mode 100644 starters/tailwind/src/Autocomplete.tsx create mode 100644 starters/tailwind/src/CommandPalette.tsx diff --git a/packages/dev/s2-docs/pages/react-aria/Autocomplete.mdx b/packages/dev/s2-docs/pages/react-aria/Autocomplete.mdx index 98d37b1b987..105a863d839 100644 --- a/packages/dev/s2-docs/pages/react-aria/Autocomplete.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Autocomplete.mdx @@ -2,7 +2,7 @@ import {Layout} from '../../src/Layout'; export default Layout; import docs from 'docs:react-aria-components'; -import vanillaDocs from 'docs:vanilla-starter/Autocomplete'; +import vanillaDocs from 'docs:vanilla-starter/CommandPalette'; import '../../tailwind/tailwind.css'; export const tags = ['combobox', 'typeahead', 'input']; @@ -12,44 +12,1045 @@ export const tags = ['combobox', 'typeahead', 'input']; {docs.exports.Autocomplete.description} - ```tsx render docs={vanillaDocs.exports.Autocomplete} links={vanillaDocs.links} props={['label', 'disableAutoFocusFirst']} initialProps={{label: 'Commands'}} type="vanilla" files={["starters/docs/src/Autocomplete.tsx", "starters/docs/src/Autocomplete.css"]} + ```tsx render docs={vanillaDocs.exports.CommandPalette} links={vanillaDocs.links} props={['disableAutoFocusFirst']} type="vanilla" files={["starters/docs/src/CommandPalette.tsx", "starters/docs/src/CommandPalette.css"]} "use client"; - import {Autocomplete} from 'vanilla-starter/Autocomplete'; + import {CommandPalette} from 'vanilla-starter/CommandPalette'; import {MenuItem} from 'vanilla-starter/Menu'; + import {Button} from 'vanilla-starter/Button'; + import {FilePlus2, FolderPlus, User, UserPen, CircleDotDashed, ChartPie, Tag} from 'lucide-react'; + import {DialogTrigger, Text} from 'react-aria-components'; + import {useState} from 'react'; - - Create new file... - Create new folder... - Assign to... - Assign to me - Change status... - Change priority... - Add label... - Remove label... - + function Example(props) { + let [isOpen, setOpen] = useState(false); + return ( + + + {/*- begin focus -*/} + + + + Create new file... + + + + Create new folder... + + + + Assign to... + + + + Assign to me + + + + Change status... + + + + Change priority... + + + + Add label... + + + + Remove label... + + + {/*- end focus -*/} + + ); + } ``` - ```tsx render docs={vanillaDocs.exports.Autocomplete} links={vanillaDocs.links} props={['label', 'disableAutoFocusFirst']} initialProps={{label: 'Commands'}} type="tailwind" files={["starters/tailwind/src/Autocomplete.tsx"]} + ```tsx render docs={vanillaDocs.exports.CommandPalette} links={vanillaDocs.links} props={['disableAutoFocusFirst']} initialProps={{label: 'Commands'}} type="tailwind" files={["starters/tailwind/src/CommandPalette.tsx"]} "use client"; - import {Autocomplete, AutocompleteItem} from 'tailwind-starter/Autocomplete'; - - - Create new file... - Create new folder... - Assign to... - Assign to me - Change status... - Change priority... - Add label... - Remove label... - + import {CommandPalette} from 'tailwind-starter/CommandPalette'; + import {MenuItem} from 'tailwind-starter/Menu'; + import {Button} from 'tailwind-starter/Button'; + import {DialogTrigger} from 'react-aria-components'; + import {useState} from 'react'; + + function Example(props) { + let [isOpen, setOpen] = useState(false); + return ( + + + {/*- begin focus -*/} + + Create new file... + Create new folder... + Assign to... + Assign to me + Change status... + Change priority... + Add label... + Remove label... + + {/*- end focus -*/} + + ); + } ``` ## Content -Autocomplete filters a [Menu](Menu.html) or [ListBox](ListBox.html) using a [TextField](TextField.html) or [SearchField](SearchField.html). It can be used to build UI patterns such as command palettes, searchable menus, and filterable selects, and supports features such as static and dynamic collections, sections, disabled items, links, etc. +Autocomplete filters a collection component using a [TextField](TextField.html) or [SearchField](SearchField.html). It can be used to build UI patterns such as command palettes, searchable menus, filterable selects, and more. + +[Menu](Menu.html) and [ListBox](ListBox.html) support **virtual focus**, which allows arrow key navigation within the list while the text input is focused. Use `disableVirtualFocus` to require the user to tab between the input and list. + + + +```tsx render docs={docs.exports.Autocomplete} links={docs.links} props={['disableVirtualFocus', 'disableAutoFocusFirst']} type="vanilla" wide +"use client"; +import {Autocomplete, useFilter} from 'react-aria-components'; +import {MenuTrigger, Menu, MenuItem} from 'vanilla-starter/Menu'; +import {Button} from 'vanilla-starter/Button'; +import {Popover} from 'vanilla-starter/Popover'; +import {SearchField} from 'vanilla-starter/SearchField'; + +function Example(props) { + let {contains} = useFilter({sensitivity: 'base'}); + + return ( + + + + {/*- begin highlight -*/} + + + 'No results.'}> + {/*- end highlight -*/} + News + Travel + Shopping + Business + Entertainment + Food + Technology + Health + Science + + + + + ); +} +``` + +```tsx render docs={docs.exports.Autocomplete} links={docs.links} props={['disableVirtualFocus', 'disableAutoFocusFirst']} type="vanilla" wide +"use client"; +import {Select, Label, SelectValue, Autocomplete, useFilter} from 'react-aria-components'; +import {Button} from 'vanilla-starter/Button'; +import {SelectItem} from 'vanilla-starter/Select'; +import {Popover} from 'vanilla-starter/Popover'; +import {ListBox} from 'vanilla-starter/ListBox'; +import {SearchField} from 'vanilla-starter/SearchField'; +import {ChevronDown} from 'lucide-react'; + +/*- begin collapse -*/ +const states = [ + {id: 'AL', name: 'Alabama'}, + {id: 'AK', name: 'Alaska'}, + {id: 'AZ', name: 'Arizona'}, + {id: 'AR', name: 'Arkansas'}, + {id: 'CA', name: 'California'}, + {id: 'CO', name: 'Colorado'}, + {id: 'CT', name: 'Connecticut'}, + {id: 'DE', name: 'Delaware'}, + {id: 'DC', name: 'District of Columbia'}, + {id: 'FL', name: 'Florida'}, + {id: 'GA', name: 'Georgia'}, + {id: 'HI', name: 'Hawaii'}, + {id: 'ID', name: 'Idaho'}, + {id: 'IL', name: 'Illinois'}, + {id: 'IN', name: 'Indiana'}, + {id: 'IA', name: 'Iowa'}, + {id: 'KS', name: 'Kansas'}, + {id: 'KY', name: 'Kentucky'}, + {id: 'LA', name: 'Louisiana'}, + {id: 'ME', name: 'Maine'}, + {id: 'MD', name: 'Maryland'}, + {id: 'MA', name: 'Massachusetts'}, + {id: 'MI', name: 'Michigan'}, + {id: 'MN', name: 'Minnesota'}, + {id: 'MS', name: 'Mississippi'}, + {id: 'MO', name: 'Missouri'}, + {id: 'MT', name: 'Montana'}, + {id: 'NE', name: 'Nebraska'}, + {id: 'NV', name: 'Nevada'}, + {id: 'NH', name: 'New Hampshire'}, + {id: 'NJ', name: 'New Jersey'}, + {id: 'NM', name: 'New Mexico'}, + {id: 'NY', name: 'New York'}, + {id: 'NC', name: 'North Carolina'}, + {id: 'ND', name: 'North Dakota'}, + {id: 'OH', name: 'Ohio'}, + {id: 'OK', name: 'Oklahoma'}, + {id: 'OR', name: 'Oregon'}, + {id: 'PA', name: 'Pennsylvania'}, + {id: 'RI', name: 'Rhode Island'}, + {id: 'SC', name: 'South Carolina'}, + {id: 'SD', name: 'South Dakota'}, + {id: 'TN', name: 'Tennessee'}, + {id: 'TX', name: 'Texas'}, + {id: 'UT', name: 'Utah'}, + {id: 'VT', name: 'Vermont'}, + {id: 'VA', name: 'Virginia'}, + {id: 'WA', name: 'Washington'}, + {id: 'WV', name: 'West Virginia'}, + {id: 'WI', name: 'Wisconsin'}, + {id: 'WY', name: 'Wyoming'} +]; +/*- end collapse -*/ + +function Example(props) { + let {contains} = useFilter({sensitivity: 'base'}); + + return ( + + ); +} +``` + +```tsx render docs={docs.exports.Autocomplete} links={docs.links} props={['disableVirtualFocus', 'disableAutoFocusFirst']} type="vanilla" wide +"use client"; +import {Autocomplete, useFilter} from 'react-aria-components'; +import {ListBox, ListBoxItem} from 'vanilla-starter/ListBox'; +import {SearchField} from 'vanilla-starter/SearchField'; + +function Example(props) { + let {contains} = useFilter({sensitivity: 'base'}); + + return ( + /*- begin highlight -*/ + + {/*- end highlight -*/} + + 'No results.'} + style={{height: 200}}> + News + Travel + Shopping + Business + Entertainment + Food + Technology + Health + Science + + + ); +} +``` + +```tsx render +"use client"; +import {Autocomplete, useFilter} from 'react-aria-components'; +import {TagGroup, Tag} from 'vanilla-starter/TagGroup'; +import {SearchField} from 'vanilla-starter/SearchField'; + +function Example() { + let {contains} = useFilter({sensitivity: 'base'}); + + return ( + /*- begin highlight -*/ + + {/*- end highlight -*/} + + 'No results.'} + style={{width: 250}}> + News + Travel + Shopping + Business + Entertainment + Food + Technology + Health + Science + + + ); +} +``` + +```tsx render +"use client"; +import {Autocomplete, Text, useFilter} from 'react-aria-components'; +import {GridList, GridListItem} from 'vanilla-starter/GridList'; +import {SearchField} from 'vanilla-starter/SearchField'; + +///- begin collapse -/// +let images = [ + { + id: "8SXaMMWCTGc", + title: "A Ficus Lyrata Leaf", + 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", + width: 400, + height: 600 + }, + { + id: "pYjCqqDEOFo", + title: "Italian beach", + 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", + width: 400, + height: 600 + }, + { + id: "CF-2tl6MQj0", + title: "Forest road", + 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", + width: 400, + height: 300 + }, + { + id: "OW97sLU0cOw", + title: "Snowy Aurora", + 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", + width: 400, + height: 600 + }, + { + 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", + width: 400, + height: 300 + }, + { + id: "w1GpST72Bg8", + title: "Snowy Mountain", + 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", + width: 400, + height: 267 + }, + { + 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", + width: 400, + height: 640 + }, + { + id: "-mFKPfXXUG0", + title: "Snowy Birches", + 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", + width: 400, + height: 400 + }, + { + id: "y36Nj_edtRE", + title: "Snowy Lake Reflections", + 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", + width: 400, + height: 600 + }, + { + id: "NvBV-YwlgBw", + title: "Rocky night sky", + 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", + width: 400, + height: 400 + }, + { + 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", + width: 400, + height: 600 + }, + { + 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", + width: 400, + height: 600 + }, + { + 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", + width: 400, + height: 600 + }, + { + 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", + width: 400, + height: 533 + }, + { + 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", + width: 400, + height: 533 + }, + { + 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", + width: 400, + height: 600 + }, + { + id: "xHqOVq9w8OI", + title: "Leafy plants", + 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", + width: 400, + height: 266 + }, + { + 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", + width: 400, + height: 298 + }, + { + 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", + width: 400, + height: 600 + }, + { + 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", + width: 400, + height: 600 + }, + { + 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", + width: 400, + height: 561 + }, + { + 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", + width: 400, + height: 500 + }, + { + id: "hlj6xJG30FE", + title: "Cave light rays", + 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", + width: 400, + height: 267 + }, + { + id: "vMoZvKeZOhw", + title: "Salt Marshes, Isle of Harris, Scotland", + 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", + width: 400, + height: 600 + }, + { + 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", + width: 400, + height: 267 + }, + { + id: "OdDx3_NB-Wk", + title: "Tall grass", + 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", + width: 400, + height: 600 + }, + { + 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", + width: 400, + height: 600 + }, + { + id: "VhKJHOz2tJ8", + title: "Heart Nebula", + 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", + width: 400, + height: 266 + } +]; +///- end collapse -/// + +function Example() { + let {contains} = useFilter({sensitivity: 'base'}); + + return ( + /*- begin highlight -*/ + + {/*- end highlight -*/} + + 'No results.'} + items={images}> + {(image) => ( + + + {image.title} + By {image.user} + + )} + + + ); +} +``` + +```tsx render +"use client"; +import {Autocomplete, ResizableTableContainer, useFilter} from 'react-aria-components'; +import {Table, TableHeader, TableBody, Column, Row, Cell} from 'vanilla-starter/Table'; +import {SearchField} from 'vanilla-starter/SearchField'; + +/*- begin collapse -*/ +const stocks = [ + { + id: 1, + symbol: 'PAACR', + name: 'Pacific Special Acquisition Corp.', + sector: 'Finance', + marketCap: 'n/a', + industry: 'Business Services', + }, + { + id: 2, + symbol: 'DCM', + name: 'NTT DOCOMO, Inc', + sector: 'Technology', + marketCap: '$96.67B', + industry: 'Radio And Television Broadcasting And Communications Equipment', + }, + { + id: 3, + symbol: 'RFEU', + name: 'First Trust RiverFront Dynamic Europe ETF', + sector: 'n/a', + marketCap: '$52.66M', + industry: 'n/a', + }, + { + id: 4, + symbol: 'SODA', + name: 'SodaStream International Ltd.', + sector: 'Consumer Durables', + marketCap: '$1.13B', + industry: 'Consumer Electronics/Appliances', + }, + { + id: 5, + symbol: 'KRA', + name: 'Kraton Corporation', + sector: 'Basic Industries', + marketCap: '$979.78M', + industry: 'Major Chemicals', + }, + { + id: 6, + symbol: 'VRTS', + name: 'Virtus Investment Partners, Inc.', + sector: 'Finance', + marketCap: '$785.49M', + industry: 'Investment Managers', + }, + { + id: 7, + symbol: 'PAH', + name: 'Platform Specialty Products Corporation', + sector: 'Basic Industries', + marketCap: '$3.52B', + industry: 'Major Chemicals', + }, + { + id: 8, + symbol: 'MANH', + name: 'Manhattan Associates, Inc.', + sector: 'Technology', + marketCap: '$3.27B', + industry: 'Computer Software: Prepackaged Software', + }, + { + id: 9, + symbol: 'SAB', + name: 'Saratoga Investment Corp', + sector: 'n/a', + marketCap: 'n/a', + industry: 'n/a', + }, + { + id: 10, + symbol: 'THQ', + name: 'Tekla Healthcare Opportunies Fund', + sector: 'n/a', + marketCap: '$772.41M', + industry: 'n/a', + }, + { + id: 11, + symbol: 'MERC', + name: 'Mercer International Inc.', + sector: 'Basic Industries', + marketCap: '$769.94M', + industry: 'Paper', + }, + { + id: 12, + symbol: 'DNI', + name: 'Dividend and Income Fund', + sector: 'n/a', + marketCap: '$130.45M', + industry: 'n/a', + }, + { + id: 13, + symbol: 'NVTR', + name: 'Nuvectra Corporation', + sector: 'Health Care', + marketCap: '$132.49M', + industry: 'Medical/Dental Instruments', + }, + { + id: 14, + symbol: 'NNN', + name: 'National Retail Properties', + sector: 'Consumer Services', + marketCap: '$5.87B', + industry: 'Real Estate Investment Trusts', + }, + { + id: 15, + symbol: 'ZF', + name: 'Virtus Total Return Fund Inc.', + sector: 'n/a', + marketCap: '$277.82M', + industry: 'n/a', + }, + { + id: 16, + symbol: 'WF', + name: 'Woori Bank', + sector: 'Finance', + marketCap: '$10.29B', + industry: 'Commercial Banks', + }, + { + id: 17, + symbol: 'VNQI', + name: 'Vanguard Global ex-U.S. Real Estate ETF', + sector: 'n/a', + marketCap: '$4.39B', + industry: 'n/a', + }, + { + id: 18, + symbol: 'BIOC', + name: 'Biocept, Inc.', + sector: 'Health Care', + marketCap: '$32.98M', + industry: 'Medical Specialities', + }, + { + id: 19, + symbol: 'FTRPR', + name: 'Frontier Communications Corporation', + sector: 'Public Utilities', + marketCap: 'n/a', + industry: 'Telecommunications Equipment', + }, + { + id: 20, + symbol: 'EPE', + name: 'EP Energy Corporation', + sector: 'Energy', + marketCap: '$1.02B', + industry: 'Oil & Gas Production', + }, + { + id: 21, + symbol: 'TEO', + name: 'Telecom Argentina Stet - France Telecom S.A.', + sector: 'Public Utilities', + marketCap: '$4.83B', + industry: 'Telecommunications Equipment', + }, + { + id: 22, + symbol: 'FENX', + name: 'Fenix Parts, Inc.', + sector: 'Consumer Services', + marketCap: '$29.61M', + industry: 'Motor Vehicles', + }, + { + id: 23, + symbol: 'KAP', + name: 'KCAP Financial, Inc.', + sector: 'n/a', + marketCap: 'n/a', + industry: 'n/a', + }, + { + id: 24, + symbol: 'WING', + name: 'Wingstop Inc.', + sector: 'Consumer Services', + marketCap: '$875.69M', + industry: 'Restaurants', + }, + { + id: 25, + symbol: 'JNP', + name: 'Juniper Pharmaceuticals, Inc.', + sector: 'Health Care', + marketCap: '$55.3M', + industry: 'Major Pharmaceuticals', + }, + { + id: 26, + symbol: 'KNL', + name: 'Knoll, Inc.', + sector: 'Consumer Durables', + marketCap: '$1.04B', + industry: 'Office Equipment / Supplies / Services', + }, + { + id: 27, + symbol: 'GNW', + name: 'Genworth Financial Inc', + sector: 'Finance', + marketCap: '$1.82B', + industry: 'Life Insurance', + }, + { + id: 28, + symbol: 'PBI', + name: 'Pitney Bowes Inc.', + sector: 'Miscellaneous', + marketCap: '$2.84B', + industry: 'Office Equipment / Supplies / Services', + }, + { + id: 29, + symbol: 'USDP', + name: 'USD Partners LP', + sector: 'Transportation', + marketCap: '$300.48M', + industry: 'Railroads', + }, + { + id: 30, + symbol: 'MOFG', + name: 'MidWestOne Financial Group, Inc.', + sector: 'Finance', + marketCap: '$437.4M', + industry: 'Major Banks', + }, + { + id: 31, + symbol: 'DPG', + name: 'Duff & Phelps Global Utility Income Fund Inc.', + sector: 'n/a', + marketCap: '$626.98M', + industry: 'n/a', + }, + { + id: 32, + symbol: 'ATNX', + name: 'Athenex, Inc.', + sector: 'n/a', + marketCap: '$767.4M', + industry: 'n/a', + }, + { + id: 33, + symbol: 'PSA^Y', + name: 'Public Storage', + sector: 'n/a', + marketCap: 'n/a', + industry: 'n/a', + }, + { + id: 34, + symbol: 'GPIAU', + name: 'GP Investments Acquisition Corp.', + sector: 'Consumer Durables', + marketCap: 'n/a', + industry: 'Home Furnishings', + }, + { + id: 35, + symbol: 'TNP^C', + name: 'Tsakos Energy Navigation Ltd', + sector: 'n/a', + marketCap: 'n/a', + industry: 'n/a', + }, + { + id: 36, + symbol: 'EFSC', + name: 'Enterprise Financial Services Corporation', + sector: 'Finance', + marketCap: '$965.1M', + industry: 'Major Banks', + }, + { + id: 37, + symbol: 'HIIQ', + name: 'Health Insurance Innovations, Inc.', + sector: 'Finance', + marketCap: '$392.38M', + industry: 'Specialty Insurers', + }, + { + id: 38, + symbol: 'NMK^B', + name: 'Niagara Mohawk Holdings, Inc.', + sector: 'Public Utilities', + marketCap: 'n/a', + industry: 'Power Generation', + }, + { + id: 39, + symbol: 'ETH', + name: 'Ethan Allen Interiors Inc.', + sector: 'Consumer Durables', + marketCap: '$822.58M', + industry: 'Home Furnishings', + }, + { + id: 40, + symbol: 'TBPH', + name: 'Theravance Biopharma, Inc.', + sector: 'Health Care', + marketCap: '$1.97B', + industry: 'Major Pharmaceuticals', + }, + { + id: 41, + symbol: 'PNF', + name: 'PIMCO New York Municipal Income Fund', + sector: 'n/a', + marketCap: '$99.42M', + industry: 'n/a', + }, + { + id: 42, + symbol: 'KOP', + name: 'Koppers Holdings Inc.', + sector: 'Basic Industries', + marketCap: '$716.78M', + industry: 'Forest Products', + }, + { + id: 43, + symbol: 'SSB', + name: 'South State Corporation', + sector: 'Finance', + marketCap: '$2.55B', + industry: 'Major Banks', + }, + { + id: 44, + symbol: 'AUY', + name: 'Yamana Gold Inc.', + sector: 'Basic Industries', + marketCap: '$2.32B', + industry: 'Precious Metals', + }, + { + id: 45, + symbol: 'TWNK', + name: 'Hostess Brands, Inc.', + sector: 'Consumer Non-Durables', + marketCap: '$2.09B', + industry: 'Packaged Foods', + }, + { + id: 46, + symbol: 'RGLS', + name: 'Regulus Therapeutics Inc.', + sector: 'Health Care', + marketCap: '$50.52M', + industry: 'Major Pharmaceuticals', + }, + { + id: 47, + symbol: 'ULBI', + name: 'Ultralife Corporation', + sector: 'Miscellaneous', + marketCap: '$102.3M', + industry: 'Industrial Machinery/Components', + }, + { + id: 48, + symbol: 'NFJ', + name: 'AllianzGI NFJ Dividend, Interest & Premium Strategy Fund', + sector: 'Finance', + marketCap: '$1.24B', + industry: 'Finance: Consumer Services', + }, + { + id: 49, + symbol: 'EQC', + name: 'Equity Commonwealth', + sector: 'Consumer Services', + marketCap: '$3.93B', + industry: 'Real Estate Investment Trusts', + }, + { + id: 50, + symbol: 'MARK', + name: 'Remark Holdings, Inc.', + sector: 'Consumer Services', + marketCap: '$57.31M', + industry: 'Telecommunications Equipment', + }, +]; +/*- end collapse -*/ + +function Example() { + let {contains} = useFilter({sensitivity: 'base'}); + + return ( + /*- begin highlight -*/ + + {/*- end highlight -*/} + + + + + + Symbol + + + Name + + Market Cap + Industry + + 'No results.'}> + {(item) => ( + + ${item.symbol} + + {item.name} + + + {item.marketCap} + + + {item.industry} + + + )} + +
+
+
+ ); +} +``` + +
### Asynchronous loading @@ -57,8 +1058,9 @@ When the `filter` prop is not set, the items are controlled. This example uses a ```tsx render "use client"; -import {Autocomplete} from 'vanilla-starter/Autocomplete'; -import {MenuItem} from 'vanilla-starter/Menu'; +import {Autocomplete} from 'react-aria-components'; +import {SearchField} from 'vanilla-starter/SearchField'; +import {ListBox, ListBoxItem} from 'vanilla-starter/ListBox'; import {useAsyncList} from 'react-stately'; function AsyncLoadingExample() { @@ -78,24 +1080,22 @@ function AsyncLoadingExample() { return ( 'No results found.'}> - {(item) => ( - - {item.name} - - )} + onInputChange={list.setFilterText}> + {/*- end highlight -*/} + + 'No results found.'} + style={{height: 300}}> + {(item) => {item.name}} + ); } @@ -103,10 +1103,10 @@ function AsyncLoadingExample() { ## API -```tsx links={{Autocomplete: '#autocomplete', SearchField: 'SearchField.html', TextField: 'TextField.html', Menu: 'Menu.html', ListBox: 'ListBox.html'}} +```tsx links={{Autocomplete: '#autocomplete', SearchField: 'SearchField.html', TextField: 'TextField.html', Menu: 'Menu.html', ListBox: 'ListBox.html', TagGroup: 'TagGroup.html', GridList: 'GridList.html', Table: 'Table.html'}} or - or + , , , , or ``` diff --git a/packages/dev/s2-docs/pages/react-aria/GridList.mdx b/packages/dev/s2-docs/pages/react-aria/GridList.mdx index 1f068c35ac3..222e83a6cdd 100644 --- a/packages/dev/s2-docs/pages/react-aria/GridList.mdx +++ b/packages/dev/s2-docs/pages/react-aria/GridList.mdx @@ -14,11 +14,10 @@ export const tags = ['list view']; {docs.exports.GridList.description} - ```tsx render docs={docs.exports.GridList} links={docs.links} props={['layout', 'selectionMode']} initialProps={{'aria-label': 'Favorite pokemon', selectionMode: 'multiple', layout: 'grid'}} type="vanilla" files={["starters/docs/src/GridList.tsx", "starters/docs/src/GridList.css"]} + ```tsx render docs={docs.exports.GridList} links={docs.links} props={['layout', 'selectionMode']} initialProps={{'aria-label': 'Photos', selectionMode: 'multiple', layout: 'grid'}} type="vanilla" files={["starters/docs/src/GridList.tsx", "starters/docs/src/GridList.css"]} "use client"; import {Text} from 'react-aria-components'; import {GridList, GridListItem} from 'vanilla-starter/GridList'; - import {Button} from 'vanilla-starter/Button'; diff --git a/packages/dev/s2-docs/pages/react-aria/MultiSelect.css b/packages/dev/s2-docs/pages/react-aria/MultiSelect.css new file mode 100644 index 00000000000..a78ddadc5e8 --- /dev/null +++ b/packages/dev/s2-docs/pages/react-aria/MultiSelect.css @@ -0,0 +1,19 @@ +.multi-select { + .react-aria-Group { + display: flex; + gap: var(--spacing-2); + align-items: center; + width: 250px; + padding: var(--spacing-2); + padding-inline-start: 8px; + box-sizing: border-box; + border: 0.5px solid var(--border-color); + border-radius: var(--radius); + background: var(--gray-50); + } + + .react-aria-Button.react-aria-Button { + width: var(--spacing-6); + height: var(--spacing-6); + } +} diff --git a/packages/dev/s2-docs/pages/react-aria/Select.mdx b/packages/dev/s2-docs/pages/react-aria/Select.mdx index 4ed87caf461..4ef5ea06a66 100644 --- a/packages/dev/s2-docs/pages/react-aria/Select.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Select.mdx @@ -114,7 +114,7 @@ function Example() { return ( + + + {/*- begin highlight -*/} + style={{flex: 1}}> + {({selectedItems, state}) => ( + item != null)} + renderEmptyState={() => 'No selected items'} + onRemove={(keys) => { + // Remove keys from Select state. + if (Array.isArray(state.value)) { + state.setValue(state.value.filter(k => !keys.has(k))); + } + }}> + {item => {item.name}} + + )} + + {/*- end highlight -*/} + + + + + + + {state => {state.name}} + + + + + ); +} +``` + ## Value Use the `defaultValue` or `value` prop to set the selected item. The value corresponds to the `id` prop of an item. When `selectionMode="multiple"`, `value` and `onChange` accept an array. Items can be disabled with the `isDisabled` prop. diff --git a/packages/dev/s2-docs/src/CodeBlock.tsx b/packages/dev/s2-docs/src/CodeBlock.tsx index b2313937506..3460e5ddde6 100644 --- a/packages/dev/s2-docs/src/CodeBlock.tsx +++ b/packages/dev/s2-docs/src/CodeBlock.tsx @@ -14,7 +14,7 @@ const example = style({ borderRadius: 'xl', marginY: { default: 32, - ':is([data-example-switcher] > *)': 0 + ':is([data-example-switcher] *)': 0 }, padding: { default: 12, diff --git a/packages/dev/s2-docs/src/ExampleSwitcher.tsx b/packages/dev/s2-docs/src/ExampleSwitcher.tsx index 841d8ea7c94..a7faad60a40 100644 --- a/packages/dev/s2-docs/src/ExampleSwitcher.tsx +++ b/packages/dev/s2-docs/src/ExampleSwitcher.tsx @@ -36,7 +36,11 @@ const switcher = style({ justifySelf: { default: 'center', lg: 'start' - } + }, + overflow: 'auto', + maxWidth: 'full', + padding: 4, + margin: -4 }); const themePicker = style({ @@ -118,9 +122,11 @@ export function ExampleSwitcher({type = 'style', examples = DEFAULT_EXAMPLES, ch return (
- - {examples.map(example => {example})} - +
+ + {examples.map(example => {example})} + +
{selected === 'Vanilla CSS' && *)': 0 + ':is([data-example-switcher] *)': 0 }, borderRadius: 'xl', display: 'grid', diff --git a/starters/docs/src/Autocomplete.css b/starters/docs/src/Autocomplete.css deleted file mode 100644 index 1ac2bf5a647..00000000000 --- a/starters/docs/src/Autocomplete.css +++ /dev/null @@ -1,17 +0,0 @@ -@import "./theme.css"; - -.my-autocomplete { - display: flex; - flex-direction: column; - gap: var(--spacing-1); - max-width: 300px; - height: 180px; - border: 0.5px solid var(--overlay-border); - padding: var(--spacing-4); - border-radius: var(--radius); - background: var(--overlay-background); - - .react-aria-Menu { - flex: 1; - } -} diff --git a/starters/docs/src/Autocomplete.tsx b/starters/docs/src/Autocomplete.tsx deleted file mode 100644 index 286cce2089f..00000000000 --- a/starters/docs/src/Autocomplete.tsx +++ /dev/null @@ -1,40 +0,0 @@ -'use client'; -import { - Autocomplete as AriaAutocomplete, - AutocompleteProps as AriaAutocompleteProps, - Key, - useFilter -} from 'react-aria-components'; -import {Menu} from './Menu'; -import {SearchField} from './SearchField'; - -import './Autocomplete.css'; - -export interface AutocompleteProps - extends Omit { - label?: string; - placeholder?: string; - items?: Iterable; - children: React.ReactNode | ((item: T) => React.ReactNode); - onAction?: (id: Key) => void; - renderEmptyState?: () => React.ReactNode -} - -export function Autocomplete( - { label, placeholder, items, children, onAction, renderEmptyState, ...props }: - AutocompleteProps -) { - let { contains } = useFilter({ sensitivity: 'base' }); - return ( - ( -
- - - - {children} - - -
- ) - ); -} diff --git a/starters/docs/src/Button.css b/starters/docs/src/Button.css index 65e6aee0873..a27b083c7c8 100644 --- a/starters/docs/src/Button.css +++ b/starters/docs/src/Button.css @@ -38,4 +38,13 @@ --highlight-background: var(--gray-1600); } } + + kbd { + font: var(--font-size-sm) system-ui; + background: var(--highlight-hover); + border: 0.5px solid var(--tint-500); + padding: 0 var(--spacing-1); + border-radius: var(--radius-sm); + margin-inline-start: var(--spacing-3); + } } diff --git a/starters/docs/src/CommandPalette.css b/starters/docs/src/CommandPalette.css new file mode 100644 index 00000000000..28d7be3729a --- /dev/null +++ b/starters/docs/src/CommandPalette.css @@ -0,0 +1,19 @@ +@import "./theme.css"; + +.command-palette-dialog { + width: min(90vw, 500px); + height: min(90vh, 356px); + padding: var(--spacing-2); + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: var(--spacing-2); + + > * { + zoom: 1.125; + } + + .react-aria-Menu { + flex: 1; + } +} diff --git a/starters/docs/src/CommandPalette.tsx b/starters/docs/src/CommandPalette.tsx new file mode 100644 index 00000000000..acadf1929b9 --- /dev/null +++ b/starters/docs/src/CommandPalette.tsx @@ -0,0 +1,55 @@ +'use client'; +import { + Autocomplete as AriaAutocomplete, + AutocompleteProps as AriaAutocompleteProps, + MenuProps as AriaMenuProps, + useFilter, + Dialog +} from 'react-aria-components'; +import {Menu} from './Menu'; +import {SearchField} from './SearchField'; +import { Modal } from './Modal'; +import { useEffect } from 'react'; +import './CommandPalette.css'; + +export interface CommandPaletteProps extends Omit, AriaMenuProps { + isOpen: boolean, + onOpenChange: (isOpen?: boolean) => void +} + +export function CommandPalette(props: CommandPaletteProps) { + let {isOpen, onOpenChange} = props; + let {contains} = useFilter({sensitivity: 'base'}); + + useEffect(() => { + let isMacUA = /mac(os|intosh)/i.test(navigator.userAgent); + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'j' && (isMacUA ? e.metaKey : e.ctrlKey)) { + e.preventDefault(); + onOpenChange(true); + } else if (e.key === 'Escape') { + e.preventDefault(); + onOpenChange(false); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onOpenChange]); + + return ( + + + + + 'No results found.'} /> + + + + ); +} diff --git a/starters/docs/src/Form.css b/starters/docs/src/Form.css index b22f0406ae9..e42e10a1b26 100644 --- a/starters/docs/src/Form.css +++ b/starters/docs/src/Form.css @@ -38,7 +38,6 @@ color: var(--text-color); margin-bottom: var(--spacing-2); font-weight: 500; - contain: inline-size; } .react-aria-FieldError { diff --git a/starters/docs/src/ListBox.css b/starters/docs/src/ListBox.css index 36fbc1a1e94..8cd98703386 100644 --- a/starters/docs/src/ListBox.css +++ b/starters/docs/src/ListBox.css @@ -88,7 +88,6 @@ &[data-pressed] { background: var(--highlight-hover); border-radius: var(--radius); - --border-color: transparent; } &[data-focus-visible] { diff --git a/starters/docs/src/Menu.css b/starters/docs/src/Menu.css index 109aa67a2c8..bd599720dfb 100644 --- a/starters/docs/src/Menu.css +++ b/starters/docs/src/Menu.css @@ -23,6 +23,7 @@ align-items: center; justify-content: center; font-style: italic; + min-height: var(--spacing-8); } } @@ -97,6 +98,7 @@ color: var(--text-color-disabled); } + .react-aria-Text:not([slot]), [slot=label] { grid-area: label; font-weight: 500; diff --git a/starters/docs/src/Select.css b/starters/docs/src/Select.css index c7aa184a3a6..ffab8a858c9 100644 --- a/starters/docs/src/Select.css +++ b/starters/docs/src/Select.css @@ -51,6 +51,11 @@ min-height: unset; border: none; + &[data-empty] { + min-height: var(--spacing-8); + padding: var(--spacing-2); + } + .react-aria-Header { padding-left: var(--spacing-7); } diff --git a/starters/docs/src/Table.css b/starters/docs/src/Table.css index dd1761ea0e8..23082fecdc2 100644 --- a/starters/docs/src/Table.css +++ b/starters/docs/src/Table.css @@ -8,7 +8,6 @@ overflow: clip; outline: none; border-spacing: 0; - min-height: 100px; align-self: start; width: 100%; word-break: break-word; @@ -21,15 +20,17 @@ &[data-focus-visible] { outline: 2px solid var(--focus-ring-color); - outline-offset: -1px; } &[data-drop-target] { outline: 2px solid var(--highlight-background); - outline-offset: -1px; background: var(--highlight-overlay) } + &:has(.react-aria-TableBody[data-empty]) { + min-height: 100px; + } + .react-aria-TableHeader { color: var(--text-color); } @@ -63,6 +64,13 @@ background: var(--gray-100); } + &:nth-child(2n) { + background: var(--gray-100); + &[data-pressed] { + background: var(--gray-200); + } + } + &[data-selected] { background: var(--highlight-background); color: var(--highlight-foreground); @@ -93,7 +101,8 @@ &[data-drop-target] { outline: 2px solid var(--highlight-background); - background: var(--highlight-overlay) + background: var(--highlight-overlay); + z-index: 4; } .drag-button { @@ -275,10 +284,21 @@ position: relative; border: 0.5px solid var(--border-color); border-radius: var(--radius); - background: var(--background-color); + background: var(--overlay-background); .react-aria-Table { border: none; + border-radius: 0; + + &:has(.react-aria-TableBody[data-empty]) { + height: 100%; + } + } + + .react-aria-TableHeader { + position: sticky; + top: 0; + z-index: 10; } .react-aria-Cell { diff --git a/starters/docs/src/Tree.css b/starters/docs/src/Tree.css index 7654b83739a..11914ca11de 100644 --- a/starters/docs/src/Tree.css +++ b/starters/docs/src/Tree.css @@ -13,6 +13,8 @@ width: 250px; max-height: 300px; box-sizing: border-box; + --drag-button-width: 0px; + --checkbox-width: 0px; &[data-focus-visible] { outline: 2px solid var(--focus-ring-color); @@ -27,11 +29,11 @@ } &[data-selection-mode=multiple] { - --checkbox-width: 28px; + --checkbox-width: calc(var(--spacing) * 6.5); } &[data-allows-dragging] { - --drag-button-width: 23px; + --drag-button-width: var(--spacing-6); } &[data-drop-target] { @@ -43,7 +45,7 @@ .react-aria-DropIndicator { &[data-drop-target] { outline: 1px solid var(--highlight-background); - margin-left: calc(8px + var(--checkbox-width, 0px) + var(--drag-button-width, 0px) + 26px + (var(--tree-item-level) - 1) * 16px); + margin-left: calc(var(--spacing-2) + var(--checkbox-width) + var(--drag-button-width) + var(--spacing-5) + (var(--tree-item-level) - 1) * var(--spacing-4)); } } } @@ -65,32 +67,21 @@ transition-property: background, color, border-radius; transition-duration: 200ms; -webkit-tap-highlight-color: transparent; + --chevron-width: var(--spacing-5); - .react-aria-Button[slot=chevron] { - all: unset; - display: flex; - visibility: hidden; - align-items: center; - justify-content: center; - width: var(--spacing-4); - height: 100%; - padding-left: calc((var(--tree-item-level) - 1) * var(--padding)); - - svg { - rotate: 0deg; - transition: rotate 200ms; - fill: none; - stroke: currentColor; - stroke-width: 3px; - } + &[data-has-child-items] { + --chevron-width: 0px; } - &[data-has-child-items] .react-aria-Button[slot=chevron] { - visibility: visible; - } - - &[data-expanded] .react-aria-Button[slot=chevron] svg { - rotate: 90deg; + --border-color: var(--gray-300); + &:not(:last-child)::after { + content: ''; + display: block; + position: absolute; + bottom: 0; + inset-inline-start: calc(var(--spacing-2) + var(--checkbox-width) + var(--drag-button-width) + var(--chevron-width) + (var(--tree-item-level) - 1) * var(--padding)); + inset-inline-end: var(--spacing-2); + border-bottom: 0.5px solid var(--border-color); } &[data-focus-visible] { @@ -106,6 +97,7 @@ background: var(--highlight-background); color: var(--highlight-foreground); --focus-ring-color: var(--highlight-foreground); + --border-color: transparent; &[data-focus-visible] { outline-color: var(--highlight-foreground); @@ -116,6 +108,7 @@ &:has(+ .react-aria-DropIndicator + [data-selected]) { border-end-start-radius: 0; border-end-end-radius: 0; + --border-color: rgb(255 255 255 / 0.3); } + [data-selected], @@ -150,12 +143,39 @@ background: var(--highlight-overlay); } + .react-aria-Button[slot=chevron] { + all: unset; + display: flex; + visibility: hidden; + align-items: center; + justify-content: center; + width: var(--spacing-4); + height: 100%; + padding-left: calc((var(--tree-item-level) - 1) * var(--padding)); + + svg { + rotate: 0deg; + transition: rotate 200ms; + fill: none; + stroke: currentColor; + stroke-width: 3px; + } + } + + &[data-has-child-items] .react-aria-Button[slot=chevron] { + visibility: visible; + } + + &[data-expanded] .react-aria-Button[slot=chevron] svg { + rotate: 90deg; + } + .react-aria-Button[slot=drag] { all: unset; display: inline-flex; align-items: center; justify-content: center; - width: 15px; + width: var(--spacing-4); text-align: center; &[data-focus-visible] { diff --git a/starters/tailwind/src/Autocomplete.tsx b/starters/tailwind/src/Autocomplete.tsx deleted file mode 100644 index f6c62d6c13e..00000000000 --- a/starters/tailwind/src/Autocomplete.tsx +++ /dev/null @@ -1,64 +0,0 @@ -'use client'; -import React from "react"; -import { - Autocomplete as AriaAutocomplete, - AutocompleteProps as AriaAutocompleteProps, - Menu as AriaMenu, - MenuSection as AriaMenuSection, - MenuSectionProps as AriaMenuSectionProps, - Collection, - Header, - MenuItemProps, - useFilter, -} from "react-aria-components"; -import { MenuItem } from "./Menu"; -import { SearchField } from "./SearchField"; - -export interface AutocompleteProps extends Omit { - children: React.ReactNode | ((item: T) => React.ReactNode); - items?: Iterable; - label?: string; -} - -export function Autocomplete({ - items, - children, - label, - ...props -}: AutocompleteProps) { - let { contains } = useFilter({ sensitivity: "base" }); - return ( -
- - - - {children} - - -
- ); -} - -export function AutocompleteItem(props: MenuItemProps) { - return ; -} - -export interface AutocompleteSectionProps extends AriaMenuSectionProps { - title?: string - items?: any -} - -export function AutocompleteSection(props: AutocompleteSectionProps) { - return ( - -
{props.title}
- - {props.children} - -
- ); -} diff --git a/starters/tailwind/src/CommandPalette.tsx b/starters/tailwind/src/CommandPalette.tsx new file mode 100644 index 00000000000..8b450f7c05f --- /dev/null +++ b/starters/tailwind/src/CommandPalette.tsx @@ -0,0 +1,55 @@ +'use client'; +import { + Autocomplete as AriaAutocomplete, + AutocompleteProps as AriaAutocompleteProps, + MenuProps as AriaMenuProps, + useFilter, + Dialog +} from 'react-aria-components'; +import {Menu} from './Menu'; +import {SearchField} from './SearchField'; +import {Modal} from './Modal'; +import React, {useEffect} from 'react'; + +export interface CommandPaletteProps extends Omit, AriaMenuProps { + isOpen: boolean, + onOpenChange: (isOpen?: boolean) => void +} + +export function CommandPalette(props: CommandPaletteProps) { + let {isOpen, onOpenChange} = props; + let {contains} = useFilter({sensitivity: 'base'}); + + useEffect(() => { + let isMacUA = /mac(os|intosh)/i.test(navigator.userAgent); + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'j' && (isMacUA ? e.metaKey : e.ctrlKey)) { + e.preventDefault(); + onOpenChange(true); + } else if (e.key === 'Escape') { + e.preventDefault(); + onOpenChange(false); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onOpenChange]); + + return ( + + + + + 'No results found.'} /> + + + + ); +} diff --git a/starters/tailwind/src/Menu.tsx b/starters/tailwind/src/Menu.tsx index 1aa7d969919..22ab332abaa 100644 --- a/starters/tailwind/src/Menu.tsx +++ b/starters/tailwind/src/Menu.tsx @@ -23,7 +23,7 @@ import { Popover, PopoverProps } from './Popover'; export function Menu(props: MenuProps) { return ( - + ); } diff --git a/starters/tailwind/src/SearchField.tsx b/starters/tailwind/src/SearchField.tsx index 115eddc6f50..9263babaa4d 100644 --- a/starters/tailwind/src/SearchField.tsx +++ b/starters/tailwind/src/SearchField.tsx @@ -14,17 +14,18 @@ export interface SearchFieldProps extends AriaSearchFieldProps { label?: string; description?: string; errorMessage?: string | ((validation: ValidationResult) => string); + placeholder?: string } export function SearchField( - { label, description, errorMessage, ...props }: SearchFieldProps + { label, description, errorMessage, placeholder, ...props }: SearchFieldProps ) { return ( {label && } - + From 2b8d6f6cf1b930341e2123a22745311455ef10a4 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 20 Oct 2025 12:53:40 -0700 Subject: [PATCH 4/7] fix: add placeholder to starter app Textfield types (#9069) * fix: add placeholder to starter app Textfield types * whoops typo --- starters/docs/src/TextField.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/starters/docs/src/TextField.tsx b/starters/docs/src/TextField.tsx index efc445e32a0..afa1834c231 100644 --- a/starters/docs/src/TextField.tsx +++ b/starters/docs/src/TextField.tsx @@ -12,15 +12,16 @@ export interface TextFieldProps extends AriaTextFieldProps { label?: string; description?: string; errorMessage?: string | ((validation: ValidationResult) => string); + placeholder?: string } export function TextField( - { label, description, errorMessage, ...props }: TextFieldProps + { label, description, errorMessage, placeholder, ...props }: TextFieldProps ) { return ( - + {description && {description}} {errorMessage} From bd354fafdef3366aadedc945f686917f2a4a0b9d Mon Sep 17 00:00:00 2001 From: Abel John <9206066+abeljohn@users.noreply.github.com> Date: Mon, 20 Oct 2025 13:04:32 -0700 Subject: [PATCH 5/7] fix: update default disabledBehavior on RAC TableProps (#9042) * fix default disabledBehavior of RAC Table * Revert "fix default disabledBehavior of RAC Table" This reverts commit ff79b9aab2fe30a7837a89b670779997739a46b9. * update jsdoc to correctly reflect default disabledBehavior of RAC Table --- packages/react-aria-components/src/Table.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 8b26e7565c0..32a9ad4b845 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -337,7 +337,7 @@ export interface TableProps extends Omit, 'children'>, Sty selectionBehavior?: SelectionBehavior, /** * Whether `disabledKeys` applies to all interactions, or only selection. - * @default "selection" + * @default "all" */ disabledBehavior?: DisabledBehavior, /** Handler that is called when a user performs an action on the row. */ From 33b6092dc1ad30ccf24d43afa15feb12546b4368 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Mon, 20 Oct 2025 18:04:15 -0500 Subject: [PATCH 6/7] fix: update RAC Autocomplete starter stories to fix verdaccio build (#9070) * fix: update RAC Autocomplete starter stories to fix verdaccio build * formatting --- .../docs/stories/Autocomplete.stories.tsx | 38 ++++---- .../tailwind/stories/Autocomplete.stories.tsx | 94 +++++++++++-------- 2 files changed, 74 insertions(+), 58 deletions(-) diff --git a/starters/docs/stories/Autocomplete.stories.tsx b/starters/docs/stories/Autocomplete.stories.tsx index 85a46b52f86..f9e84dccbda 100644 --- a/starters/docs/stories/Autocomplete.stories.tsx +++ b/starters/docs/stories/Autocomplete.stories.tsx @@ -1,10 +1,12 @@ -import {Autocomplete} from '../src/Autocomplete'; +import {Button} from '../src/Button'; +import {CommandPalette} from '../src/CommandPalette'; +import {DialogTrigger} from '../src/Dialog'; import {MenuItem} from '../src/Menu'; import type {Meta, StoryFn} from '@storybook/react'; -const meta: Meta = { - component: Autocomplete, +const meta: Meta = { + component: CommandPalette, parameters: { layout: 'centered' }, @@ -12,22 +14,20 @@ const meta: Meta = { }; export default meta; -type Story = StoryFn; +type Story = StoryFn; export const Example: Story = (args) => ( - - Create new file... - Create new folder... - Assign to... - Assign to me - Change status... - Change priority... - Add label... - Remove label... - + + + + Create new file... + Create new folder... + Assign to... + Assign to me + Change status... + Change priority... + Add label... + Remove label... + + ); - -Example.args = { - label: 'Commands', - placeholder: 'Search commands...' -}; diff --git a/starters/tailwind/stories/Autocomplete.stories.tsx b/starters/tailwind/stories/Autocomplete.stories.tsx index 05392955bc8..f834a4798d2 100644 --- a/starters/tailwind/stories/Autocomplete.stories.tsx +++ b/starters/tailwind/stories/Autocomplete.stories.tsx @@ -1,28 +1,36 @@ -import { Meta } from '@storybook/react'; -import React from 'react'; -import { Autocomplete, AutocompleteItem, AutocompleteSection } from '../src/Autocomplete'; +import {Meta} from "@storybook/react"; +import React from "react"; +import {CommandPalette} from "../src/CommandPalette"; +import {Button} from "../src/Button"; +import {DialogTrigger} from "react-aria-components"; +import {MenuItem, MenuSection} from "../src/Menu"; -const meta: Meta = { - component: Autocomplete, +const meta: Meta = { + component: CommandPalette, parameters: { layout: 'centered' }, - tags: ['autodocs'], - args: { - label: 'Ice cream flavor' - } + tags: ['autodocs'] }; export default meta; export const Example = (args: any) => ( - - Chocolate - Mint - Strawberry - Vanilla - Cookies and Cream - + + + + Chocolate + Mint + Strawberry + Vanilla + Cookies and Cream + + ); export const DisabledItems = (args: any) => ; @@ -31,30 +39,38 @@ DisabledItems.args = { }; export const Sections = (args: any) => ( - - - Apple - Banana - Orange - Honeydew - Grapes - Watermelon - Cantaloupe - Pear - - - Cabbage - Broccoli - Carrots - Lettuce - Spinach - Bok Choy - Cauliflower - Potatoes - - + + + + + Apple + Banana + Orange + Honeydew + Grapes + Watermelon + Cantaloupe + Pear + + + Cabbage + Broccoli + Carrots + Lettuce + Spinach + Bok Choy + Cauliflower + Potatoes + + + ); Sections.args = { - label: 'Preferred fruit or vegetable' + label: 'Preferred fruit or vegetable', }; From f40b575e38837e1aa7cabf0431406e81275d118a Mon Sep 17 00:00:00 2001 From: Vladimir Semyonov <20096510+vovsemenv@users.noreply.github.com> Date: Tue, 21 Oct 2025 02:05:53 +0300 Subject: [PATCH 7/7] fix (#8984) Co-authored-by: Daniel Lu Co-authored-by: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> --- packages/@react-stately/select/src/useSelectState.ts | 4 ++-- packages/@react-types/select/src/index.d.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/@react-stately/select/src/useSelectState.ts b/packages/@react-stately/select/src/useSelectState.ts index 70e2c14802f..176cbf02bba 100644 --- a/packages/@react-stately/select/src/useSelectState.ts +++ b/packages/@react-stately/select/src/useSelectState.ts @@ -162,13 +162,13 @@ export function useSelectState extends Coll /** Sets the default open state of the menu. */ defaultOpen?: boolean, /** Method that is called when the open state of the menu changes. */ - onOpenChange?: (isOpen: boolean) => void + onOpenChange?: (isOpen: boolean) => void, + /** Whether the select should be allowed to be open when the collection is empty. */ + allowsEmptyCollection?: boolean } export interface AriaSelectProps extends SelectProps, DOMProps, AriaLabelingProps, FocusableDOMProps {