Skip to content

feat(select): add <rh-select>#2802

Open
adamjohnson wants to merge 157 commits intostaging/eeveefrom
feat/rh-select
Open

feat(select): add <rh-select>#2802
adamjohnson wants to merge 157 commits intostaging/eeveefrom
feat/rh-select

Conversation

@adamjohnson
Copy link
Collaborator

@adamjohnson adamjohnson commented Dec 17, 2025

What I did

  1. Adds the <rh-select>, <rh-option>, and <rh-option-group> elements.
  2. Closes [feat] Create <rh-select> element #2557.

Testing Instructions

  1. View the following demos:
  2. Ensure this element meets all the accessibility criteria set forth via the APG Select-only Combobox
  3. Ensure this design implementation matches the Figma specs
  4. Ensure the correct design tokens have been used in rh-select's CSS
  5. Ensure rh-select implements the custom change, open, and close events correctly
  6. Should we add any part="xyz" attributes?
  7. Make sure colors in Color Context demo are correct
  8. Check Code docs for accuracy
  9. Ensure Select docs match the Figma docs.

Notes to Reviewers

  • Should the dropdown (listbox) have a max-height / scroll when there are lots of options?

Outstanding To Do's:

@changeset-bot
Copy link

changeset-bot bot commented Dec 17, 2025

🦋 Changeset detected

Latest commit: 7ef30f7

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

This PR includes changesets to release 1 package
Name Type
@rhds/elements Minor

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

@adamjohnson
Copy link
Collaborator Author

@marionnegp I decreased the spacing between the toggle text, status icon and lil' caret 👉 5fb848b.

I didn't make any changes to the font size because users could do that with theming:

rh-select {
  --rh-font-size-body-text-sm: 0.75rem;
}

Redefining that variable will change the font size for the select text and help text. We certainly can add a custom variable for just the select text (or more) if you want. Just let me know.

@marionnegp
Copy link
Collaborator

Redefining that variable will change the font size for the select text and help text. We certainly can add a custom variable for just the select text (or more) if you want. Just let me know.

Nope, I think having the variable there is good enough. Thanks!

@adamjohnson adamjohnson requested a review from marionnegp March 19, 2026 21:13
@bennypowers
Copy link
Member

/agentic_review

@qodo-code-review
Copy link

qodo-code-review bot commented Mar 20, 2026

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📎 Requirement gaps (0) 📐 Spec deviations (0)

Grey Divider


Action required

1. Option value not reactive 🐞 Bug ✓ Correctness
Description
RhOption.value's custom setter only updates a private field and never requests an update, so the
@observes('value') handler will not run and the cached displayLabel can become stale when value is
changed programmatically. This breaks the component’s own intended value→label sync logic.
Code

elements/rh-select/rh-option.ts[R41-48]

+  @property()
+  get value() {
+    return this.#value ?? this.displayLabel ?? '';
+  }
+
+  set value(v: string) {
+    this.#value = v;
+  }
Evidence
The component defines an @observes('value') hook to update the cached label when value changes, but
the value setter does not trigger Lit’s update cycle, so that observer won’t reliably fire for
programmatic assignments (e.g., option.value = 'x').

elements/rh-select/rh-option.ts[41-48]
elements/rh-select/rh-option.ts[149-159]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`&amp;lt;rh-option&amp;gt;` implements `value` as a custom accessor but its setter does not call `requestUpdate()`. As a result, updates to `value` made programmatically do not reliably participate in Lit reactivity, and the existing `@observes(&amp;#x27;value&amp;#x27;)` hook intended to keep `displayLabel` in sync may not run.
### Issue Context
The component already has `@observes(&amp;#x27;value&amp;#x27;)` to recompute the cached display label, but the setter currently only mutates `#value`.
### Fix Focus Areas
- elements/rh-select/rh-option.ts[41-48]
- elements/rh-select/rh-option.ts[155-159]
### Implementation notes
- Update the `set value(v: string)` implementation to capture the old value and call `this.requestUpdate(&amp;#x27;value&amp;#x27;, oldValue)`.
- Ensure attribute→property updates also trigger the same path (the accessor should remain reactive).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Group disabled not synced🐞 Bug ✓ Correctness
Description
RhOptionGroup caches its child options only once in firstUpdated(), so disabling/enabling the group
(or adding/removing options later) can leave some child rh-option elements with an incorrect
disabled state. This makes the UI state inconsistent and can allow selecting options that should be
disabled.
Code

