Components · Event search
Components

Event search

One field does two jobs: find an event, or add the one we do not have yet. Three different people arrive at it — someone checking a rating, someone about to review, an organiser looking for their own race — and most of them are typing a name we have never seen, on a phone, on mobile data.

Search 412 events, or add a new one.

Try
Account

Arrow keys move through results, Enter selects, Esc closes. The create row is always last, and never pre-selected.

Who is typing

Three different people use this field, and only one of them is about to leave a review:

WhoWantsWhat that means here
An athlete checking a ratingThe score, before enteringThe rating must be on the result row — they may never click through.
An athlete about to reviewTo find the event they ranDate and city disambiguate editions and namesakes.
An organiser finding their own eventTo claim or check itUnverified events must be findable, not hidden.
Copy rule

Nothing in this component may assume why the person is searching. No "before you review", no "your debrief", no "your event". Earlier drafts justified the email field with "so we can send you its debrief" — which is wrong for two of the three, and a promise we cannot keep for someone who was never at the event. State the reason that is true for everyone: we may need to reach you about the event you are adding.

Why it is one component

The obvious build is a search field plus a separate "Can't find your event? Add it" link. That version produces duplicates, reliably, for one reason: the link is visible before the search has finished. Someone types four characters, sees no results yet, sees the link, and creates "Maratona Lisboa" three days after somebody created "Maratona de Lisboa".

Folding creation into the result list makes the ordering physical. The create row cannot be reached without passing every match first — by eye, by arrow key, or with a screen reader. Duplicate prevention is this component's actual job; search is how it does it.

Anatomy

1 · Label
A question, always visible. Never a placeholder standing in for it.
2 · Combobox input
role="combobox", aria-expanded, aria-controls, aria-autocomplete="list". A real <input>.
3 · Listbox
Matches, newest edition first. Each row carries the name, the date and the place — three events share the name "Trail do Gerês".
4 · Create row
A listbox option like any other, last, above a hairline, with the typed text quoted back.
5 · Create panel
Replaces the list in place. Three fields. Never a route change, never a dialog.
6 · Status region
role="status", visually hidden, announcing the match count.

The result row

Three events share the name "Trail do Gerês" and two more start with "Maratona de". A bare list of names is not enough to choose from, so every row carries four things: the matched name, the date, the place, and how the event is rated.

  • The matched substring is bold, the rest is regular. It shows why a row is in the list — "euro" bolded inside Europarque and inside Maratona da Europa explains a result set that otherwise looks arbitrary. Matching is diacritic-insensitive, so the highlight is computed on the normalised string and applied to the original.
  • Build the highlight with DOM nodes, never innerHTML. The query is user input and the event name is user-submitted; concatenating them into a string is an injection waiting to happen.
  • The rating is stars and the figure. Stars alone put a lone filled star at position four and it reads as "the fourth", not "4 of 5". The number carries the precision; the stars carry the recognition.
  • An unrated event shows "Not yet rated", not five empty stars. Five empty stars is a rendering of zero, and zero is a rating nobody gave. Absence is its own state — the same rule as the ScoreBar neutral.
  • Stars are gold at every tier. Tier colour never enters this list; at 14px, five colour-coded rows are noise.
Divergence from the shipping search

The current product renders five empty stars for an event with no ratings, and no number beside the stars. Both are corrected here: empty stars claim a score that does not exist, and stars without a figure are ambiguous at a glance. If the shipping search keeps them, that is a product divergence, not a system update.

When the create row appears

This is the whole design, and it is a state machine, not a condition. Getting it wrong in either direction costs you: too eager and you get duplicates, too shy and the person abandons.

QuerySearch stateCreate rowWhy
EmptyidleHiddenNothing to name it with.
1–2 charactersidleHiddenToo short to be an event name, and too short to search well.
3+ characterssearchingHiddenThe one that matters. Never offer creation while results are still in flight.
3+ charactersresults returnedShown, lastThey have seen the matches and rejected them.
3+ charactersno resultsShown, aloneWith an empty-state line above it, not a bare row.
Exact name matchresults returnedHiddenIf the typed string equals a result's name, creation is almost certainly a mistake.
Anyrequest failedHiddenWe do not know whether it exists. Offer Retry, not Create.
The rule behind the table

