Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions frontend/src/components/SearchBar.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,26 @@
/* The label sits inside the box with its field, so the two read as one
control. On wide screens the boxes join into one bar (SearchPage.css). */
.search-bar__field {
display: flex;
flex-direction: column;
padding: 8px 14px 6px;
/* The field is stretched over the whole box (both grid rows) and given
the padding itself, so a click anywhere in the box, also on the label
or the edge, lands on the field and opens it. The label sits in the
same cell underneath, in the space the field's top padding leaves. */
--label-size: 13px;
--label-line: 1.3;
display: grid;
grid-template-rows: auto 36px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--code-bg);
transition: border-color 0.15s;
transition:
border-color 0.15s,
background-color 0.15s;

/* The tint also shows on wide screens, where the joined bar keeps the
border color (SearchPage.css) and the border alone wouldn't change. */
&:hover {
border-color: var(--accent-border);
background: color-mix(in srgb, var(--accent-bg) 60%, var(--code-bg));
}

/* The box shows focus for the field inside it, also when the mouse
Expand All @@ -28,18 +38,21 @@
}

label {
font-size: 13px;
grid-area: 1 / 1;
padding: 8px 14px 0;
font-size: var(--label-size);
font-weight: 600;
line-height: 1.3;
line-height: var(--label-line);
color: var(--text);
}

input,
select {
grid-area: 1 / 1 / 3 / 2;
width: 100%;
height: 30px;
min-width: 0;
box-sizing: border-box;
padding: 0;
padding: calc(8px + var(--label-size) * var(--label-line)) 14px 6px;
border: 0;
font: inherit;
font-size: 16px;
Expand All @@ -48,6 +61,13 @@
outline: none;
}

/* Selects and date fields open a list or picker, so they show it is
clickable. The text search keeps its text cursor. */
select,
input[type='date'] {
cursor: pointer;
}

input::placeholder {
color: var(--text);
opacity: 0.8;
Expand Down
61 changes: 54 additions & 7 deletions frontend/src/components/SearchFilters.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,59 @@
import { useQuery } from '@apollo/client/react'
import { useRef, useState } from 'react'
import { useEffect, useRef, useState, type ChangeEvent } from 'react'
import { FILTER_OPTIONS_QUERY } from '../graphql/queries'
import { createRetry } from '../utils/create-retry'
import { formatCountry } from '../utils/format-sighting'
import './SearchFilters.css'

interface DateInputProps {
id: string
value: string
min?: string
max?: string
onCommit: (value: string) => void
}

/**
* A date field that tolerates typing. While the user types, the browser
* reports "" for an incomplete date and a valid date for a year like
* "0002". Feeding either back into a controlled input wipes or rewrites
* what is being typed, so the input is uncontrolled and only complete dates
* (four-digit year) or a real clear reach the URL state.
*/
function DateInput({ id, value, min, max, onCommit }: DateInputProps) {
const ref = useRef<HTMLInputElement>(null)

// Keeps the field in step with changes made elsewhere, such as removing
// the filter chip, without touching it while the user is mid-edit.
useEffect(() => {
const input = ref.current
if (input && input.value !== value && !input.validity.badInput) {
input.value = value
}
}, [value])

function handleChange(event: ChangeEvent<HTMLInputElement>) {
const { value: next, validity } = event.currentTarget
if (next === '') {
if (!validity.badInput) onCommit('')
return
}
if (Number(next.slice(0, 4)) >= 1000) onCommit(next)
}

return (
<input
ref={ref}
id={id}
type="date"
defaultValue={value}
min={min}
max={max}
onChange={handleChange}
/>
)
}

export interface SearchFiltersValue {
shape: string
country: string
Expand Down Expand Up @@ -122,22 +171,20 @@ export default function SearchFilters({
</div>
<div className="search-bar__field">
<label htmlFor="filter-from">From</label>
<input
<DateInput
id="filter-from"
type="date"
value={filters.from}
max={filters.to || undefined}
onChange={(event) => onChange('from', event.target.value)}
onCommit={(value) => onChange('from', value)}
/>
</div>
<div className="search-bar__field">
<label htmlFor="filter-to">To</label>
<input
<DateInput
id="filter-to"
type="date"
value={filters.to}
min={filters.from || undefined}
onChange={(event) => onChange('to', event.target.value)}
onCommit={(value) => onChange('to', value)}
/>
</div>
</div>
Expand Down
Loading