elements/rh-select/rh-option-group.ts[R51-58]

+  override firstUpdated() {
+    if (!isServer) {
+      this.#optionEls = this.#getChildOptions();
+      if (this.disabled) {
+        this.#updateDisabledChildren();
+      }
+    }
+  }
Evidence
Child options are captured once into #optionEls during firstUpdated(), but subsequent disabled
changes only iterate that cached list; the code never refreshes #optionEls on slot changes, so
dynamically inserted options are never updated.

elements/rh-select/rh-option-group.ts[51-58]
elements/rh-select/rh-option-group.ts[72-95]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`&amp;lt;rh-option-group&amp;gt;` stores its child `&amp;lt;rh-option&amp;gt;` elements in a private cache (`#optionEls`) only once in `firstUpdated()`. If children are added/removed later, or if `disabled` is toggled after the child list changes, the group will not correctly propagate `disabled` to the current set of child options.
### Issue Context
The component already has a `#getChildOptions()` helper that reads from the default slot, but it is only used during `firstUpdated()`.
### Fix Focus Areas
- elements/rh-select/rh-option-group.ts[51-58]
- elements/rh-select/rh-option-group.ts[72-95]
### Implementation notes
- Add a `@slotchange` handler on the default slot to refresh `#optionEls` and then call `#updateDisabledChildren()`.
- or, simpler: in `disabledChanged()` (and/or `#updateDisabledChildren()`), recompute `this.#optionEls = this.#getChildOptions()` before iterating.
- Ensure the initial disabled state is applied even if options are slotted after first render.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Space key intercepted always 🐞 Bug ✓ Correctness
Description
RhSelect.#onKeydown treats Space as a printable character and unconditionally calls
preventDefault()+stopPropagation(), routing Space into type-ahead. This conflicts with the
component’s own intent to intercept Space only during an active type-ahead session and can block
other Space-driven behaviors (open/select) handled elsewhere.
Code

elements/rh-select/rh-select.ts[R690-702]

+  #onKeydown(event: KeyboardEvent) {
+    // Only handle printable characters
+    const isPrintable = event.key.length === 1
+      && !event.ctrlKey
+      && !event.altKey
+      && !event.metaKey;
+
+    if (isPrintable) {
+      // Don't scroll after pressing space and don't toggle the listbox
+      event.preventDefault();
+      event.stopPropagation();
+      this.#handleTypeAhead(event.key);
+    }
Evidence
The capture-phase handler explicitly documents that Space should only be intercepted during an
active type-ahead session, but #onKeydown intercepts all printable keys (Space included) and stops
propagation every time, creating conflicting behavior and potentially preventing the normal Space
behavior when not in a type-ahead session.

elements/rh-select/rh-select.ts[219-235]
elements/rh-select/rh-select.ts[690-702]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`#onKeydown` currently classifies Space (`event.key === &amp;#x27; &amp;#x27;`) as a printable character and always calls `preventDefault()` and `stopPropagation()`, sending it to `#handleTypeAhead()`. This contradicts the documented intent in `#captureKeydown` (only intercept Space during an active type-ahead session) and can interfere with Space’s normal meaning for combobox/select interactions.
### Issue Context
There are two different Space interception paths:
- `#captureKeydown` (capture phase) conditionally intercepts Space only when a type-ahead session is active.
- `#onKeydown` (bubble phase) intercepts Space unconditionally as “printable”.
### Fix Focus Areas
- elements/rh-select/rh-select.ts[219-235]
- elements/rh-select/rh-select.ts[690-702]
### Implementation notes
- Update `#onKeydown` to exclude Space from `isPrintable` (e.g., add `&amp;amp;&amp;amp; event.key !== &amp;#x27; &amp;#x27;`).
- Keep Space handling in `#captureKeydown` for the specific “type-ahead in progress” case.
- Re-run/extend keyboard tests around Space to ensure open/select behavior remains correct.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
4. Option group unlabeled 🐞 Bug ⛨ Security
Description
RhOptionGroup sets role="group" but never sets aria-label/aria-labelledby from its label
attribute/slot, so assistive technologies won’t have a programmatic group name. This undermines the
accessibility purpose of providing a label for grouped options.
Code