Creation is only ever offered after a completed search. A create row visible during loading, or after an error, is a duplicate-event generator — and a duplicate splits an event's ratings in half, which is worse for the organiser than a missing event.

Matching

  • Diacritic-insensitive, both ways. "geres" finds "Trail do Gerês"; "Gerês" finds a record typed without accents. Portuguese event names are typed on English keyboards constantly.
  • Token AND, not substring. "maratona lisboa" matches "Maratona de Lisboa" — every token must appear somewhere, in any order. Plain includes() fails this and is the most common cause of a false "no results".
  • Stopwords ignored in the query but not the record: de, do, da, of, the.
  • Never fuzzy on the create check. Matching is generous for showing results and strict for hiding the create row — the two thresholds are different on purpose.
  • Debounce 200ms, and cancel in-flight requests. An out-of-order response that arrives after a later one is how a list ends up showing matches for a query the person has already changed.

Creating in place

Selecting the create row swaps the list for a three-field panel in the same component. The name is pre-filled with what was typed, cased as typed — whoever wrote “maratona do porto” gets it back to correct, not silently title-cased into something they did not write.

  • Three required fields, one optional: name, date, city — plus the event website, which is optional and is the single most useful thing on the form. A URL is what turns "someone typed a name" into a claim we can check in ten seconds, it is how the event gets found again next year, and it is the fastest route to an organiser claiming their page. Optional because demanding it would cost more submissions than it saves. Everything else can be filled in by the organiser later; an athlete with a race number still on their vest will not complete a fourth.
  • The date is a native <input type="date">. Not three selects, not a custom calendar. The platform picker is already localised, already keyboard-accessible, and on a phone it is the control they use every day. Store ISO, display in the locale.
  • The city is a Places Autocomplete field, not free text. It resolves to a city and its country, both stored — "Braga" is in Portugal and in Spain, and "Trail do Gerês" needs to be findable by someone searching either. Typed text that was never picked from the list is rejected: without a selection there is no country.
  • Validate the URL, do not fetch it. Shape only — starts with http(s)://, has a dot. Fetching an unknown URL from the client leaks the user's IP to a stranger's server, and from the server it is an SSRF surface. Confirmation is a moderation job, not a form job.
  • Signed-out users give an email, and are told why: "So we can reach you if this event needs checking. Not shown publicly, never used for marketing." An email field with no reason attached is the most abandoned field in any form. Signed-in users never see it.
  • Terms are a real checkbox, unticked. Never pre-ticked, never implied by the button label. The label also asks them to confirm the event is real — one sentence doing the legal job and the moderation job together.
  • Validation on submit, not on disable. The button stays enabled and explains what is missing, in role="alert", focusing the offending field. A disabled button that will not say why is a dead end.
  • Never a dialog and never a route change. Both lose the typed query, and a route change loses whatever they were part-way through.
  • Cancel returns to the list with the query intact, so a mis-tap costs nothing.
  • On submit the new event is selected immediately and they carry on. Moderation happens behind them, never in front of them.
  • A created event is marked unverified until an organiser claims it. It rates normally; it just does not appear in public rankings yet.

Places integration

The city field is a Google Places Autocomplete session restricted to cities. Two details that are easy to get wrong and expensive to get wrong:

const ac = new google.maps.places.Autocomplete(input, {
  types: ['(cities)'],
  fields: ['address_components', 'name'],   // never 'geometry' — it is billed separately
  sessionToken                              // one token per typing session, or every
});                                          // keystroke bills as its own request

