Skip to content

Conversation

snowystinger
Copy link
Member

@snowystinger snowystinger commented Sep 4, 2025

Closes #2874

Fixes popover positioning, see first commit's new story to see bad behaviour by scrolling trigger partially out of view horizontally to start. You'll see the popover rendered way out of place. After the second commit, it has been fixed and the popover will be bound by the scrolling element.

Fixes pending state for buttons so they aren't clickable through context. Also adds the pending state to ActionButtons.

Adds the default label which we support in other components to Picker's items.

Moves S2 Table cell focus rings so they render over the cell content.

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

🧢 Your Project:

@rspbot
Copy link

rspbot commented Sep 4, 2025

@rspbot
Copy link

rspbot commented Sep 4, 2025

Copy link
Member

@LFDanLu LFDanLu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behavior looks good to me, will need some more time to double check the calculatePosition change (wish it wasn't so brittle)

Comment on lines 168 to 170
if (key.startsWith('on')) {
props[key] = undefined;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about onBlur/onFocus? Should those be removed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

o doh, i saw "focusProps" and assumed that's where that was being handled, you are correct

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one question i did have here, is should i move this logic into useButton, it might be easier, but currently useButton doesn't know about pending since there are so many style dependent and dom structure requirements, so it only exists in higher levels

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm, is this mainly a concern about how the spinner may be rendered relative to the button and/or if there is a spinner at all? Not quite sure I fully understand the style/dom structure dependencies here

Copy link
Member Author

@snowystinger snowystinger Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

essentially there are different ways you can set things up
https://react-spectrum.adobe.com/react-aria/Button.html#accessibility

but the event behaviour should probably be the same, it'd just be weird to put that into the hooks i guess since it doesn't include a lot of the other choices

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gotcha I think for now we punt on moving the logic into the hooks then

@rspbot
Copy link

rspbot commented Sep 4, 2025

@rspbot
Copy link

rspbot commented Sep 5, 2025

@rspbot
Copy link

rspbot commented Sep 5, 2025

@rspbot
Copy link

rspbot commented Sep 5, 2025

@rspbot
Copy link

rspbot commented Sep 5, 2025

@@ -181,7 +181,7 @@ function getDelta(
// 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 startEdgeOffset = offset - containerScroll + containerOffsetWithBoundary[axis];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I double checked this and found that it was tied to pinch zoom positioning: 3722d30. If you comment out what the startEdge/endEdge values here are, try opening the RAC Popover story outside the iframe, and open the popover after pinch zooming on Safari in such a way that the trigger is close to the edge of the left of the window you'll see that the startEdgeOffset calculation is incorrect (the boundary will return 12, but the start edge will return a value that is much larger). Each of the values here should return coordinate values transformed in such a way that a zoomed in window should have its origin at the top left.

As a result, opening a dropdown when the trigger is partially off screen won't do the following (aka preserve the visibility of the dropdown by keeping it in the bounds of the screen)
image

instead this happens, where the dropdown falls outside of its defined boundary
image

I see the original issue you alluded to in your description, but I think the calculated boundary edges are also incorrect in that story (they currently return 0 and 300 respectively but should be the left and right edge of the box with respect to the top left corner of the story).

In addition, the startEdgeOffset should just be equal to the offset in that case since that value is already in the proper coordinate system, but at the moment it is receiving a containerOffsetWithBoundary that has negative values that should instead be offsetting the value of boundaryDimensions (aka containerOffsetWithBoundary is representing how far offset the boundary element is with respect to the containing element aka the page). I think we'll need to

  1. fix the boundaryStartEdge and boundaryEndEdge calcs here
  2. Change it to be - containerOffsetWithBoundary[axis] - boundaryDimensions[AXIS[axis]]; or update how containerOffsetWithBoundary is being calculated to return positive values (this one is probably more correct if we consider the top left corner to be the 0,0 origin and there for the offset should be positive instead of negative if the container is centered in the page)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahhh, thank you, I'll have a look on Monday

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no worries, happy to sit with you and debug if you'd like as well, this stuff is tricky :/

@rspbot
Copy link

rspbot commented Sep 8, 2025

@rspbot
Copy link

rspbot commented Sep 9, 2025

@@ -507,7 +529,7 @@ export function calculatePosition(opts: PositionOpts): PositionResult {
// 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);
let containerOffsetWithBoundary: Offset = boundaryElement.tagName === 'BODY' ? getOffset(container, false) : getPosition(boundaryElement, container, false);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flipped so that getPosition works in the same direction as getOffset

@@ -138,13 +138,16 @@ describe('calculatePosition', function () {
};

const container = createElementWithDimensions('div', containerDimensions);
Object.assign(container.style, {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

calculate position will look for the containing block, which should be container and not document.body as it is in the default case. The default case should mostly be covered by chromatic. These tests cover our other main case where the overlay is inside the container that calculate position will use.

@@ -89,7 +89,7 @@ describe('useOverlayPosition', function () {
position: absolute;
z-index: 100000;
left: 12px;
bottom: 518px;
bottom: 350px;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these were wrong

@@ -143,6 +143,9 @@ Default.play = async ({canvasElement}) => {
let body = canvasElement.ownerDocument.body;
let menu = await within(body).getByRole('menu');
let menuItems = within(menu).getAllByRole('menuitem');

// clean up any previous click state
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the fix for chromatic's stories that threw errors

order: 0
})
}],
[ImageContext, {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we just add opacity to be overridable to every component? visibility?

staticColor: staticColor,
size: props.size === 'XS' ? undefined : props.size,
isDisabled: isDisabled,
styles: style({position: 'absolute', top: '--badgeTop', insetStart: '--badgePosition', marginTop: 'calc((self(height) * -1)/2)', marginStart: 'calc((self(height) * -1)/2)'})
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably shouldn't use notifications and pending together? very much an edge case

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i assume it would disappear or something but i doubt design as thought of this case

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, i'm leaving this alone for now

@@ -526,6 +527,11 @@ const PickerButton = createHideableComponent(function PickerButton<T extends obj
[TextContext, {
slots: {
description: {},
[DEFAULT_SLOT]: {styles: style({
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is already how we do it in Menu and ComboBox


return (
<ListBoxItem
isDisabled={isDisabled}
ref={ref}
className={renderProps => (UNSAFE_className || '') + selectBoxStyles({
...renderProps,
isFocusVisible: isFocusVisible && renderProps.isFocused,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is because Menu/Picker would remove the focus ring if the mouse moves over it, but a SelectBox should behave like a Radio or Checkbox, and should not have this behaviour

@@ -85,10 +85,10 @@ export const ButtonContext = createContext<ContextValue<ButtonContextValue, HTML
*/
export const Button = /*#__PURE__*/ createHideableComponent(function Button(props: ButtonProps, ref: ForwardedRef<HTMLButtonElement>) {
[props, ref] = useContextProps(props, ref, ButtonContext);
props = disablePendingProps(props);
let ctx = props as ButtonContextValue;
let {isPending} = ctx;
let {buttonProps, isPressed} = useButton(props, ref);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pressable props were added inside useButton via the Pressable context

@rspbot
Copy link

rspbot commented Sep 10, 2025

@rspbot
Copy link

rspbot commented Sep 10, 2025

reidbarber
reidbarber previously approved these changes Sep 10, 2025
Copy link
Member

@reidbarber reidbarber left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! LGTM

Comment on lines +297 to +300
const containerHeight = (isContainerPositioned ? containerOffsetWithBoundary.height : containerDimensions[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 container.
let overlayTop = position.top != null ? position.top : (containerHeight - (position.bottom ?? 0) - overlayHeight);
Copy link
Member

@LFDanLu LFDanLu Sep 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ugh just found this case:
image

it seems pretty tied to this scroll position where there the trigger is basically flush with the boundary give or take a little bit. https://reactspectrum.blob.core.windows.net/reactspectrum/4097f3c57914b81db847e123fdc996f066df81b3/storybook/index.html?path=/story/react-aria-components-popover--scrolling-boundary-container&providerSwitcher-express=false

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decision after chatting, we'll bring this to the team, but since it's not a regression, we can move forward

@rspbot
Copy link

rspbot commented Sep 10, 2025

@rspbot
Copy link

rspbot commented Sep 10, 2025

## API Changes

@react-spectrum/s2

/@react-spectrum/s2:ActionButton

 ActionButton {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-controls?: string
   aria-current?: boolean | 'true' | 'false' | 'page' | 'step' | 'location' | 'date' | 'time'
   aria-describedby?: string
   aria-details?: string
   aria-disabled?: boolean | 'true' | 'false'
   aria-expanded?: boolean | 'true' | 'false'
   aria-haspopup?: boolean | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog' | 'true' | 'false'
   aria-label?: string
   aria-labelledby?: string
   aria-pressed?: boolean | 'true' | 'false' | 'mixed'
   autoFocus?: boolean
   children: ReactNode
   excludeFromTabOrder?: boolean
   form?: string
   formAction?: string
   formEncType?: string
   formMethod?: string
   formNoValidate?: boolean
   formTarget?: string
   id?: string
   isDisabled?: boolean
+  isPending?: boolean
   isQuiet?: boolean
   name?: string
   onBlur?: (FocusEvent<Target>) => void
   onFocus?: (FocusEvent<Target>) => void
   onKeyDown?: (KeyboardEvent) => void
   onKeyUp?: (KeyboardEvent) => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   onPressUp?: (PressEvent) => void
   preventFocusOnPress?: boolean
   size?: 'XS' | 'S' | 'M' | 'L' | 'XL' = 'M'
   slot?: string | null
   staticColor?: 'black' | 'white' | 'auto'
   styles?: StylesProp
   type?: 'button' | 'submit' | 'reset' = 'button'
   value?: string
 }

/@react-spectrum/s2:ActionButtonProps

 ActionButtonProps {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-controls?: string
   aria-current?: boolean | 'true' | 'false' | 'page' | 'step' | 'location' | 'date' | 'time'
   aria-describedby?: string
   aria-details?: string
   aria-disabled?: boolean | 'true' | 'false'
   aria-expanded?: boolean | 'true' | 'false'
   aria-haspopup?: boolean | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog' | 'true' | 'false'
   aria-label?: string
   aria-labelledby?: string
   aria-pressed?: boolean | 'true' | 'false' | 'mixed'
   autoFocus?: boolean
   children: ReactNode
   excludeFromTabOrder?: boolean
   form?: string
   formAction?: string
   formEncType?: string
   formMethod?: string
   formNoValidate?: boolean
   formTarget?: string
   id?: string
   isDisabled?: boolean
+  isPending?: boolean
   isQuiet?: boolean
   name?: string
   onBlur?: (FocusEvent<Target>) => void
   onFocus?: (FocusEvent<Target>) => void
   onKeyDown?: (KeyboardEvent) => void
   onKeyUp?: (KeyboardEvent) => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   onPressUp?: (PressEvent) => void
   preventFocusOnPress?: boolean
   size?: 'XS' | 'S' | 'M' | 'L' | 'XL' = 'M'
   slot?: string | null
   staticColor?: 'black' | 'white' | 'auto'
   styles?: StylesProp
   type?: 'button' | 'submit' | 'reset' = 'button'
   value?: string
 }

Copy link
Member

@LFDanLu LFDanLu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, verified that it also fixes #2874 locally. Checked a couple of other positioning issues but no such luck

@snowystinger snowystinger added this pull request to the merge queue Sep 10, 2025
Merged via the queue into main with commit 38a7696 Sep 11, 2025
35 checks passed
@snowystinger snowystinger deleted the fix-various-bugs branch September 11, 2025 00:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

useOverlayPosition wrong position when boundary element is set
5 participants