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.
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:
| Who | Wants | What that means here |
|---|---|---|
| An athlete checking a rating | The score, before entering | The rating must be on the result row — they may never click through. |
| An athlete about to review | To find the event they ran | Date and city disambiguate editions and namesakes. |
| An organiser finding their own event | To claim or check it | Unverified events must be findable, not hidden. |
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 · Label2 · Combobox inputrole="combobox", aria-expanded, aria-controls, aria-autocomplete="list". A real <input>.3 · Listbox4 · Create row5 · Create panel6 · Status regionrole="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.
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.
| Query | Search state | Create row | Why |
|---|---|---|---|
| Empty | idle | Hidden | Nothing to name it with. |
| 1–2 characters | idle | Hidden | Too short to be an event name, and too short to search well. |
| 3+ characters | searching | Hidden | The one that matters. Never offer creation while results are still in flight. |
| 3+ characters | results returned | Shown, last | They have seen the matches and rejected them. |
| 3+ characters | no results | Shown, alone | With an empty-state line above it, not a bare row. |
| Exact name match | results returned | Hidden | If the typed string equals a result's name, creation is almost certainly a mistake. |
| Any | request failed | Hidden | We do not know whether it exists. Offer Retry, not Create. |
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;
PTalways is. - Never trust the input's text. Submit is blocked until
place_changedhas 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
Skeleton rows in the shape of results. No spinner, no create row.
"No event matches corrida do douro." — then the create row, and nothing else. No illustration, no apology.
"Search didn't load." with Retry. The create row stays hidden — we cannot claim the event is missing.
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.
| Field | Message |
|---|---|
| Event name | Enter the event name. |
| Date | Pick the date it took place. |
| City — empty | Start typing and pick a city. |
| City — typed but not chosen | Pick a city from the list so we know the country. |
| Event website | Include https:// at the start. |
| Email — empty | Enter your email. |
| Email — malformed | Check for typos — this isn’t a valid address. |
| Terms | Confirm 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
inputfor text fields, onchangefor 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-describedbykeeps 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>| Key | Does |
|---|---|
| ↓ / ↑ | Moves aria-activedescendant. Wraps. Opens the list if closed. |
| Enter | Selects the active option — including the create row. Does nothing if none is active. |
| Esc | Closes the list. A second press clears the field. |
| Home / End | First / last option. |
| Tab | Leaves 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
Quote what they typed. Say plainly that we do not have it.
An exclamation mark, a question they already answered, and no sign of what will be created.
theme.css.