Choice Cards
Cards to compare and pick, on real radio buttons or checkboxes, with a title, a line and an optional price. The picked card's ink border draws itself around it from the corner while a check draws in its mark. Pure CSS.
View codeA real select you can type into: the list filters as you type, accents and case aside, the matched letters underlined and the best match lit for Enter. Codes on the right are searched too.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Add the Kodza Combobox to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<div class="kodza-combobox">
<label class="kodza-combobox__box">
<span class="kodza-combobox__label">Country</span>
<select class="kodza-combobox__native" name="country" required>
<option value="" selected hidden></option>
<option value="ar" data-meta="AR">Argentina</option>
<option value="au" data-meta="AU">Australia</option>
<option value="at" data-meta="AT">Austria</option>
<option value="be" data-meta="BE">Belgium</option>
<option value="br" data-meta="BR">Brazil</option>
<option value="ca" data-meta="CA">Canada</option>
<option value="cl" data-meta="CL">Chile</option>
<option value="cn" data-meta="CN">China</option>
<option value="co" data-meta="CO">Colombia</option>
<option value="ci" data-meta="CI">Côte d’Ivoire</option>
<option value="cz" data-meta="CZ">Czechia</option>
<option value="dk" data-meta="DK">Denmark</option>
<option value="eg" data-meta="EG">Egypt</option>
<option value="ee" data-meta="EE">Estonia</option>
<option value="fi" data-meta="FI">Finland</option>
<option value="fr" data-meta="FR">France</option>
<option value="de" data-meta="DE">Germany</option>
<option value="gh" data-meta="GH">Ghana</option>
<option value="gr" data-meta="GR">Greece</option>
<option value="is" data-meta="IS">Iceland</option>
<option value="in" data-meta="IN">India</option>
<option value="id" data-meta="ID">Indonesia</option>
<option value="ie" data-meta="IE">Ireland</option>
<option value="it" data-meta="IT">Italy</option>
<option value="jp" data-meta="JP">Japan</option>
<option value="ke" data-meta="KE">Kenya</option>
<option value="mx" data-meta="MX">Mexico</option>
<option value="ma" data-meta="MA">Morocco</option>
<option value="nl" data-meta="NL">Netherlands</option>
<option value="nz" data-meta="NZ">New Zealand</option>
<option value="ng" data-meta="NG">Nigeria</option>
<option value="no" data-meta="NO">Norway</option>
<option value="pe" data-meta="PE">Peru</option>
<option value="pl" data-meta="PL">Poland</option>
<option value="pt" data-meta="PT">Portugal</option>
<option value="re" data-meta="RE">Réunion</option>
<option value="sn" data-meta="SN">Senegal</option>
<option value="sg" data-meta="SG">Singapore</option>
<option value="za" data-meta="ZA">South Africa</option>
<option value="kr" data-meta="KR">South Korea</option>
<option value="es" data-meta="ES">Spain</option>
<option value="se" data-meta="SE">Sweden</option>
<option value="ch" data-meta="CH">Switzerland</option>
<option value="tr" data-meta="TR">Türkiye</option>
<option value="gb" data-meta="GB">United Kingdom</option>
<option value="us" data-meta="US">United States</option>
<option value="uy" data-meta="UY">Uruguay</option>
<option value="vn" data-meta="VN">Vietnam</option>
</select>
</label>
<p class="kodza-combobox__note">
<span class="kodza-combobox__hint">Accents optional. Try “cote”.</span>
<span class="kodza-combobox__error" aria-live="polite"></span>
</p>
</div>
<div class="kodza-combobox">
<label class="kodza-combobox__box">
<span class="kodza-combobox__label">Time zone</span>
<select class="kodza-combobox__native" name="timezone">
<optgroup label="Europe">
<option value="Europe/London" data-meta="UTC+0">London</option>
<option value="Europe/Lisbon" data-meta="UTC+0">Lisbon</option>
<option value="Europe/Paris" data-meta="UTC+1" selected>Paris</option>
<option value="Europe/Berlin" data-meta="UTC+1">Berlin</option>
<option value="Europe/Madrid" data-meta="UTC+1">Madrid</option>
<option value="Europe/Stockholm" data-meta="UTC+1">Stockholm</option>
<option value="Europe/Athens" data-meta="UTC+2">Athens</option>
<option value="Europe/Helsinki" data-meta="UTC+2">Helsinki</option>
<option value="Europe/Istanbul" data-meta="UTC+3">Istanbul</option>
</optgroup>
<optgroup label="Africa">
<option value="Africa/Dakar" data-meta="UTC+0">Dakar</option>
<option value="Africa/Lagos" data-meta="UTC+1">Lagos</option>
<option value="Africa/Cairo" data-meta="UTC+2">Cairo</option>
<option value="Africa/Johannesburg" data-meta="UTC+2">Johannesburg</option>
<option value="Africa/Nairobi" data-meta="UTC+3">Nairobi</option>
</optgroup>
<optgroup label="Americas">
<option value="America/Anchorage" data-meta="UTC−9">Anchorage</option>
<option value="America/Los_Angeles" data-meta="UTC−8">Los Angeles</option>
<option value="America/Denver" data-meta="UTC−7">Denver</option>
<option value="America/Mexico_City" data-meta="UTC−6">Mexico City</option>
<option value="America/Chicago" data-meta="UTC−6">Chicago</option>
<option value="America/New_York" data-meta="UTC−5">New York</option>
<option value="America/Bogota" data-meta="UTC−5">Bogotá</option>
<option value="America/Halifax" data-meta="UTC−4">Halifax</option>
<option value="America/Santiago" data-meta="UTC−3">Santiago</option>
<option value="America/Sao_Paulo" data-meta="UTC−3">São Paulo</option>
<option value="America/Argentina/Buenos_Aires" data-meta="UTC−3">Buenos Aires</option>
</optgroup>
<optgroup label="Asia">
<option value="Asia/Dubai" data-meta="UTC+4">Dubai</option>
<option value="Asia/Karachi" data-meta="UTC+5">Karachi</option>
<option value="Asia/Kolkata" data-meta="UTC+5:30">Mumbai</option>
<option value="Asia/Kathmandu" data-meta="UTC+5:45">Kathmandu</option>
<option value="Asia/Dhaka" data-meta="UTC+6">Dhaka</option>
<option value="Asia/Bangkok" data-meta="UTC+7">Bangkok</option>
<option value="Asia/Singapore" data-meta="UTC+8">Singapore</option>
<option value="Asia/Shanghai" data-meta="UTC+8">Shanghai</option>
<option value="Asia/Seoul" data-meta="UTC+9">Seoul</option>
<option value="Asia/Tokyo" data-meta="UTC+9">Tokyo</option>
</optgroup>
<optgroup label="Pacific">
<option value="Pacific/Honolulu" data-meta="UTC−10">Honolulu</option>
<option value="Australia/Perth" data-meta="UTC+8">Perth</option>
<option value="Australia/Sydney" data-meta="UTC+11">Sydney</option>
<option value="Pacific/Fiji" data-meta="UTC+12">Fiji</option>
<option value="Pacific/Auckland" data-meta="UTC+13">Auckland</option>
</optgroup>
</select>
</label>
<p class="kodza-combobox__note">
<span class="kodza-combobox__hint">City or offset: “+9” works.</span>
<span class="kodza-combobox__error" aria-live="polite"></span>
</p>
</div>
<div class="kodza-combobox">
<label class="kodza-combobox__box">
<span class="kodza-combobox__label">Assign to</span>
<select class="kodza-combobox__native" name="assignee">
<option value="" selected>Unassigned</option>
<option value="amara" data-meta="@amara">Amara Okafor</option>
<option value="hanna" data-meta="@hanna">Hanna Nowak</option>
<option value="ines" data-meta="@ines">Inès Moreau</option>
<option value="kenji" data-meta="@kenji">Kenji Watanabe</option>
<option value="leo" data-meta="@leo">Léo Lefebvre</option>
<option value="mateo" data-meta="@mateo">Mateo García</option>
<option value="ravi" data-meta="@ravi">Ravi Iyer</option>
<option value="sofia" data-meta="@sofia">Sofia Rossi</option>
<option value="dependabot" data-meta="@bot" disabled>Dependabot (just bumps)</option>
</select>
</label>
<p class="kodza-combobox__note">
<span class="kodza-combobox__hint">Name or @handle.</span>
<span class="kodza-combobox__error" aria-live="polite"></span>
</p>
</div>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Combobox
A select you can type into. The script lays a text field over a real select (kept in step,
so forms and WordPress get the value): typing filters the list, accents and case don't
matter, the matched letters are underlined and the best match is lit, ready for Enter. Groups
stay, a short code rides on the right of each option (data-meta, searched too: "+9", "@ines",
"FR"), and the list changes height smoothly as it filters. Leave the field on a half-typed
name and it takes the only match, or puts the last choice back and says why, under the field.
Without JS, the native select stays, styled. Same box as Text Field and Select.
Everything is tuned with the --kz-combobox-* variables; the words and timings, at the top of
the script. */
.kodza-combobox {
--kz-combobox-bg: #ffffff;
--kz-combobox-fg: #0a0a0a;
--kz-combobox-muted: rgba(10, 10, 10, 0.6);
--kz-combobox-line: rgba(10, 10, 10, 0.14);
--kz-combobox-line-hover: rgba(10, 10, 10, 0.3);
--kz-combobox-soft: rgba(10, 10, 10, 0.06);
--kz-combobox-selection: rgba(128, 128, 128, 0.35); /* the selected text: grey, not the browser blue */
--kz-combobox-error: #0a0a0a; /* a message, never colour alone: set a red here if you want one */
--kz-combobox-shadow: 0 18px 40px -16px rgba(10, 10, 10, 0.3);
--kz-combobox-height: 56px;
--kz-combobox-radius: calc(12px * var(--kz-radius-scale, 1));
--kz-combobox-pad: 16px;
--kz-combobox-width: var(--kz-field-width, 320px);
--kz-combobox-list-height: 280px; /* the open list, at most: it scrolls past that */
--kz-combobox-duration: 0.35s;
--kz-combobox-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
display: grid;
gap: 8px;
width: 100%;
max-width: var(--kz-combobox-width);
/* The closed list keeps its size, ready to unroll, but never lengthens the page */
contain: layout;
/* A number, not auto, so it can wait for the list to roll up before dropping back */
z-index: 0;
transition: z-index 0s linear calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1));
color: var(--kz-combobox-fg);
}
/* Open, the field rises above what follows it (its list included) */
.kodza-combobox.is-open {
z-index: 20;
transition-delay: 0s;
}
/* The box: label on top, value below (the native select, or the text field the script adds) */
.kodza-combobox__box {
position: relative;
display: block;
box-sizing: border-box;
height: var(--kz-combobox-height);
margin: 0;
padding: 0;
border-radius: var(--kz-combobox-radius);
background: var(--kz-combobox-bg);
box-shadow: inset 0 0 0 1px var(--kz-combobox-line);
cursor: text;
transition: box-shadow calc(0.25s * var(--kz-motion-scale, 1)) ease;
}
.kodza-combobox__box:hover {
box-shadow: inset 0 0 0 1px var(--kz-combobox-line-hover);
}
.kodza-combobox__box:focus-within {
box-shadow: inset 0 0 0 1.5px var(--kz-combobox-fg), 0 0 0 4px var(--kz-combobox-soft);
}
.kodza-combobox.is-invalid .kodza-combobox__box {
box-shadow: inset 0 0 0 1.5px var(--kz-combobox-error);
}
.kodza-combobox.is-invalid .kodza-combobox__box:focus-within {
box-shadow: inset 0 0 0 1.5px var(--kz-combobox-error), 0 0 0 4px var(--kz-combobox-soft);
}
.kodza-combobox.is-disabled .kodza-combobox__box {
opacity: 0.5;
cursor: not-allowed;
}
.kodza-combobox__label {
position: absolute;
top: 10px;
left: var(--kz-combobox-pad);
z-index: 1;
color: var(--kz-combobox-muted);
font-size: 12px;
line-height: 1;
pointer-events: none;
transform-origin: left top;
transition: transform calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1)) var(--kz-combobox-ease);
}
/* Nothing chosen yet: the label sits in the field at the size of a value, as in Text Field, and
rises when the field takes the focus */
.kodza-combobox.is-empty .kodza-combobox__box:not(:focus-within) .kodza-combobox__label {
transform: translateY(calc(var(--kz-combobox-height) / 2 - 17.5px)) scale(1.25);
}
/* Without the script: the native select, styled like the field */
.kodza-combobox__native {
display: flex;
align-items: flex-end;
box-sizing: border-box;
width: 100%;
height: 100%;
margin: 0;
padding: 0 44px 9px var(--kz-combobox-pad);
border: 0;
border-radius: inherit;
outline: none;
background: transparent url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 6.5 8 10.5l4-4' fill='none' stroke='%23999' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") right 16px center / 16px no-repeat;
color: inherit;
font: inherit;
font-size: 15px;
line-height: 22px;
cursor: pointer;
appearance: none;
}
/* Enhanced: the native select steps back (still in the form), the text field takes its place */
.kodza-combobox.is-ready .kodza-combobox__native {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
clip-path: inset(50%);
}
/* The value line: the text field, and the old value rolling out above it (ghost) */
.kodza-combobox__value {
position: absolute;
right: 44px;
bottom: 9px;
left: var(--kz-combobox-pad);
height: 22px;
overflow: hidden;
}
.kodza-combobox__input,
.kodza-combobox__ghost {
display: block;
box-sizing: border-box;
width: 100%;
height: 22px;
margin: 0;
padding: 0;
overflow: hidden;
color: inherit;
font: inherit;
font-size: 15px;
line-height: 22px;
letter-spacing: inherit;
text-overflow: ellipsis;
white-space: pre;
}
.kodza-combobox__input {
border: 0;
border-radius: 0;
outline: none;
background: transparent;
box-shadow: none;
caret-color: var(--kz-combobox-fg);
appearance: none;
}
/* Clicking in selects the whole name, ready to be typed over */
.kodza-combobox__input::selection {
background: var(--kz-combobox-selection);
color: var(--kz-combobox-fg);
}
.kodza-combobox__input::placeholder {
color: transparent;
opacity: 1;
transition: color calc(0.2s * var(--kz-motion-scale, 1)) ease;
}
.kodza-combobox__input:focus::placeholder {
color: var(--kz-combobox-muted);
}
.kodza-combobox__input:disabled {
cursor: not-allowed;
}
.kodza-combobox__ghost {
position: absolute;
inset: 0;
pointer-events: none;
}
/* The chevron: a click opens the list, or closes it */
.kodza-combobox__toggle {
position: absolute;
top: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
cursor: pointer;
}
.kodza-combobox.is-disabled .kodza-combobox__toggle {
cursor: not-allowed;
}
.kodza-combobox__toggle svg {
display: block;
width: 16px;
height: 16px;
fill: none;
stroke: var(--kz-combobox-muted);
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1)) var(--kz-combobox-ease);
}
.kodza-combobox.is-open .kodza-combobox__toggle svg {
transform: rotate(180deg);
}
/* The list unrolls from the field, over the note below it */
.kodza-combobox__list {
position: absolute;
top: calc(var(--kz-combobox-height) + 8px);
right: 0;
left: 0;
z-index: 20;
box-sizing: border-box;
max-height: var(--kz-combobox-list-height);
margin: 0;
padding: 6px;
overflow-y: auto;
overscroll-behavior: contain;
border-radius: calc(14px * var(--kz-radius-scale, 1));
background: var(--kz-combobox-bg);
box-shadow: 0 0 0 1px var(--kz-combobox-line), var(--kz-combobox-shadow);
list-style: none;
visibility: hidden;
clip-path: inset(0 0 100% 0 round calc(14px * var(--kz-radius-scale, 1)));
transform: translateY(-4px);
transition:
clip-path calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1)) var(--kz-combobox-ease),
transform calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1)) var(--kz-combobox-ease),
visibility 0s linear calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1));
}
.kodza-combobox.is-open .kodza-combobox__list {
visibility: visible;
clip-path: inset(-40px -40px -60px -40px round calc(14px * var(--kz-radius-scale, 1)));
transform: none;
transition-delay: 0s;
}
.kodza-combobox.is-above .kodza-combobox__list {
top: auto;
bottom: calc(100% + 8px);
clip-path: inset(100% 0 0 0 round calc(14px * var(--kz-radius-scale, 1)));
transform: translateY(4px);
}
.kodza-combobox.is-above.is-open .kodza-combobox__list {
clip-path: inset(-60px -40px -40px -40px round calc(14px * var(--kz-radius-scale, 1)));
transform: none;
}
.kodza-combobox__highlight {
position: absolute;
top: 0;
right: 6px;
left: 6px;
height: var(--kz-hl-h, 36px);
border-radius: calc(8px * var(--kz-radius-scale, 1));
background: var(--kz-combobox-soft);
opacity: var(--kz-hl-on, 0);
transform: translateY(var(--kz-hl-y, 0));
pointer-events: none;
}
.kodza-combobox.is-open .kodza-combobox__highlight {
transition:
transform calc(0.22s * var(--kz-motion-scale, 1)) var(--kz-combobox-ease),
opacity calc(0.15s * var(--kz-motion-scale, 1)) ease;
}
.kodza-combobox__group {
margin: 0;
padding: 8px 10px 4px 32px;
font-family: var(--kz-font-label, var(--kz-font-mono, ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 11px);
font-weight: var(--kz-font-label-weight, inherit);
line-height: var(--kz-font-label-leading, inherit);
letter-spacing: var(--kz-font-label-tracking, inherit);
text-transform: var(--kz-font-label-case, inherit);
color: var(--kz-combobox-muted);
}
.kodza-combobox__option {
position: relative;
display: flex;
align-items: center;
gap: 12px;
height: 36px;
margin: 0;
padding: 0 10px 0 32px;
border-radius: calc(8px * var(--kz-radius-scale, 1));
font-size: 14px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
}
.kodza-combobox__option[aria-disabled="true"] {
color: var(--kz-combobox-muted);
cursor: not-allowed;
}
.kodza-combobox__group[hidden],
.kodza-combobox__option[hidden],
.kodza-combobox__empty[hidden] {
display: none;
}
.kodza-combobox__text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* The letters that match what was typed */
.kodza-combobox__option mark {
padding: 0;
background: none;
color: inherit;
text-decoration: underline;
text-decoration-thickness: 1.5px;
text-underline-offset: 3px;
}
/* The code on the right (data-meta): ISO code, offset, handle */
.kodza-combobox__meta {
flex: none;
margin-left: auto;
color: var(--kz-combobox-muted);
font-family: var(--kz-font-label, var(--kz-font-mono, ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 12px);
font-weight: var(--kz-font-label-weight, 400);
letter-spacing: var(--kz-font-label-tracking, inherit);
font-variant-numeric: tabular-nums;
}
/* The tick of the chosen option draws itself */
.kodza-combobox__option svg {
position: absolute;
left: 10px;
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
transition: stroke-dashoffset calc(0.15s * var(--kz-motion-scale, 1)) ease-in;
}
.kodza-combobox__option[aria-selected="true"] svg {
stroke-dashoffset: 0;
transition: stroke-dashoffset calc(0.35s * var(--kz-motion-scale, 1)) var(--kz-combobox-ease) calc(0.05s * var(--kz-motion-scale, 1));
}
/* Nothing matches */
.kodza-combobox__empty {
margin: 0;
padding: 18px 10px;
color: var(--kz-combobox-muted);
font-size: 13px;
text-align: center;
}
/* Hint and error share one line: the hint steps out upward as the error rises in */
.kodza-combobox__note {
display: grid;
margin: 0;
padding: 0 2px;
overflow: hidden;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 13px);
font-weight: var(--kz-font-label-weight, inherit);
letter-spacing: var(--kz-font-label-tracking, inherit);
line-height: var(--kz-font-label-leading, 1.4);
text-transform: var(--kz-font-label-case, inherit);
}
.kodza-combobox__hint,
.kodza-combobox__error {
grid-area: 1 / 1;
transition:
opacity calc(var(--kz-combobox-duration) * 0.625 * var(--kz-motion-scale, 1)) ease,
transform calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1)) var(--kz-combobox-ease);
}
.kodza-combobox__hint {
color: var(--kz-combobox-muted);
}
.kodza-combobox__error {
display: flex;
align-items: center;
gap: 6px;
color: var(--kz-combobox-error);
font-weight: var(--kz-font-label-weight, 500);
opacity: 0;
transform: translateY(100%);
}
.kodza-combobox__error::before {
content: "";
flex: none;
width: 14px;
height: 14px;
background: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6.5' fill='none' stroke='black' stroke-width='1.5'/%3E%3Cpath d='M8 4.5v4.2M8 10.9v.1' stroke='black' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6.5' fill='none' stroke='black' stroke-width='1.5'/%3E%3Cpath d='M8 4.5v4.2M8 10.9v.1' stroke='black' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
}
.kodza-combobox.is-invalid .kodza-combobox__hint {
opacity: 0;
transform: translateY(-100%);
}
.kodza-combobox.is-invalid .kodza-combobox__error {
opacity: 1;
transform: none;
}
/* What screen readers hear as the list filters ("4 options") */
.kodza-combobox__live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.kodza-combobox__box,
.kodza-combobox__label,
.kodza-combobox__input::placeholder,
.kodza-combobox__toggle svg,
.kodza-combobox__list,
.kodza-combobox.is-open .kodza-combobox__highlight,
.kodza-combobox__option svg,
.kodza-combobox__hint,
.kodza-combobox__error {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-combobox {
--kz-combobox-bg: #161616;
--kz-combobox-fg: #f2f2f0;
--kz-combobox-muted: rgba(242, 242, 240, 0.62);
--kz-combobox-line: rgba(255, 255, 255, 0.14);
--kz-combobox-line-hover: rgba(255, 255, 255, 0.3);
--kz-combobox-soft: rgba(255, 255, 255, 0.08);
--kz-combobox-error: #f2f2f0;
--kz-combobox-shadow: 0 18px 40px -16px rgba(0, 0, 0, 0.8);
}
*/
/* kodza-guard bbd3901dc7d7 — generated by Kodza, do not edit.
Theme guard: keeps a site's own CSS (WordPress themes, Bootstrap, CSS resets…) off this
component. :not(#_) is always true and weighs as much as an id, so these lines win over
theme rules made of classes and tags. Customise the component through its --kz-* variables. */
/* What the browser gives, where the component sets it only in some states: at the lowest weight, so its own rules below put it back */
:where(.kodza-combobox__native):not(#_) {
position: revert;
}
:where(.kodza-combobox__highlight):not(#_) {
transition: revert;
}
:where(.kodza-combobox__option):not(#_) {
color: revert;
}
:where(.kodza-combobox__empty):not(#_):where(:not([hidden])),
:where(.kodza-combobox__group):not(#_):where(:not([hidden])) {
display: revert;
}
:where(.kodza-combobox__empty):not(#_):where([hidden]),
:where(.kodza-combobox__group):not(#_):where([hidden]) {
display: none;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-combobox:not(#_),
.kodza-combobox__label:not(#_),
.kodza-combobox__value:not(#_),
.kodza-combobox__toggle:not(#_),
.kodza-combobox__text:not(#_),
.kodza-combobox__meta:not(#_),
.kodza-combobox__hint:not(#_),
.kodza-combobox__error:not(#_),
.kodza-combobox__live:not(#_) {
box-sizing: revert;
}
.kodza-combobox__box:not(#_) {
border: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
float: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-combobox__input:not(#_) {
text-decoration: revert;
transition: revert;
list-style: revert;
float: revert;
position: revert;
outline-offset: revert;
max-width: revert;
max-height: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
min-height: revert;
}
.kodza-combobox__native:not(#_) {
text-decoration: revert;
transition: revert;
list-style: revert;
float: revert;
box-shadow: revert;
outline-offset: revert;
max-width: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-combobox__list:not(#_) {
border: revert;
outline: revert;
text-decoration: revert;
float: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-combobox__highlight:not(#_) {
border: revert;
margin: revert;
padding: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-combobox__option:not(#_) {
border: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-combobox__empty:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-combobox__note:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-combobox__group:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-combobox__native option:where(:not([class*="kodza-"])):not(#_),
.kodza-combobox__native optgroup:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
color: revert;
}
.kodza-combobox__toggle svg:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-combobox__option svg:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-combobox__list:not(#_):where(:not([hidden])),
.kodza-combobox__highlight:not(#_):where(:not([hidden])),
.kodza-combobox__option svg:where(:not([class*="kodza-"])):not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-combobox__list:not(#_):where([hidden]),
.kodza-combobox__highlight:not(#_):where([hidden]),
.kodza-combobox__option svg:where(:not([class*="kodza-"])):not(#_):where([hidden]) {
display: none;
}
.kodza-combobox__error:not(#_)::before {
box-sizing: revert;
}
/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */
.kodza-combobox:not(#_) {
width: min(100%, var(--kz-combobox-width));
}
.kodza-combobox__box:not(#_) {
position: relative;
display: block;
box-sizing: border-box;
margin: 0;
padding: 0;
border-radius: var(--kz-combobox-radius);
background: var(--kz-combobox-bg);
box-shadow: inset 0 0 0 1px var(--kz-combobox-line);
transition: box-shadow calc(0.25s * var(--kz-motion-scale, 1)) ease;
}
.kodza-combobox__box:hover:not(#_) {
box-shadow: inset 0 0 0 1px var(--kz-combobox-line-hover);
}
.kodza-combobox__box:focus-within:not(#_) {
box-shadow: inset 0 0 0 1.5px var(--kz-combobox-fg), 0 0 0 4px var(--kz-combobox-soft);
}
.kodza-combobox.is-invalid .kodza-combobox__box:not(#_) {
box-shadow: inset 0 0 0 1.5px var(--kz-combobox-error);
}
.kodza-combobox.is-invalid .kodza-combobox__box:focus-within:not(#_) {
box-shadow: inset 0 0 0 1.5px var(--kz-combobox-error), 0 0 0 4px var(--kz-combobox-soft);
}
.kodza-combobox__native:not(#_) {
display: flex;
align-items: flex-end;
box-sizing: border-box;
width: 100%;
height: 100%;
margin: 0;
padding: 0 44px 9px var(--kz-combobox-pad);
border: 0;
border-radius: inherit;
outline: none;
background: transparent url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 6.5 8 10.5l4-4' fill='none' stroke='%23999' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") right 16px center / 16px no-repeat;
color: inherit;
font: inherit;
font-size: 15px;
line-height: 22px;
cursor: pointer;
appearance: none;
}
.kodza-combobox.is-ready .kodza-combobox__native:not(#_) {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
clip-path: inset(50%);
}
.kodza-combobox__input:not(#_) {
display: block;
box-sizing: border-box;
width: 100%;
height: 22px;
margin: 0;
padding: 0;
overflow: hidden;
color: inherit;
font: inherit;
font-size: 15px;
line-height: 22px;
letter-spacing: inherit;
text-overflow: ellipsis;
white-space: pre;
}
.kodza-combobox__input:not(#_) {
border: 0;
border-radius: 0;
outline: none;
background: transparent;
box-shadow: none;
caret-color: var(--kz-combobox-fg);
appearance: none;
}
.kodza-combobox__input:not(#_)::selection {
background: var(--kz-combobox-selection);
color: var(--kz-combobox-fg);
}
.kodza-combobox__input:not(#_)::placeholder {
color: transparent;
opacity: 1;
transition: color calc(0.2s * var(--kz-motion-scale, 1)) ease;
}
.kodza-combobox__input:focus:not(#_)::placeholder {
color: var(--kz-combobox-muted);
}
.kodza-combobox__input:disabled:not(#_) {
cursor: not-allowed;
}
.kodza-combobox__toggle svg:not(#_) {
display: block;
transition: transform calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1)) var(--kz-combobox-ease);
}
.kodza-combobox__list:not(#_) {
position: absolute;
box-sizing: border-box;
max-height: var(--kz-combobox-list-height);
margin: 0;
padding: 6px;
border-radius: calc(14px * var(--kz-radius-scale, 1));
background: var(--kz-combobox-bg);
box-shadow: 0 0 0 1px var(--kz-combobox-line), var(--kz-combobox-shadow);
list-style: none;
transition: clip-path calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1)) var(--kz-combobox-ease),
transform calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1)) var(--kz-combobox-ease),
visibility 0s linear calc(var(--kz-combobox-duration) * var(--kz-motion-scale, 1));
}
.kodza-combobox.is-open .kodza-combobox__list:not(#_) {
transition-delay: 0s;
}
.kodza-combobox__highlight:not(#_) {
position: absolute;
border-radius: calc(8px * var(--kz-radius-scale, 1));
background: var(--kz-combobox-soft);
}
.kodza-combobox.is-open .kodza-combobox__highlight:not(#_) {
transition: transform calc(0.22s * var(--kz-motion-scale, 1)) var(--kz-combobox-ease),
opacity calc(0.15s * var(--kz-motion-scale, 1)) ease;
}
.kodza-combobox__group:not(#_) {
margin: 0;
padding: 8px 10px 4px 32px;
font-family: var(--kz-font-label, var(--kz-font-mono, ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 11px);
font-weight: var(--kz-font-label-weight, inherit);
line-height: var(--kz-font-label-leading, inherit);
letter-spacing: var(--kz-font-label-tracking, inherit);
text-transform: var(--kz-font-label-case, inherit);
color: var(--kz-combobox-muted);
}
.kodza-combobox__option:not(#_) {
position: relative;
display: flex;
margin: 0;
padding: 0 10px 0 32px;
border-radius: calc(8px * var(--kz-radius-scale, 1));
font-size: 14px;
font-weight: 500;
}
.kodza-combobox__option[aria-disabled="true"]:not(#_) {
color: var(--kz-combobox-muted);
}
.kodza-combobox__group[hidden]:not(#_),
.kodza-combobox__option[hidden]:not(#_),
.kodza-combobox__empty[hidden]:not(#_) {
display: none;
}
.kodza-combobox__option svg:not(#_) {
position: absolute;
transition: stroke-dashoffset calc(0.15s * var(--kz-motion-scale, 1)) ease-in;
}
.kodza-combobox__option[aria-selected="true"] svg:not(#_) {
transition: stroke-dashoffset calc(0.35s * var(--kz-motion-scale, 1)) var(--kz-combobox-ease) calc(0.05s * var(--kz-motion-scale, 1));
}
.kodza-combobox__empty:not(#_) {
margin: 0;
padding: 18px 10px;
color: var(--kz-combobox-muted);
font-size: 13px;
text-align: center;
}
.kodza-combobox__note:not(#_) {
display: grid;
margin: 0;
padding: 0 2px;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 13px);
font-weight: var(--kz-font-label-weight, inherit);
letter-spacing: var(--kz-font-label-tracking, inherit);
line-height: var(--kz-font-label-leading, 1.4);
text-transform: var(--kz-font-label-case, inherit);
}
@media (prefers-reduced-motion: reduce) {
.kodza-combobox__box:not(#_),
.kodza-combobox__toggle svg:not(#_),
.kodza-combobox__list:not(#_),
.kodza-combobox.is-open .kodza-combobox__highlight:not(#_),
.kodza-combobox__option svg:not(#_) {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-combobox__input:not(#_)::placeholder {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Combobox
// Lays a text field over the real <select> (options, groups, disabled ones) and keeps both in
// step: typing filters the list, accents and case don't matter, the matched letters are
// underlined and the best match is lit, ready for Enter. The code on the right of an option
// (data-meta) is searched too. Leaving the field takes the only match, or an exact one, or puts
// the last choice back and says why under the field; an empty field takes the empty option.
// "change" fires on the real select, so forms and frameworks see an ordinary one. Options
// changed by the page are read again; after setting .value from a script, fire "change" on it.
// The words and timings are below; each word can be set per field with data-text-<name>
// (data-text-nomatch="…"), {query} and {count} are filled in.
(() => {
const SELECTOR = '.kodza-combobox';
const WORDS = {
placeholder: 'Start typing',
empty: 'No match. Typo?', // the list, when nothing matches
nomatch: 'No match for “{query}”. Typo?', // under the field, when it is left on no match
required: 'Pick one from the list.',
count: '{count} options', // read out as the list filters
one: '1 option',
};
const ROLL = 420; // ms, the value rolling to a new choice
const RESIZE = 240; // ms, the list changing height as it filters
const ANNOUNCE = 400; // ms of quiet before the count is read out (a wait, not a motion)
const PAGE = 5; // rows per PageUp / PageDown
const ease = 'cubic-bezier(0.22, 1, 0.36, 1)';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// The site's pace, --kz-motion-scale (set on :root, read on the instance): every duration of the
// script times it, and 0 moves nothing, like reduced motion
const paceOf = (el) => {
const pace = parseFloat(getComputedStyle(el).getPropertyValue('--kz-motion-scale'));
return pace >= 0 ? pace : 1;
};
const tick = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7" pathLength="1"/></svg>';
const chevron = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6.5 8 10.5l4-4"/></svg>';
let uid = 0;
// Text as it is searched: lower case, no accents, the typographic minus and apostrophes as
// typed on a keyboard. `at` gives, for each folded character, its place in the original text
const fold = (value) => {
let text = '';
const at = [];
let i = 0;
for (const char of value) {
const folded = char.toLowerCase().normalize('NFD').replace(/\p{M}/gu, '').replace(/−/g, '-').replace(/[‘’]/g, "'");
for (let n = 0; n < folded.length; n += 1) at.push(i);
text += folded;
i += char.length;
}
at.push(value.length);
return { text, at };
};
// Where the query starts a word ("york" in "new york"), or -1
const wordAt = (text, query) => {
for (let s = text.indexOf(query); s >= 0; s = text.indexOf(query, s + 1)) {
if (!s || !/[\p{L}\p{N}]/u.test(text[s - 1])) return s;
}
return -1;
};
const fill = (words, values) => words.replace(/\{(\w+)\}/g, (all, key) => (key in values ? values[key] : all));
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const box = root.querySelector('.kodza-combobox__box');
const native = root.querySelector('.kodza-combobox__native');
const label = root.querySelector('.kodza-combobox__label');
const hint = root.querySelector('.kodza-combobox__hint');
const error = root.querySelector('.kodza-combobox__error');
if (!box || !native) return;
const id = `kodza-combobox-${++uid}`;
const word = (key) => root.dataset[`text${key[0].toUpperCase()}${key.slice(1)}`] || WORDS[key];
// The field: a text input on the value line, the old value rolling out above it (ghost)
const value = document.createElement('span');
value.className = 'kodza-combobox__value';
const input = document.createElement('input');
input.className = 'kodza-combobox__input';
input.type = 'text';
input.autocomplete = 'off';
input.spellcheck = false;
input.placeholder = word('placeholder');
input.setAttribute('role', 'combobox');
input.setAttribute('aria-autocomplete', 'list');
input.setAttribute('aria-expanded', 'false');
input.setAttribute('aria-controls', `${id}-list`);
if (label) {
label.id = `${id}-label`;
input.setAttribute('aria-labelledby', `${id}-label`);
}
const described = [];
if (hint) described.push((hint.id = `${id}-hint`));
if (error) described.push((error.id = `${id}-error`));
if (described.length) input.setAttribute('aria-describedby', described.join(' '));
const ghost = document.createElement('span');
ghost.className = 'kodza-combobox__ghost';
ghost.setAttribute('aria-hidden', 'true');
value.append(input, ghost);
// Before the real select: the <label> around them now points at the text field
native.before(value);
native.tabIndex = -1;
native.setAttribute('aria-hidden', 'true');
const toggle = document.createElement('span');
toggle.className = 'kodza-combobox__toggle';
toggle.setAttribute('aria-hidden', 'true');
toggle.innerHTML = chevron;
box.append(toggle);
// The list, from the real options (built again whenever the page changes them)
const list = document.createElement('ul');
list.className = 'kodza-combobox__list';
list.id = `${id}-list`;
list.tabIndex = -1;
list.setAttribute('role', 'listbox');
if (label) list.setAttribute('aria-labelledby', `${id}-label`);
const highlight = document.createElement('li');
highlight.className = 'kodza-combobox__highlight';
highlight.setAttribute('role', 'presentation');
const empty = document.createElement('li');
empty.className = 'kodza-combobox__empty';
empty.setAttribute('role', 'presentation');
empty.hidden = true;
empty.textContent = word('empty');
list.append(highlight, empty);
box.after(list);
const live = document.createElement('span');
live.className = 'kodza-combobox__live';
live.setAttribute('aria-live', 'polite');
root.append(live);
// Sections in the order of the select: a group with its heading, or a run of options
// outside any group. Each entry keeps its folded name and code for the search
let sections = [];
let options = [];
const entries = new Map();
const build = () => {
sections = [];
options = [];
entries.clear();
[...list.children].forEach((child) => child !== highlight && child !== empty && child.remove());
let loose = null;
[...native.children].forEach((child) => {
const group = child.tagName === 'OPTGROUP' ? child : null;
if (!group && child.tagName !== 'OPTION') return;
let section = loose;
if (group) {
const head = document.createElement('li');
head.className = 'kodza-combobox__group';
head.setAttribute('role', 'presentation');
head.textContent = group.label;
section = { head, entries: [] };
sections.push(section);
loose = null;
} else if (!section) {
section = loose = { head: null, entries: [] };
sections.push(section);
}
(group ? [...group.children] : [child]).forEach((option) => {
const text = option.textContent.trim();
if (option.hidden || !text) return; // the placeholder: "nothing chosen yet" is not a choice
const item = document.createElement('li');
item.className = 'kodza-combobox__option';
item.id = `${id}-option-${options.length}`;
item.setAttribute('role', 'option');
item.innerHTML = tick;
const name = document.createElement('span');
name.className = 'kodza-combobox__text';
name.textContent = text;
item.append(name);
const meta = option.dataset.meta?.trim() || '';
let code = null;
if (meta) {
code = document.createElement('span');
code.className = 'kodza-combobox__meta';
code.textContent = meta;
item.append(code);
}
const disabled = option.disabled || !!group?.disabled;
if (disabled) item.setAttribute('aria-disabled', 'true');
const entry = { option, item, text, meta, name, code, disabled, order: options.length, folded: fold(text), metaFolded: fold(meta) };
section.entries.push(entry);
options.push(entry);
entries.set(item, entry);
});
});
sections = sections.filter((section) => section.entries.length);
sections.forEach(({ head, entries: rows }) => {
if (head) list.insertBefore(head, empty);
rows.forEach(({ item }) => list.insertBefore(item, empty));
});
};
build();
const isOpen = () => root.classList.contains('is-open');
let active = null;
let firstShown = null;
let hits = []; // the enabled rows the query matches, best first
let shown = []; // the same, in the order of the list (for the arrows)
let typing = false; // the field holds a query, not the chosen option
// The matched letters, underlined (a slice of the original text, accents and all)
const mark = (el, text, folded, start, length) => {
if (start < 0) return el.replaceChildren(text);
const from = folded.at[start];
const to = Math.max(folded.at[start + length], folded.at[start + length - 1] + 1);
const marked = document.createElement('mark');
marked.textContent = text.slice(from, to);
el.replaceChildren(text.slice(0, from), marked, text.slice(to));
};
// The list for a query: each section keeps its place, its matches come best first (the name
// starts with it, then a word of the name or of the code does, then the name or the code
// holds it), the rest hide. The list changes height smoothly while it is open
let resize = null;
let announced = 0;
const render = (query, speak = true) => {
const q = fold(query.trim()).text;
const still = reduceMotion || !paceOf(root);
const from = isOpen() && !still ? list.offsetHeight : 0;
resize?.cancel();
const ranked = [];
firstShown = null;
sections.forEach(({ head, entries: rows }) => {
const found = [];
const missed = [];
rows.forEach((entry) => {
const { text, meta, name, code, folded, metaFolded } = entry;
let rank = -1;
let inName = -1;
let inCode = -1;
if (!q) rank = 0;
else if (folded.text.startsWith(q)) [rank, inName] = [0, 0];
else if ((inName = wordAt(folded.text, q)) >= 0) rank = 1;
else if ((inCode = wordAt(metaFolded.text, q)) >= 0) rank = 1;
else if ((inName = folded.text.indexOf(q)) >= 0) rank = 2;
else if ((inCode = metaFolded.text.indexOf(q)) >= 0) rank = 3;
mark(name, text, folded, q ? inName : -1, q.length);
if (code) mark(code, meta, metaFolded, q ? inCode : -1, q.length);
entry.item.hidden = rank < 0;
(rank < 0 ? missed : found).push({ entry, rank });
});
found.sort((a, b) => a.rank - b.rank || a.entry.order - b.entry.order);
if (head) {
head.hidden = !found.length;
list.insertBefore(head, empty);
if (found.length && !firstShown) firstShown = head;
}
found.forEach(({ entry }) => {
list.insertBefore(entry.item, empty);
if (!firstShown) firstShown = entry.item;
});
missed.forEach(({ entry }) => list.insertBefore(entry.item, empty));
ranked.push(...found);
});
shown = ranked.filter(({ entry }) => !entry.disabled);
hits = shown.slice().sort((a, b) => a.rank - b.rank).map(({ entry }) => entry);
shown = shown.map(({ entry }) => entry);
empty.hidden = ranked.length > 0;
if (!ranked.length) firstShown = empty;
list.scrollTop = 0;
if (from) {
const to = list.offsetHeight;
if (Math.abs(to - from) > 0.5) {
list.style.overflowY = 'hidden';
resize = list.animate([{ height: `${from}px` }, { height: `${to}px` }], { duration: RESIZE * paceOf(root), easing: ease });
const done = () => {
list.style.overflowY = '';
resize = null;
};
resize.onfinish = done;
resize.oncancel = done;
}
}
// Read out how many are left, once the typing pauses
clearTimeout(announced);
const count = ranked.length;
if (speak) announced = setTimeout(() => {
live.textContent = !count ? word('empty') : count === 1 ? word('one') : fill(word('count'), { count });
}, ANNOUNCE);
return hits[0] || null;
};
const light = (entry, scroll = true) => {
active = entry;
if (!entry) {
input.removeAttribute('aria-activedescendant');
return list.style.setProperty('--kz-hl-on', 0);
}
input.setAttribute('aria-activedescendant', entry.item.id);
list.style.setProperty('--kz-hl-y', `${entry.item.offsetTop}px`);
list.style.setProperty('--kz-hl-h', `${entry.item.offsetHeight}px`);
list.style.setProperty('--kz-hl-on', 1);
// Scroll the list only (scrollIntoView would also nudge the page), with the option's group
// heading when it opens the group, and the list's own padding above the very first row
if (scroll) {
const prev = entry.item.previousElementSibling;
const first = prev?.classList.contains('kodza-combobox__group') ? prev : entry.item;
const top = first === firstShown ? 0 : first.offsetTop;
const bottom = entry.item.offsetTop + entry.item.offsetHeight;
if (top < list.scrollTop) list.scrollTop = top;
else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
}
};
const open = () => {
if (isOpen() || input.disabled) return;
// Not enough room below and more above: the list opens upwards. A closed list that changes
// side gets there at once, so it always unrolls from the field. Measuring also settles the
// highlight where it was put, before the open list starts animating it
const rect = box.getBoundingClientRect();
const room = Math.min(parseFloat(getComputedStyle(list).maxHeight) || 280, list.scrollHeight);
const above = rect.bottom + room + 16 > innerHeight && rect.top > innerHeight / 2;
if (above !== root.classList.contains('is-above')) {
list.style.transition = 'none';
root.classList.toggle('is-above', above);
void list.offsetWidth;
list.style.transition = '';
}
root.classList.add('is-open');
input.setAttribute('aria-expanded', 'true');
};
// The whole list, lit on the current choice
const openAll = () => {
if (isOpen() || input.disabled) return;
render('');
const chosen = options.find(({ option }) => option.selected);
light(chosen && !chosen.disabled ? chosen : hits[0] || null);
open();
};
const close = () => {
resize?.cancel();
clearTimeout(announced);
if (!isOpen()) return;
root.classList.remove('is-open');
input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant');
};
// The value rolls to the new text: the old one goes up, the new one comes from below
let rolls = [];
const setText = (text, roll) => {
const before = input.value;
if (before === text) return;
input.value = text;
rolls.forEach((animation) => animation.cancel());
rolls = [];
// Read when the value rolls: read at mount, the style it computes would turn the field's first
// state (its label folded over a chosen value) into a transition
const pace = paceOf(root);
if (!roll || !before || reduceMotion || !pace) return;
ghost.textContent = before;
const timing = { duration: ROLL * pace, easing: ease };
const out = ghost.animate([{ transform: 'none' }, { transform: 'translateY(-110%)' }], { ...timing, fill: 'forwards' });
const done = () => {
if (rolls.includes(out)) ghost.textContent = '';
};
out.onfinish = done;
out.oncancel = () => (ghost.textContent = '');
rolls = [out, input.animate([{ transform: 'translateY(110%)' }, { transform: 'none' }], timing)];
};
// The field follows the real select
const sync = (roll) => {
options.forEach(({ option, item }) => item.setAttribute('aria-selected', String(option.selected)));
input.disabled = native.disabled;
root.classList.toggle('is-disabled', native.disabled);
input.setAttribute('aria-required', String(native.required));
if (typing) return;
const chosen = native.selectedOptions[0];
const text = !chosen || chosen.hidden ? '' : chosen.textContent.trim();
root.classList.toggle('is-empty', !text);
setText(text, roll);
};
// Hint and error share the line under the field
const say = (text) => {
if (!error) return;
error.textContent = text;
root.classList.add('is-invalid');
input.setAttribute('aria-invalid', 'true');
};
const clearError = () => {
root.classList.remove('is-invalid');
input.removeAttribute('aria-invalid');
};
const pick = (option) => {
if (!option || option.disabled || option.parentElement?.disabled) return;
const changed = !option.selected;
option.selected = true;
typing = false;
clearError();
sync(true);
close();
if (changed) native.dispatchEvent(new Event('change', { bubbles: true }));
};
const revert = () => {
typing = false;
sync(true);
};
// Leaving the field: the only match, or one named exactly, is taken; an empty field takes the
// empty option; anything else puts the last choice back and says why
const commit = () => {
if (!typing) return close();
const query = input.value.trim();
const q = fold(query).text;
if (!q) {
const blank = [...native.options].find((option) => option.value === '' && !option.disabled);
if (blank) pick(blank);
else revert();
close();
if (native.required && !native.value) say(word('required'));
return;
}
const exact = hits.find(({ folded, metaFolded }) => folded.text === q || metaFolded.text === q);
if (exact || hits.length === 1) return pick((exact || hits[0]).option);
const none = !empty.hidden; // nothing matches at all (a disabled match is not a typo)
revert();
close();
if (none) say(fill(word('nomatch'), { query }));
};
// Typing filters, lit on the best match
input.addEventListener('input', () => {
typing = true;
clearError();
root.classList.remove('is-empty');
light(render(input.value));
open();
}, { signal });
input.addEventListener('keydown', (event) => {
if (event.isComposing) return;
const moves = { ArrowDown: 1, ArrowUp: -1, PageDown: PAGE, PageUp: -PAGE };
if (event.key in moves) {
event.preventDefault();
if (!isOpen()) return openAll();
if (!shown.length) return;
const index = shown.indexOf(active);
const step = moves[event.key];
const next = index < 0 ? (step > 0 ? 0 : shown.length - 1) : Math.max(0, Math.min(shown.length - 1, index + step));
light(shown[next]);
} else if (event.key === 'Enter' && isOpen()) {
event.preventDefault();
if (active) pick(active.option);
else commit();
} else if (event.key === 'Tab' && isOpen() && active) {
pick(active.option);
} else if (event.key === 'Escape' && (isOpen() || typing)) {
event.preventDefault();
revert();
close();
}
}, { signal });
// A click in the field opens the whole list, with the text selected, ready to be typed over
let pointed = false;
input.addEventListener('pointerdown', () => (pointed = document.activeElement !== input), { signal });
input.addEventListener('mouseup', (event) => {
if (!pointed) return;
pointed = false;
event.preventDefault(); // keeps the selection made on focus
}, { signal });
input.addEventListener('focus', () => input.select(), { signal });
input.addEventListener('click', () => !isOpen() && openAll(), { signal });
input.addEventListener('blur', () => {
if (document.activeElement === input) return; // the window lost the focus, not the field
commit();
}, { signal });
// The rest of the box (label, padding, chevron) works the field: the focus stays in it and the
// wrapping <label> never sends the click to the hidden select
box.addEventListener('mousedown', (event) => event.target !== input && event.preventDefault(), { signal });
box.addEventListener('click', (event) => {
if (event.target === input) return;
event.preventDefault();
if (input.disabled) return;
if (toggle.contains(event.target) && isOpen()) {
revert();
return close();
}
if (document.activeElement !== input) input.focus();
openAll();
}, { signal });
const entryAt = (event) => entries.get(event.target.closest?.('.kodza-combobox__option'));
list.addEventListener('pointermove', (event) => {
const entry = entryAt(event);
if (entry && active !== entry && !entry.disabled) light(entry, false);
}, { signal });
list.addEventListener('click', (event) => {
const entry = entryAt(event);
if (entry && !entry.disabled) pick(entry.option);
}, { signal });
list.addEventListener('mousedown', (event) => event.preventDefault(), { signal });
// Sent without a choice: the field says it, not the browser's bubble on the hidden select
native.addEventListener('invalid', (event) => {
event.preventDefault();
say(native.validity.customError ? native.validationMessage : word('required'));
const first = native.form && [...native.form.elements].find((el) => el.willValidate && !el.validity.valid);
if (!native.form || first === native) input.focus();
}, { signal });
// The real select changed from elsewhere (a form reset, a script): follow it
native.addEventListener('change', () => {
typing = false;
sync(true);
}, { signal });
native.form?.addEventListener('reset', () => setTimeout(() => {
typing = false;
clearError();
sync(true);
}), { signal });
// The page changed the options (counts, disabled ones, a new list): build the list again and
// filter it as it was, with the value shown without a roll
let queued = false;
const observer = new MutationObserver(() => {
if (queued) return;
queued = true;
queueMicrotask(() => {
queued = false;
if (signal.aborted) return;
const was = active?.option;
build();
sync(false);
if (!isOpen()) return;
const best = render(typing ? input.value : '', false);
const again = shown.find(({ option }) => option === was);
light(again || best, false);
});
});
observer.observe(native, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['disabled', 'hidden', 'label', 'data-meta', 'required'] });
signal.addEventListener('abort', () => {
observer.disconnect();
clearTimeout(announced);
resize?.cancel();
rolls.forEach((animation) => animation.cancel());
});
render('', false);
sync(false);
}
// Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
// event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
// markup back. For themes and apps that swap content without a page load. Each running root
// keeps its markup as written under KODZA, shared by every Kodza script on the page: a Kodza
// component inside another one is saved as written, not half-started, and stopped with it. Two
// components on one root (a reveal on a stat card) both start, and stop together.
const KODZA = Symbol.for('kodza');
const RUNNING = Symbol.for(`kodza ${SELECTOR}`); // a root this component runs on, for every copy of this script
const instances = new WeakMap();
const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
const attrsOf = (el) => [...el.attributes].map((a) => [a.name, a.value]);
const put = (el, saved) => {
[...el.attributes].forEach((a) => el.removeAttribute(a.name));
saved.attrs.forEach(([name, value]) => el.setAttribute(name, value));
el.innerHTML = saved.html;
};
// The markup inside a root as written: Kodza components already running in it give back their own
const written = (root) => {
const all = [...root.querySelectorAll('*')];
if (!all.some((el) => el[KODZA])) return root.innerHTML;
const copy = root.cloneNode(true);
const twins = copy.querySelectorAll('*');
let last = null;
all.forEach((el, i) => {
if (!el[KODZA] || last?.contains(el)) return;
last = el;
put(twins[i], el[KODZA]);
});
return copy.innerHTML;
};
const init = (scope) => find(scope).forEach((root) => {
// Already running, maybe from another copy of this script. Marked is-ready with no Kodza component
// running on it, a root is a copy of a started one (a cloned slide): it is left as it is
if (root[RUNNING] || (root.classList.contains('is-ready') && !root[KODZA])) return;
// Rendered by React (data-kodza-manual): React starts it, with an init aimed at that markup once
// it has hydrated it. Any earlier start would change markup React has not taken over yet
const owner = root.closest('[data-kodza-manual]');
if (owner && !(scope && owner.contains(scope))) return;
const controller = new AbortController();
// Another Kodza component already running on this root: its markup as written is kept, and the
// two stop together
const before = root[KODZA];
const saved = { html: before ? before.html : written(root), attrs: before ? before.attrs : attrsOf(root), stop: () => destroy(root) };
if (before) before.above = saved;
instances.set(root, { controller, saved, before });
root[KODZA] = saved;
root[RUNNING] = true;
mount(root, controller.signal);
root.classList.add('is-ready');
});
const destroy = (scope) => find(scope).forEach((root) => {
const instance = instances.get(root);
if (!instance) return;
instances.delete(root);
instance.saved.above?.stop(); // a Kodza component started after it on the same root stops first
// The Kodza components running inside it stop first: once its markup is put back, their own
// script could not find them any more
root.querySelectorAll('*').forEach((el) => el[KODZA]?.stop());
instance.controller.abort();
delete root[RUNNING];
// Started on top of another one: that one stops next, and puts the markup back
if (instance.before) {
delete instance.before.above;
root[KODZA] = instance.before;
return instance.before.stop();
}
delete root[KODZA];
put(root, instance.saved);
});
document.addEventListener('kodza:init', (event) => init(event.detail?.root));
document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
init();
// Loaded in <head>, the script runs before the markup exists: once more when it is parsed
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
'use client';
// Kodza — Combobox, for React. From https://kodza.dev/components/combobox/
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Next to this file: combobox.css and combobox.js, the component's CSS and JS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
// The script changes this markup once React has rendered it (it adds layers, splits text, moves
// nodes), so React must not patch it afterwards. When a prop changes, the key gives React a fresh
// element: kodza:destroy stops the script on the old one, kodza:init starts it on the new one.
// data-kodza-manual keeps the script from starting it on its own: the effect below does, once
// React has hydrated the markup (on a page that streams in, the script can load first).
// Built from your data (tabs, rows, options)? Put what the markup is made of in the key, as plain
// values (ids, text, numbers): a change starts the script again on fresh markup. What the visitor
// changes (the open tab, the sort, what they type) is the script's: keep it out of the key, follow
// it with onClick or onInput on the root, and change it from your code as a visitor would (click
// the tab; set the field's value, then dispatch a bubbling input event).
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './combobox.css';
export type ComboboxProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "vars"> & {
/** Settings, as CSS variables on the root */
vars?: {
/** default: #ffffff */
"--kz-combobox-bg"?: string | number;
/** default: #0a0a0a */
"--kz-combobox-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.6) */
"--kz-combobox-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.14) */
"--kz-combobox-line"?: string | number;
/** default: rgba(10, 10, 10, 0.3) */
"--kz-combobox-line-hover"?: string | number;
/** default: rgba(10, 10, 10, 0.06) */
"--kz-combobox-soft"?: string | number;
/** the selected text: grey, not the browser blue — default: rgba(128, 128, 128, 0.35) */
"--kz-combobox-selection"?: string | number;
/** a message, never colour alone: set a red here if you want one — default: #0a0a0a */
"--kz-combobox-error"?: string | number;
/** default: 0 18px 40px -16px rgba(10, 10, 10, 0.3) */
"--kz-combobox-shadow"?: string | number;
/** default: 56px */
"--kz-combobox-height"?: string | number;
/** default: calc(12px * var(--kz-radius-scale, 1)) */
"--kz-combobox-radius"?: string | number;
/** default: 16px */
"--kz-combobox-pad"?: string | number;
/** default: var(--kz-field-width, 320px) */
"--kz-combobox-width"?: string | number;
/** the open list, at most: it scrolls past that — default: 280px */
"--kz-combobox-list-height"?: string | number;
/** default: 0.35s */
"--kz-combobox-duration"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-combobox-ease"?: string | number;
};
className?: string;
};
export function Combobox({ vars, className, style, ...rest }: ComboboxProps) {
const ref = useRef<ComponentRef<'div'>>(null);
const classes = ["kodza-combobox", className].filter(Boolean).join(' ');
// Everything the markup is made of: when it changes, React gets a fresh element (see the top)
const key = JSON.stringify([classes, vars, style, rest]);
// Starts the script on this element once React has rendered it, stops it when it goes
useEffect(() => {
const root = ref.current;
let live = true;
// @ts-ignore: a plain script, no types to find
import('./combobox.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
return (
<div key={key} {...rest} className={classes} data-kodza-manual style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<label className="kodza-combobox__box">
<span className="kodza-combobox__label">Country</span>
<select className="kodza-combobox__native" name="country" required defaultValue="">
<option value="" hidden />
<option value="ar" data-meta="AR">Argentina</option>
<option value="au" data-meta="AU">Australia</option>
<option value="at" data-meta="AT">Austria</option>
<option value="be" data-meta="BE">Belgium</option>
<option value="br" data-meta="BR">Brazil</option>
<option value="ca" data-meta="CA">Canada</option>
<option value="cl" data-meta="CL">Chile</option>
<option value="cn" data-meta="CN">China</option>
<option value="co" data-meta="CO">Colombia</option>
<option value="ci" data-meta="CI">Côte d’Ivoire</option>
<option value="cz" data-meta="CZ">Czechia</option>
<option value="dk" data-meta="DK">Denmark</option>
<option value="eg" data-meta="EG">Egypt</option>
<option value="ee" data-meta="EE">Estonia</option>
<option value="fi" data-meta="FI">Finland</option>
<option value="fr" data-meta="FR">France</option>
<option value="de" data-meta="DE">Germany</option>
<option value="gh" data-meta="GH">Ghana</option>
<option value="gr" data-meta="GR">Greece</option>
<option value="is" data-meta="IS">Iceland</option>
<option value="in" data-meta="IN">India</option>
<option value="id" data-meta="ID">Indonesia</option>
<option value="ie" data-meta="IE">Ireland</option>
<option value="it" data-meta="IT">Italy</option>
<option value="jp" data-meta="JP">Japan</option>
<option value="ke" data-meta="KE">Kenya</option>
<option value="mx" data-meta="MX">Mexico</option>
<option value="ma" data-meta="MA">Morocco</option>
<option value="nl" data-meta="NL">Netherlands</option>
<option value="nz" data-meta="NZ">New Zealand</option>
<option value="ng" data-meta="NG">Nigeria</option>
<option value="no" data-meta="NO">Norway</option>
<option value="pe" data-meta="PE">Peru</option>
<option value="pl" data-meta="PL">Poland</option>
<option value="pt" data-meta="PT">Portugal</option>
<option value="re" data-meta="RE">Réunion</option>
<option value="sn" data-meta="SN">Senegal</option>
<option value="sg" data-meta="SG">Singapore</option>
<option value="za" data-meta="ZA">South Africa</option>
<option value="kr" data-meta="KR">South Korea</option>
<option value="es" data-meta="ES">Spain</option>
<option value="se" data-meta="SE">Sweden</option>
<option value="ch" data-meta="CH">Switzerland</option>
<option value="tr" data-meta="TR">Türkiye</option>
<option value="gb" data-meta="GB">United Kingdom</option>
<option value="us" data-meta="US">United States</option>
<option value="uy" data-meta="UY">Uruguay</option>
<option value="vn" data-meta="VN">Vietnam</option>
</select>
</label>
<p className="kodza-combobox__note">
<span className="kodza-combobox__hint">Accents optional. Try “cote”.</span>
{' '}
<span className="kodza-combobox__error" aria-live="polite" />
</p>
</div>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.