Skip to content

Conversation

@benjaminleonard
Copy link
Contributor

@benjaminleonard benjaminleonard commented Jun 9, 2023

The current listbox has issues when used within a modal, it is absolutely positioned and clipped by the parent container. Notably when you open a listbox inside a modal towards the end of a form or in a non-side modal.

We are presently using downshift's useSelect hook. My first attempt was to use the react-aria select and combobox implementation since we are using that lib elsewhere. #1558

This has a few issues, there's a bug that prevents the user from selecting an option after a form has validated or errored that field. We can fix this by adding the error as a key to the component, but not an ideal solution. Also ran into a few other focus errors.

The other little roadblock is that radix breaks scrolling on a listbox since it prevents scrolling outside of the dialog. This seems to be a bug, presently using this workaround.

I then figured we might be able to get the original select working with floating-ui and its FloatingPortal — unfortunately it expects the dropdown to be in the same part of the DOM as the button and get's a little upset if you use a portal.

This brought me to the headless-ui listbox. It doesn't have the portal functionality out of the box, but it had an example using floating-ui and FloatingPortal that I used as a reference.

This does seem to work well and I have the tests passing. Hopefully this is an easier path to getting our present listbox issues resolved. It's much simpler than the implementation on #1558.

return (
<div className={cn('max-w-lg', className)}>
<div className="mb-2">
<FieldLabel id={`${id}-label`} tip={description} optional={!required}>
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Headless UI has it's on aria-labelledby solution but you need to have the label in the same parent component, so I moved this directly into the Listbox and made it optional.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was going to make a PR for getting aria-describedby into headless UI listbox if this works out. They have it in some other components.

Copy link
Collaborator

@david-crespo david-crespo Jun 12, 2023

Choose a reason for hiding this comment

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

If I put aria-describedby="test" on <Select.Button> or <Select.Options> it passes through fine — is that what you're looking for?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok great, I must have missed that


// add host filter instance "host-filter-instance"
await page.locator('role=button[name="Host type"]').click()
await page.locator('role=button[name*="Host type"]').click()
Copy link
Contributor Author

Choose a reason for hiding this comment

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

So the accessible name is a combination of the label and contents of the field. Previously I guess it was just the label. This does mean that we need to use * since it's not an exact match.

react-aria does the same thing so I figured it was a reasonable accessible behaviour.

import { expectNotVisible, expectRowVisible, expectVisible } from './utils'

async function chooseFile(page: Page, size = 5 * MiB) {
async function chooseFile(page: Page, size = 10 * MiB) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The upload kept finishing before the test had finished

// states right away we won't catch them in time
await expectVisible(page, ['role=heading[name="Image upload progress"]'])
const done = page.locator('role=dialog >> role=button[name="Done"]')
const done = page.locator('css=.ox-modal >> role=button[name="Done"]')
Copy link
Contributor Author

Choose a reason for hiding this comment

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

With modal={false} the underlying modal is still seen by playwright, this stops it getting mixed up with the other "done" button.

This comment was marked as resolved.

placement: finalPlacement,
middlewareData,
} = useFloating({
const { refs, floatingStyles, context } = useFloating({
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Had to update this to the latest useFloating implementation

Copy link
Collaborator

Choose a reason for hiding this comment

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

That's good, I was hoping we could upgrade for the new underlying floating-ui/react-dom API
https://github.com/floating-ui/floating-ui/releases/tag/%40floating-ui%2Freact-dom%402.0.0

Copy link
Collaborator

Choose a reason for hiding this comment

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

oh and it looks like a big improvement. much less noise. nice!

}}
// required to get required error to trigger on blur
onBlur={field.onBlur}
// onBlur={field.onBlur}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Headless UI doesn't have a onBlur but I'm wondering if it's even necessary?

The present behaviour where it kicks out a required error when you click off seems a bit premature and the user can only select from items in the listbox.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, that error is annoying anyway.

Comment on lines +148 to +160
const imagesQuery = useApiQuery('imageList', {
query: { includeSiloImages: true, ...projectSelector },
})

const images = imagesQuery.data?.items || []

return (
<ListboxField
control={control}
name="diskSource.imageId"
label="Source image"
placeholder="Select an image"
isLoading={imagesQuery.isLoading}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Maybe there's a neater way to reuse this — but this is passing query isLoading and then the listbox uses that to decide whether to show a spinner or "No items" since the two both manifest as an empty array

Copy link
Collaborator

Choose a reason for hiding this comment

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

Nope, this is perfect

)
}

export const SpinnerLoader = ({
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fun little component, better version of the one I added into utilization.

Is a spinner that you can set a minimum time it appears for — and if the loading is quick (aka cached) it doesn't show at all.

@david-crespo
Copy link
Collaborator

This only adds 6kb min / 2kb gzipped so that's nice.

@benjaminleonard
Copy link
Contributor Author

This only adds 6kb min / 2kb gzipped so that's nice.

I should also remove downshift if we're not using it elsewhere

@david-crespo
Copy link
Collaborator

It's currently only used on MultiSelect:

image

which is not used anywhere, so we should probably get rid of it. But it won't affect the bundle size because it's already being tree-shaken out.

import { FieldLabel, SpinnerLoader, TextInputHint } from '@oxide/ui'
import { SelectArrows6Icon } from '@oxide/ui'

export type ListboxItem = {
Copy link
Collaborator

Choose a reason for hiding this comment

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

wonder if we can do something less weird than labelString since we're not using downshift. looking into it

const newPreset = item.value as RangeKeyAll
setPreset(newPreset)
onRangeChange(newPreset)
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

cast no longer necessary because Listbox understands that the values are RangeKey. if (item) no longer necessary because it's done inside Listbox

// took longer than the load time.
hideTimerRef.current = setTimeout(() => {
setIsVisible(false)
}, Math.max(0, loadTime - elapsedLoadTime))
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is a lot of fun. Is the logic in the else here meant to prevent the spinner from flashing too briefly, e.g., if the thing we're waiting for takes 27ms, don't let the spinner pop in at 25ms and out after 2ms?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So it'll take the loadTime as a minimum. If it loads in 1000ms you'll see the loader for exactly that long, if it loads in 500ms the loader will still show for a minimum of 750ms. If it loads in 23ms you won't see it at all. A bit of a head scratcher but I think that's the best behaviour.

document.body.classList.remove('pointer-events-none')
}
}, [isOpen])

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yikes, what bug is this fixing? Definitely needs a comment.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So this relates to this

The other little roadblock is that radix breaks scrolling on a listbox since it prevents scrolling outside of the dialog. This seems to be a bug, presently using radix-ui/primitives#1159 (comment).

With modal on, the listbox cannot be scrolled. A package that is a little overzealous with preventing mouse events I think.

Copy link
Collaborator

Choose a reason for hiding this comment

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

With modal={false} like they suggest, it seems to work without the pointer events useEffect.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah it might be fine without, I wasn’t sure if it would catch any stray clicks but I suppose the overlay does that

/>
{file && modalOpen && (
<Modal isOpen onDismiss={closeModal}>
<Modal.Title>Image upload progress</Modal.Title>
Copy link
Collaborator

Choose a reason for hiding this comment

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

I noticed that Modal takes a title prop that hooks up the aria-labelledby properly. I made a note on my to-do list to look at the other uses of Modal and change them to use the title prop instead of Modal.Title. I'm thinking we shouldn't even export Modal.Title, instead requiring that it be done through the title prop to guarantee the ARIA thing is good.


export function Modal({ children, onDismiss, title, isOpen }: ModalProps) {
const titleId = 'modal-title'
const titleId = useId()
Copy link
Collaborator

Choose a reason for hiding this comment

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

This should be unique. Justin pointed me at this, and I changed the existing spots that use our useUuid to useId and deleted useUuid.

Copy link
Collaborator

@david-crespo david-crespo left a comment

Choose a reason for hiding this comment

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

Works great for me. Functionality improvements but still net -80, love it. If you wanna do the aria-describedby thing go ahead, otherwise merge away.

@david-crespo david-crespo enabled auto-merge (squash) June 13, 2023 22:20
@david-crespo david-crespo merged commit 89b58a1 into main Jun 13, 2023
@david-crespo david-crespo deleted the headless-listbox branch June 13, 2023 22:23
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.

2 participants