ac.addListener('place_changed', () => {
  const p = ac.getPlace();
  const country = p.address_components?.find(c => c.types.includes('country'));
  if (!country) return reject();            // no selection, no country, no submit
  save({ city: p.name, country: country.long_name, countryCode: country.short_name });
});
  • Store the country code, not just the name. "Portugal" and "Portugal" from two locales are not the same string; PT always is.
  • Never trust the input's text. Submit is blocked until place_changed has fired — a typed string that looks like a city is not a place.
  • The field degrades to plain text if the script fails to load, and the country becomes a required select. An organiser on a blocked network still gets to add their event.
  • It is a combobox too, with the same keyboard contract as the event search above it. Two comboboxes in one panel, both behaving identically.

States

Searching

Skeleton rows in the shape of results. No spinner, no create row.

No results

"No event matches corrida do douro." — then the create row, and nothing else. No illustration, no apology.

Failed

"Search didn't load." with Retry. The create row stays hidden — we cannot claim the event is missing.

Selected

Input holds the name, list closes, a clear button appears. Selection is committed on Enter or click, never on hover or blur.

Errors

Validation follows Input & textarea exactly: a border, a message, aria-invalid, aria-describedby. No red fill, and no single summary line at the foot of the form — an error belongs beside the control that caused it, because that is where the fix happens.

FieldMessage
Event nameEnter the event name.
DatePick the date it took place.
City — emptyStart typing and pick a city.
City — typed but not chosenPick a city from the list so we know the country.
Event websiteInclude https:// at the start.
Email — emptyEnter your email.
Email — malformedCheck for typos — this isn’t a valid address.
TermsConfirm the terms to add this event.
  • The label already names the field, so the message says what to do and nothing else. No “Error:”, no field name repeated, no apology.
  • Two states, two messages. Empty and malformed are different problems — “Enter your email” and “Check for typos” send the person to different actions.
  • Validation runs on submit, never on blur of an untouched field. It walks the fields in reading order, so focus never travels backwards — the first failing control takes focus and the rest stay quiet.
  • An error dies the moment it stops being true — on input for text fields, on change for the checkbox, on selection for the city. It does not wait for a second submit.
  • role="alert" is set when the message appears, not declared on an empty element. A permanent alert node announces nothing, because there is no mutation for the screen reader to catch.
  • aria-describedby keeps the help text. The error id goes first, so the order heard is problem, then reason — the field never loses its explanation to a validation failure.

Accessibility

This is the ARIA 1.2 combobox-with-listbox pattern, unmodified. The create row's only difference from a result is how it looks.

<label for="event-search">Which event did you take part in?</label>

<input id="event-search" type="text" role="combobox"
       aria-expanded="true" aria-controls="event-list"
       aria-autocomplete="list" aria-activedescendant="event-opt-2"
       autocomplete="off">

<ul id="event-list" role="listbox" aria-label="Events">
  <li role="option" id="event-opt-1">Maratona de Lisboa <span>14 Mar 2026 · Lisboa</span></li>
  <li role="option" id="event-opt-2" aria-selected="true">Meia de Lisboa <span>02 Feb 2026 · Lisboa</span></li>

  <!-- last, and only after the search has completed -->
  <li role="option" id="event-opt-new">Create “maratona lisboa”</li>
</ul>

<div role="status" aria-live="polite" class="sr-only">2 events found</div>
KeyDoes
/ Moves aria-activedescendant. Wraps. Opens the list if closed.
EnterSelects the active option — including the create row. Does nothing if none is active.
EscCloses the list. A second press clears the field.
Home / EndFirst / last option.
TabLeaves the field and closes the list without selecting.
  • Focus never leaves the input. Options are pointed at with aria-activedescendant, never focused — that is what keeps typing possible while navigating.
  • Nothing is active on open. Auto-highlighting the first row means Enter selects something they have not read.
  • The status region announces counts, not rows. "2 events found" — the listbox itself announces the rows.
  • Rows are 44px in every density. This runs on a phone, outdoors, one-handed.
  • The clear button is a real button with its own label, and it is 44px too.

Copy

Create “corrida do douro”
Add an event we don't have yet
Do

Quote what they typed. Say plainly that we do not have it.

Can't find it? Add your event!
Don't

An exclamation mark, a question they already answered, and no sign of what will be created.

Sporting Scouter Design System
Every token on this site is generated from theme.css.