Skip to content

feat: added EsAutocomplete - #1777

Open
ericdouglaspratt wants to merge 87 commits into
mainfrom
ceo-683-autocomplete
Open

ericdouglaspratt wants to merge 87 commits into
mainfrom
ceo-683-autocomplete

Conversation

@ericdouglaspratt

@ericdouglaspratt ericdouglaspratt commented Jul 7, 2026 •

Copy link
Copy Markdown
Collaborator

🔗 Linked issue

❓ Type of change

  • 📖 Documentation (updates to the documentation or readme)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality like performance)
  • ✨ New feature (a non-breaking change that adds functionality)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

  • There's a lot here, because this PR accomplishes several things:
    • Added EsAutocomplete, broken into subcomponents and utilities for easier separation of concerns
    • Satisfies the full screen takeover requirement for mobile (using Reka UI's Dialog) with a separate subcomponent
    • Splits some of the concerns (e.g. animation, mobile choreography, showing only enough rows there's space for, etc.) into separate composables or utils
    • Added Vitest for unit tests
  • On focus outline behavior:
    • Our inputs do not currently comply with the "sufficient contrast" accessibility requirements for a focus outline
    • There is also a browser limitation on inputs where they cannot show a focus outline only on :focus-visible when tabbed to via keyboard - either they show one when it has focus of any kind, or they don't show one
    • Because of this, I chose to have a focus outline on the autocomplete whenever the input has focus
    • We will likely need to move to a similar approach for all of our other inputs for consistency and to meet the contrast requirement, but that's outside the scope of this PR
  • On animation and motion design:
    • The motion design for the autocomplete was created to solve for the as-of-yet undocumented principle that "everything should clearly come from somewhere; nothing should just pop into existence or disappear" - this is especially important for users who are less familiar with technology or cognitively impaired so when things appear or go away, it is clear where they came from, where they went, and to what part of the UI they are associated
    • On desktop, the suggestions pane slides out of and back into the input, and animates its height change whenever the number of suggestions changes
    • On mobile, the fullscreen takeover is made less jarring by animating the input from its position on the page up to its position in the takeover, and back into place on the page when closed
    • All animation respects the reduce motion setting
  • Started off making use of Reka UI's alpha Autocomplete primitives but eventually moved away from it because (a) it's marked as being in "alpha" mode so its API may be unreliable, and (b) I kept having to add workaround after workaround for various things, including to:
    • Override undesired focus and submit behavior
    • Ensure the suggestion list is DOM-adjacent to the input for accessibility rather than being portaled
    • Allow up/down arrows to cycle through the list of suggestions and go back to the input
    • Fix over-repetition in VoiceOver readouts
  • Expected limitations:
    • In VoiceOver mode, when the suggestions pane is brought up and then you navigate to the next elements on the page outside of the autocomplete without making a selection, the suggestions pane does not close until the next focusable page element (e.g. a link or input) receives focus. This is a VoiceOver limitation that Google, Amazon, and other sites with autocompletes also face, as the last-focused element technically retains focus until the next focusable element gets it, even if the VoiceOver reading cursor has moved on.

Desktop simple example

Screenshot 2026-09-23 at 10 01 54 AM

Desktop address example (inverted behavior)

Screenshot 2026-09-23 at 10 02 45 AM

Mobile address example (fullscreen takeover open)

Screenshot 2026-09-23 at 10 03 28 AM

🥼 Testing

🧐 Feedback Requested / Focus Areas

  • Overall

📝 Checklist

  • I have verified accessibility of any new components by:
    • automated check with the WAVE browser extension
    • navigating by keyboard
    • using with a screen reader (e.g. VoiceOver on Safari)
  • I have updated any applicable documentation.

ericdouglaspratt and others added 30 commits July 2, 2026 14:43
Reka auto-highlights the first item whenever results arrive from an empty
list, so Enter right after typing selected that item instead of submitting
the typed query. Track whether the highlight was user-initiated (arrow keys
or pointer movement) and handle Enter in the capture phase on the anchor:
a non-user highlight now submits the raw query and stops the event before
Reka's own handler can select.

Review finding 1 of 10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ition

The submit path now calls preventDefault so a surrounding <form> does not
natively submit alongside the component's submit event (Reka only prevents
Enter's default when selecting a highlighted item), and ignores the Enter
that commits an IME composition, which previously fired submit with
half-composed text.

Review finding 2 of 10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ResizeObserver watches the list container, whose height the trim itself
controls, so when available height grew (window resized taller) a trimmed
container never resized and the observer stayed silent — the list was stuck
at its old item count until the suggestions changed. A window resize now
re-measures directly, deferred one frame so the popper's own resize handling
updates the available-height constraint first (which also coalesces resize
bursts to one measure per frame).

Review finding 3 of 10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
noResults was keyed to the previous query's response, so after an empty
result, lengthening the query displayed "No results found" for the new
query while its search was still in flight — contradicting the documented
behavior. Reset noResults as soon as the query changes above minChars.

Review finding 4 of 10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The blanket mousedown.prevent on the panel blocked focus for any interactive
element a consumer renders via the item slot; it now only prevents default
for non-interactive targets, and focusout additionally ignores focus moving
into the panel so such elements stay usable. focusout also ignores the
window itself losing focus (alt-tab, devtools) — the input is still the
active element, so the panel no longer disappears mid-interaction.

Review finding 5 of 10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… machinery

The measuring/pending/settledHeight flags and the do-while re-entrancy loop
existed only because the ResizeObserver watched the same element whose height
the trim itself mutates. The height limit is now read from the container's
resolved max-height (desktop popover) or explicit height (mobile takeover
list) — both stable while items render — so measuring is idempotent and the
observer plus all three guards are deleted. Triggers are now explicit:
suggestion changes, window resizes, and the mobile shell re-measuring after
it sets the list height from the visual viewport.

Review finding 7 of 10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The clear button was Reka's AutocompleteCancel with a tabindex=0 attribute
that only overrode the hardcoded tabindex=-1 via undocumented Vue attr-merge
order — an in-range reka-ui bump could silently make it keyboard-unreachable.
It is now a plain button we fully own (clear + refocus), naturally tabbable.
The Enter capture handler now also ignores Enter originating from the clear
button so it acts as a click rather than a submit.

The mobile shell resolved the panel element with a computed over the
non-reactive $el — the exact pattern the desktop shell's comment calls
broken. Both shells now share useAutocompleteContentEl, which re-resolves
the element when the open state that mounts the panel changes.

Review finding 9 of 10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shells duplicated ~150 lines each. Consolidated:

- panelMessage is computed once in the parent (which owns model, minChars,
  noResults, and the message texts) and passed down as a single prop,
  replacing four forwarded props per shell
- the shells' withDefaults blocks were dead code — the parent always binds
  every prop, so shell defaults could never apply and could silently drift;
  shells now declare plain typed props
- Enter/select/clear handling and the user-highlight tracking (byte-identical
  including comments) moved to a shared useAutocompleteShell composable
- the label block and the clear button (with its styles) are now shared
  es-autocomplete-label / es-autocomplete-clear-button components
- the suggestions ref wrapper computeds became toRef(props, 'suggestions')

The shells now carry only their genuine differences: popover chrome + focus
open/close model vs dialog takeover chrome + visual-viewport handling, and
their MAX_VISIBLE caps. Behavior-preserving.

Review finding 6 of 10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…able guards

The debounce timer was cleared at three separate call sites that each had to
remember the same bookkeeping; scheduleComplete/cancelPendingComplete now
centralize it, so a future terminal path can't forget the cancel and
re-introduce the late-response-reopens-panel bug.

Removed the 'model.value ?? empty-string' guards that spread to eight sites:
defineModel<string>({ default: '' }) makes undefined unrepresentable through
the typed API, so the branches were unreachable and implied a contract that
does not exist.

Deliberately NOT adopted from the review: replacing the timer with @vueuse
watchDebounced (it would be this source-shipped package's first direct
@vueuse/core import, and the dependency is a devDependency — a runtime
resolution risk for consumers not worth taking here), and DS-wide
consolidation of the overlay/label markup with es-menu-bar/es-form-input
(requires an es-ds-styles change; the autocomplete-internal copies were
already deduplicated in the previous commit).

Review finding 8 of 10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- watch suggestions with depth 1 so apps that mutate the array in place
  (push/splice) still update noResults and re-trim the list
- guard against toLowerCase changing string length (e.g. İ), which would
  misalign highlight segment offsets; fall back to case-sensitive matching
- share one EMPTY_SUGGESTIONS constant so the below-minChars computed is
  referentially stable and doesn't re-fire downstream watchers per keystroke
- coalesce visualViewport resize handling to one layout read/write per frame
  during the mobile keyboard animation
- docs: add the v-model row to the props table (per the text-input page
  precedent)
- import splitAutocompleteText explicitly in the component (matching the
  explicit-utils-import precedent set by es-menu-bar)
- gitignore .playwright-mcp/ browser-session artifacts

Review finding 10 of 10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ericdouglaspratt
ericdouglaspratt marked this pull request as ready for review September 23, 2026 14:11
@ericdouglaspratt
ericdouglaspratt requested a review from a team as a code owner September 23, 2026 14:11

This branch was successfully deployed

1 active (outdated) deployment
int — 5f632305 Deployed Sep 23, 2026 by ericdouglaspratt via deploy #45
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.

1 participant