elements/rh-select/rh-option-group.ts[R35-69]

+  @property() label?: string;
+
+  /**
+   * Whether the option group and all its child options are disabled.
+   * When true, automatically disables all rh-option children, preventing
+   * selection of any options within this group.
+   */
+  @property({ type: Boolean, reflect: true }) disabled = false;
+
+  @query('slot:not([name])') private defaultSlot!: HTMLSlotElement;
+
+  // eslint-disable-next-line no-unused-private-class-members
+  #internals = InternalsController.of(this, { role: 'group' });
+
+  #optionEls: RhOption[] = [];
+
+  override firstUpdated() {
+    if (!isServer) {
+      this.#optionEls = this.#getChildOptions();
+      if (this.disabled) {
+        this.#updateDisabledChildren();
+      }
+    }
+  }
+
+  render() {
+    return html`
+      <div id="label-container"
+           role="presentation">
+        <!-- Group label as inline text. Overrides the \`label\` attribute. Screen readers announce this text when the group receives focus. -->
+        <slot name="label">${this.label}</slot>
+      </div>
+      <!-- Insert \`<rh-option>\` elements. Each option must have accessible text content for screen readers. -->
+      <slot></slot>
+    `;
Evidence
The implementation declares the label is required for accessibility and sets role="group" via
InternalsController, but it only renders the label visually; it does not bind that label to the
group’s accessible name via ElementInternals or ARIA attributes.

elements/rh-select/rh-option-group.ts[16-20]
elements/rh-select/rh-option-group.ts[35-48]
elements/rh-select/rh-option-group.ts[60-69]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`&amp;lt;rh-option-group&amp;gt;` exposes a `label` API and renders a label slot, but the host element (role=&amp;quot;group&amp;quot;) is not given an accessible name via `aria-label`/`aria-labelledby` (or ElementInternals equivalents). Screen readers may announce an unnamed “group”.
### Issue Context
The class doc states the group label is required for accessibility, and the element sets `role: &amp;#x27;group&amp;#x27;` via InternalsController.
### Fix Focus Areas
- elements/rh-select/rh-option-group.ts[35-48]
- elements/rh-select/rh-option-group.ts[60-69]
### Implementation notes
- Set the group’s accessible name based on the resolved label:
- Option A: set `this.#internals.ariaLabel = resolvedLabel` (prefer slot content over `label` attribute).
- Option B: give the label container an `id` and set `this.#internals.ariaLabelledByElements = [labelElement]` (or set `aria-labelledby` appropriately).
- Update/extend tests to assert the group has an accessible name.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

@adamjohnson adamjohnson requested a review from bennypowers March 20, 2026 20:35
Copy link
Collaborator

@marionnegp marionnegp left a comment

Choose a reason for hiding this comment

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

  • The caret icon size should be 10px.
  • For docs, I think you added switch to the list of related elements and patterns, which I think works. Can you add scheme toggle too?

I also made some updates to the docs images, since that's a little faster than putting edit requests in here anyway!

@bennypowers bennypowers dismissed their stale review March 23, 2026 18:04

points addressed

@adamjohnson adamjohnson requested a review from marionnegp March 23, 2026 18:28
Copy link
Collaborator

@marionnegp marionnegp left a comment

Choose a reason for hiding this comment

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

Thanks, Adam!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or request

Projects

Status: Review 🔍

Development

Successfully merging this pull request may close these issues.

[feat] Create <rh-select> element

5 participants