Skip to content

feat(Select): add renderTrigger for custom trigger elements - #655

Merged
blvdmitry merged 3 commits into
canaryfrom
claude/select-custom-trigger-tp5z14
Aug 15, 2026
Merged

feat(Select): add renderTrigger for custom trigger elements#655
blvdmitry merged 3 commits into
canaryfrom
claude/select-custom-trigger-tp5z14

Conversation

@blvdmitry

Copy link
Copy Markdown
Contributor

Summary

Adds a renderTrigger prop to the custom Select, letting consumers replace the built-in trigger while keeping value resolution, type-ahead, keyboard handling and form submission.

<Select
  name="animal"
  placeholder="Select an animal"
  renderTrigger={(attributes, { active }) => (
    <Actionable attributes={attributes}>
      <Text color="neutral-faded">{attributes.children}</Text>
      <Icon svg={active ? IconChevronUp : IconChevronDown} size={4} />
    </Actionable>
  )}
>
  <Select.Option value="dog">Dog</Select.Option>
</Select>

The plumbing already existed one level down — DropdownMenu.Trigger is Flyout.Trigger, which hands out TriggerAttributes. SelectCustomControlled just hard-coded SelectTrigger as their only consumer, so this forwards that function outward.

API shape follows the existing convention for render props that carry both attributes and state — spreadable attributes as the first positional argument, state as a second argument named props, matching Accordion.Trigger and Resizable.Handle. The wrapped (args: {...}) form used by renderValue/renderDateValue is for value formatters where nothing is spreadable. The resolved value node goes in attributes.children (falling back to placeholder), mirroring how Actionable.render includes children so a bare spread works.

A Select.Trigger compound child was considered and rejected: children is exclusively the option list that traverseOptionList walks, and hoisting a trigger out of it would reintroduce the child scanning that #608 deliberately removed.

Changes:

  • Select.types.tsTriggerRenderAttributes plus renderTrigger on the four custom fragment members. Placed alongside renderValue so value narrows to string vs string[] off the multiple discriminant; typed never on the native fragments.
  • SelectHiddenInput.tsx (new) — extracted from SelectTrigger so both paths share one place for the value serialization. Without it, a custom trigger would silently break form submission.
  • SelectCustomControlled.tsx — the wiring. onClick is composed manually (Actionable was merging Select's onClick with the flyout's for free) and guarded on disabled.
  • Type exports through Select/index.ts and src/index.ts.
  • Removed the unused RootProps type — a vestigial render-prop sketch of this same feature, superseded here.

Related Issue

Screenshots / Recordings

Covered by the new renderTrigger story (Components/Select), which renders an Actionable with neutral-faded text and a chevron driven by active.

Notes for Reviewers

Worth a look:

  • attributes.children placement. Great for a raw {...attributes} DOM spread, slightly odd when spreading into a Reshaped component's attributes prop — Actionable overrides it with its own children, so consumers read attributes.children and place it themselves, as the story does. Moving it to the second argument is the alternative.
  • Placeholder styling. The fallback is passed as a raw string rather than the <Text color="disabled"> the default trigger uses, so custom triggers aren't forced into a look. Consumers distinguish value from placeholder via the value in the second argument.
  • width="trigger" and focus return both key off whatever element receives attributes.ref — spreading onto an inner node gives a wrong dropdown width and breaks focus-on-close. Worth calling out in docs, with positionRef as the escape hatch.
  • Labelling. id still lands on the hidden input, so a FormControl label's htmlFor points at something unfocusable. Pre-existing, but more visible with a custom trigger — passing id/aria-labelledby through the render arguments would be a follow-up.
  • size, variant, icon and startSlot are silently ignored with a custom trigger, matching how Popover treats its trigger.

Verification: tsc --noEmit clean; vitest --project=storybook passes 581/582, the one failure being a pre-existing Image.stories load assertion that also fails on a clean checkout here.


Generated by Claude Code

Select only supported its built-in trigger, so replacing it meant dropping
down to DropdownMenu and losing value resolution, type-ahead and the hidden
input. renderTrigger hands the flyout attributes out to the consumer instead,
following the Accordion.Trigger and Resizable.Handle convention of passing
spreadable attributes as the first argument and state as the second.

The hidden input moves into a shared SelectHiddenInput so form submission
keeps working when the default trigger is replaced, and the onClick prop is
composed manually since Actionable is no longer there to merge it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5bUMTZHMVrTywF9gTCJcf
@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown

size-limit report 📦

Path Size Loading time (3g) Running time (snapdragon) Total time
Library / JS 49.31 KB (+0.34% 🔺) 987 ms (+0.34% 🔺) 876 ms (+2.58% 🔺) 1.9 s
Library / CSS 23.06 KB (0%) 462 ms (0%) 0 ms (+100% 🔺) 462 ms
Theming / JS 7.79 KB (0%) 156 ms (0%) 64 ms (-6.54% 🔽) 219 ms
Theming with a default theme definition / JS 8.67 KB (0%) 174 ms (0%) 65 ms (-22.08% 🔽) 239 ms

@blvdmitry
blvdmitry marked this pull request as ready for review August 15, 2026 12:56
@blvdmitry
blvdmitry merged commit 00954ae into canary Aug 15, 2026
12 checks passed
@blvdmitry
blvdmitry deleted the claude/select-custom-trigger-tp5z14 branch August 15, 2026 12:56
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