-
Notifications
You must be signed in to change notification settings - Fork 21
Headless UI listbox #1563
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
Headless UI listbox #1563
Conversation
| return ( | ||
| <div className={cn('max-w-lg', className)}> | ||
| <div className="mb-2"> | ||
| <FieldLabel id={`${id}-label`} tip={description} optional={!required}> |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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
app/test/e2e/image-upload.e2e.ts
Outdated
| // 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"]') |
There was a problem hiding this comment.
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.
This comment was marked as resolved.
Sorry, something went wrong.
| placement: finalPlacement, | ||
| middlewareData, | ||
| } = useFloating({ | ||
| const { refs, floatingStyles, context } = useFloating({ |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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} |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nope, this is perfect
libs/ui/lib/spinner/Spinner.tsx
Outdated
| ) | ||
| } | ||
|
|
||
| export const SpinnerLoader = ({ |
There was a problem hiding this comment.
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.
|
This only adds 6kb min / 2kb gzipped so that's nice. |
I should also remove downshift if we're not using it elsewhere |
libs/ui/lib/listbox/Listbox.tsx
Outdated
| import { FieldLabel, SpinnerLoader, TextInputHint } from '@oxide/ui' | ||
| import { SelectArrows6Icon } from '@oxide/ui' | ||
|
|
||
| export type ListboxItem = { |
There was a problem hiding this comment.
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) | ||
| } |
There was a problem hiding this comment.
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
libs/ui/lib/spinner/Spinner.tsx
Outdated
| // took longer than the load time. | ||
| hideTimerRef.current = setTimeout(() => { | ||
| setIsVisible(false) | ||
| }, Math.max(0, loadTime - elapsedLoadTime)) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
libs/ui/lib/modal/Modal.tsx
Outdated
| document.body.classList.remove('pointer-events-none') | ||
| } | ||
| }, [isOpen]) | ||
|
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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.
david-crespo
left a comment
There was a problem hiding this 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.

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
useSelecthook. My first attempt was to use thereact-ariaselect and combobox implementation since we are using that lib elsewhere. #1558This 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
radixbreaks 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-uiand itsFloatingPortal— 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-uilistbox. It doesn't have the portal functionality out of the box, but it had an example usingfloating-uiandFloatingPortalthat 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.