Skip to content

[KZN-3415] uses native popover api in SingleSelect #5885

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 27 commits into
base: main
Choose a base branch
from

Conversation

Kitty-Al
Copy link
Contributor

@Kitty-Al Kitty-Al commented Jul 2, 2025

https://cultureamp.atlassian.net/browse/KZN-3415

What

  • Implements the native popover api which handles moving the popover into the css layer
  • uses useOverlayPosition to handle the popover positioning instead of using css anchor and having to manually handle the resizing & flipping of the popover
  • positions popover via css anchor positioning, if anchor positioning not supported we're now positioning manually via javascript

Positioning supports:

  • SSR
  • RTL layouts
  • Window resizing

Notes: Because we now handle the popover visibility manually via the Select component state we have to wire up the selected state, and clone the children passing the appropriate props.

Why

Reducing any future risk of z-index and layout issues. These were a regular occurance in the past

Initially positioning fallback was meant to be done with RAC - however RAC was not working in RTL layouts due to quirks of positioning in the css top-layer that RAC doesn't account for.

Copy link

changeset-bot bot commented Jul 2, 2025

⚠️ No Changeset found

Latest commit: 239f351

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@Kitty-Al Kitty-Al marked this pull request as ready for review July 3, 2025 00:28
Copy link
Contributor

github-actions bot commented Jul 3, 2025

✨ Here is your branch preview! ✨

Last updated for commit 239f351: Merge branch 'main' into KZN-3415/popover-api

onSelectionChange={(key) =>
handleOnSelectionChange(key != null ? new Set([key]) : new Set())
}
className={classNameOverride}
Copy link
Contributor

Choose a reason for hiding this comment

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

One this I noticed was that we're using classNameOverride here instead of className. While this was the pattern for our older components, for recent ones like Button, Link and LinkButton we've allowed for className to be used so folks can leverage the RACs render props (see RAC styling section).

Looking at some of the sub-components that use RAC like ListItem they have className available so was just wondering if we are selectively choosing which ones have access to this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for calling this out 😄 I never remember whether classNameOverride is new or old pattern given that it's still being used in some of our next components. I've removed it now and written myself a note 🧠

Comment on lines 12 to 15
interface TriggerProps {
buttonRef: React.RefObject<HTMLButtonElement>
}

Copy link
Contributor

Choose a reason for hiding this comment

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

I noticed that were using an interface for TriggerProps and was wandering if there's a specifc reason for chosing this over type?

In the past we made a decision to lean on type over interface unless we foresaw the need to extend / merge a type. The main reasoning for where we landed was to simplify the decision process of whether something should be an interface or type, which has lead to most of our codebase using type.

@@ -0,0 +1,9 @@
export type SelectItem = {
label: string
Copy link
Contributor

Choose a reason for hiding this comment

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

Question on the types here, is the item always going to have a string label or is will it need to be a React node / element to be able handle Avatars, icons and such?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is definitely not set in stone, and will most likely change. My thoughts are that we would support a Key: number or string. Passing in items will be mostly used for handling state and async behaviour. If users want to render a bespoke item they can do so by mapping over their own ListItem and passing it as children.

@@ -12,7 +13,9 @@ export default meta
type Story = StoryObj<typeof meta>

export const Playground: Story = {
args: {},
args: {
items: singleMockItems,
Copy link
Contributor

Choose a reason for hiding this comment

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

I noticed in the playground that when there is no children there appears to be a bullet rendered.
Screenshot 2025-07-08 at 11 10 09 am

I'm assume we'll tackle what null or an empty children prop looks like later but just wanted to flag it in this story :)

zIndex: 'none',
margin: '0',
boxSizing: 'border-box',
width: buttonRef.current?.getBoundingClientRect().width ?? '200px',
Copy link
Contributor

Choose a reason for hiding this comment

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

So this would mean whatever the trigger button size is there popover box will be. Is there plans to give the consumers the ability to control the trigger styles a little more, ie: either a custom trigger button or giving them a className prop?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

At the moment these styles are still temporary, as we don't have the designs for the popover yet.

placeholder=""
{...restProps}
>
<Trigger buttonRef={buttonRef} />
Copy link
Contributor

Choose a reason for hiding this comment

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

The trigger at the moment is controlled entirely by our implementation with only the buttonRef exposed. Have we considered any potential needs for our consumers to either have a custom trigger or to be able to pass in data attributes that they may used in testing or for programmatically shifting focus?

The main thing that gets me thinking about that is this recent support question

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the callout. I totally appreciate that our users might need more flexibility here. I've written a ticket so that we capture the work on exposing the trigger & popover, trying to give our users as much flexibility as possible https://cultureamp.atlassian.net/browse/KZN-3462

describe('renders', () => {
it('a basic select component', () => {
const { getByRole } = render(<SingleSelectWrapper />)
const select = getByRole('button')
expect(select).toBeInTheDocument()
})
})

describe('popover interactions', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

I keen to get thoughts about potentially moving the interaction tests over to Storybook's play tests 🤔

I don't think we have a hard and fast rule about when to use one over the other so might also be worth discussing as a team when we're all back :)

shouldFlip: true,
})
return (
<RACPopover trigger="manual" isOpen={isOpen} onOpenChange={setOpen} ref={racPopoverRef}>
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we'll need to give this an aria-labelledby to ensure the element with the role dialog has a title

Copy link
Contributor

@mcwinter07 mcwinter07 left a comment

Choose a reason for hiding this comment

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

One thing we might also need to consider is the keyboard behaviour for arrow keys to open the popover when focused on the trigger. We received this feedback in the recent Intopia audit that elements with aria-haspopup="listbox" imply that arrow keys can be used to open dialogs.

return { '--position-anchor': anchorName } as React.CSSProperties
}
return getAnchorPositioningStyles(anchorName, positionData)
}, [hasAnchorSupport, anchorName, positionData])
Copy link
Contributor

Choose a reason for hiding this comment

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

I would invert this function and check if hasAnchorSupport is true and fallback to manual positioning:

if(!!hasAnchorSupport) {
  return getAnchorPositioningStyles(anchorName, positionData)
}

return getManualPositioningStyles(positionData)

useEffect(() => {
if (typeof window === 'undefined') return // SSR safety
// This effect only runs on the client after hydration
if (typeof window === 'undefined') return
Copy link
Contributor

Choose a reason for hiding this comment

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

You'd definitely want to useLayoutEffect here as this will run before painting and I would go back to the client hook but make it check if window/document exists and just bail e.g.

function useClientLayoutEffect( ...args) {
  if (typeof document === "undefined") return;

  useLayoutEffect(...args); 
} 

This way if this gets SSR'd then it'll just not run anything and the name is clear that its client side only

/**
* Hook to calculate and update the position of an overlay rendered in the top layer.
*/
export function useFixedOverlayPosition({
Copy link
Contributor

Choose a reason for hiding this comment

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

We can simplify this in the case where the browser supports anchor positioning and we would only need to keep track of maxHeight adjustments otherwise anchor positioning can do everything else.

This is the CSS snippet I have on my local, maybe we pair on this.

@layer kz-components {
  .popover {
    // ...
    @supports (anchor-name: --anchor) {
      position-area: bottom center;
      position-try-fallbacks: flip-block;
      margin-block-start: 4px;
      width: anchor-size(width);
    }
  } 
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants