Skip to content

Conversation

@kawikabader
Copy link
Contributor

@kawikabader kawikabader commented Nov 13, 2025

Summary

Introduces the @bento/select component.

This PR also includes necessary updates to @bento/button, @bento/pressable, @bento/listbox, @bento/use-props, @bento/text, and @bento/overlay that were discovered during Select development.

Changes Made

New: @bento/select

  • Slot-based composition with trigger, popover, listbox, value, label, description, error slots
  • Single and multiple selection modes
  • Dynamic collections via items prop with Node<T>.value pattern for rich data access
  • Static collections via JSX children with ListBoxSection grouping support
  • Empty state rendering via renderEmptyState prop
  • Controlled/uncontrolled open state (isOpen/defaultOpen)
  • Form integration with hidden <select> for native submission
  • Full keyboard navigation and ARIA compliance via React Aria
  • Popover positioning via usePopover with flip, offset, and container padding options

Refactored: @bento/button

  • Now uses React Aria hooks directly (useButton, useFocusRing, useHover) instead of wrapping Pressable
  • Single useProps call with explicit slot prop passthrough for ARIA attributes
  • Added ButtonRenderProps and render function children support
  • Added forwardRef support

Refactored: @bento/use-props

  • Added type-safe function overloads for better TypeScript support
  • renderProp now correctly handles falsy values (false, 0, '') using in operator

Refactored: @bento/pressable

  • Extracts interaction state before calling useProps to enable render props

Fixed: @bento/text

  • Changed as prop type from string to React.ElementType

Fixed: @bento/overlay

  • Updated example to use standard ref pattern instead of childRef

Cleaned up: @bento/listbox

  • Simplified example CSS to minimal Storybook styles
  • Removed unused value prop from ListBoxItemProps (React Aria handles this via Node<T>.value)

Test Plan

  • ✅ Unit tests added for Select (browser + node SSR)
  • ✅ Example tests covering all Select variants
  • ✅ Updated Button tests for new render function children
  • ✅ Added tests for renderProp falsy value handling
  • ✅ All existing tests pass (npm test)
  • ✅ Storybook stories: Static, Dynamic, Grouped, AllFeatures
  • ✅ Manual testing: keyboard nav, screen reader, form submission

Checklist

  • My code follows the project's code style (biome)
  • I have added/updated tests that prove my fix is effective or that my feature works
  • I have added/updated documentation (README, Storybook stories, etc.)
  • All new and existing tests pass locally
  • I have added a changeset (run npm run changeset if this affects packages)
  • I have reviewed my own code and commented any complex areas

@changeset-bot
Copy link

changeset-bot bot commented Nov 13, 2025

🦋 Changeset detected

Latest commit: 62d6ff2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 27 packages
Name Type
@bento/visually-hidden Minor
@bento/use-svg-sprite Minor
@bento/illustration Minor
@bento/field-error Minor
@bento/scroll-lock Minor
@bento/focus-lock Minor
@bento/container Minor
@bento/pressable Minor
@bento/use-props Minor
@bento/checkbox Minor
@bento/dismiss Minor
@bento/divider Minor
@bento/forward Minor
@bento/heading Minor
@bento/listbox Minor
@bento/overlay Minor
@bento/button Minor
@bento/portal Minor
@bento/select Minor
@bento/error Minor
@bento/radio Minor
@bento/slots Minor
@bento/icon Minor
@bento/text Minor
@bento/box Minor
@bento/input Patch
@bento/environment Patch

Not sure what this means? Click here to learn what changesets are.

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

const { pressProps, isPressed } = usePress({ ...mergedProps, ref });

// Second pass: get apply function with interaction state for render props
const { props, apply } = useProps(args, { isHovered, isFocused, isFocusVisible, isPressed });
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems like we need to re-think useProp as it pertains to exposing state to the prop functions.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

How so?

Copy link
Contributor

Choose a reason for hiding this comment

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

useProp is defined earlier in the function, and when the state has finally been determined....it has to be re-called. Just doesn't seem like an ideal flow.

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 two-pass pattern exists because of ordering requirements:

Pass 1 merges slot props so React Aria hooks see overrides like isDisabled.
Pass 2 provides the apply() function with interaction state for render props.

We can't do both in one pass because we need mergedProps to GET the state, but we need the state to execute render props.

I'm open to other ideas if you have them though.

* Resolves button children - either renders the function with state or returns static content.
* @internal
*/
export function resolveChildren(
Copy link
Contributor

Choose a reason for hiding this comment

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

Doesn't useProp for the children prop already do 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.

useProps render‑prop contract is:

if (isRenderProp(name, value)) return value({
  props, slots, state, original
});

In Button we call useProps(args, {}), so props.children (via the proxy) would get { props, slots, state: {}, original }, not { isPressed, isHovered, isFocused, isFocusVisible } from React Aria. That’s why we intentionally bypass props.children and call resolveChildren(args.children, { isPressed, ... }) instead.

Copy link
Contributor

Choose a reason for hiding this comment

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

If this is a problem with the useProps API, then that API should be adjusted. We should not be making this component behave differently.

I'm missing the part why this change is needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Alrighty, I've removed the resolveChildren helper function.

The current implementation handles children inline without introducing a new abstraction:

// Execute children render prop explicitly (before useProps to prevent incorrect execution)
const content = typeof args.children === 'function' ? args.children(renderState) : args.children;

// Second pass: apply user props with render state via apply()
const { apply } = useProps(args, renderState);

Does this feel more appropriate here?

* @returns The render function from the ListBox, or undefined if not found
* @internal
*/
function extractListBoxRenderFunction(children: ReactNode): ((item: unknown) => ReactNode) | undefined {
Copy link
Contributor

Choose a reason for hiding this comment

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

This function seems very fragile. It expects a specific composition pattern, and extracting the render from children, is this necessary?

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 useSelectState needs the children(item) => ReactNode render fn at the Select level for items-based (dynamic) collections, but our slot API hides it inside <ListBox slot="listbox">. extractListBoxRenderFunction walks the known slot structure, finds the listbox slot, and lifts its render fn so we can keep the nice slot-based API without inventing a parallel renderItem prop on Select. It’s fragile only to the extent that our public slot contract is: Select requires exactly one ListBox with slot="listbox".

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've removed extractListBoxRenderFunction entirely and implemented an explicit renderItem prop on Select instead.

New pattern:

<Select 
  items={fruits}
  renderItem={(fruit) => <ListBoxItem id={fruit.id}>{fruit.name}</ListBoxItem>}
>
  <Button slot="trigger">...</Button>
  <Popover slot="popover">
    <ListBox slot="listbox" /> {/* presentational only */}
  </Popover>
</Select>

The renderItem prop is now required (type-enforced) when items is provided, making the contract explicit and removing all slot traversal logic. The ListBox slot becomes purely presentational for dynamic collections.

Does this feel like a better solution?

Copy link
Contributor

Choose a reason for hiding this comment

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

Purely based on the example you have above. I'd be confused about the purpose of <Select renderItem/> in relation to the items I'd expect in the <Listbox/>. What's keeping us from using the same construction that Adobe has?

<Select>
  <Label />
  <Button>
    <SelectValue />
  </Button>
  <Text slot="description" />
  <FieldError />
  <Popover>
    <ListBox />
  </Popover>
</Select>

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 that's a solid point. I've gone ahead and removed items and renderItem from Select and moved them to ListBox.

Now matches Adobe's pattern:

<Select>
  <Button slot="trigger">...</Button>
  <Popover slot="popover">
    <ListBox slot="listbox" items={fruits}>
      {(fruit) => <ListBoxItem id={fruit.id}>{fruit.name}</ListBoxItem>}
    </ListBox>
  </Popover>
</Select>

Select is now purely a coordinator (overlay, state, forms). ListBox owns its collection.

- Resolved conflicts in package.json files
- Updated select package dependencies to match latest versions
- Removed unused @bento/pressable dependency from button
- Regenerated package-lock.json
- Simplified ButtonWithRenderPropExample to remove untestable isPressed branch
- Added test for hover state in render prop example
- All tests now pass with 100% coverage
Copy link
Contributor

@akazemier-godaddy akazemier-godaddy left a comment

Choose a reason for hiding this comment

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

Just going to hard block on this. Seems like we're going in a completely wrong direction here by suddenly breaking our interface contracts for no reason.

* Resolves button children - either renders the function with state or returns static content.
* @internal
*/
export function resolveChildren(
Copy link
Contributor

Choose a reason for hiding this comment

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

If this is a problem with the useProps API, then that API should be adjusted. We should not be making this component behave differently.

I'm missing the part why this change is needed?

@kawikabader kawikabader self-assigned this Dec 15, 2025
state?: S
): Returns;
export function useProps(...rest: unknown[]): Returns {
export function useProps(...rest: any[]): Returns {
Copy link
Contributor

Choose a reason for hiding this comment

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

There is a lot changing here, types being removed, and no tests.

Can you describe what specifically is changing and why?

<Select items={[]} renderEmptyState={() => <div data-testid="empty">No items</div>}>
<Select
items={[]}
renderItem={renderFruitItem}
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we have a renderItem prop? Wouldn't this be handled by the Listbox render function?

const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const [trigger, setTrigger] = useState<HTMLButtonElement | null>(null);
Copy link
Contributor

Choose a reason for hiding this comment

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

Why the use of useState to set the button ref?

};

// Execute children render prop explicitly (before useProps to prevent incorrect execution)
const content = typeof args.children === 'function' ? args.children(renderState) : args.children;
Copy link
Contributor

Choose a reason for hiding this comment

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

So here we are deviating from how Bento handles the "function" props, no? https://github.com/godaddy/bento/blob/main/packages/use-props/src/index.ts#L221-L226

// from this
<button>(({ props, state }) => ...)</button>

// to this
<button>(({ isHovered, isPressed, ... }) => ...)</button>

Not sure if exposing those states in the second argument of useProps is the right call but I'd look into it:

const { props: mergedProps, ref: mergedRef } = useProps(args, renderState, forwardedRef);

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.

7 participants