diff --git a/src/app/App.tsx b/src/app/App.tsx index e9c571a..9eaf250 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -22,6 +22,7 @@ import type { SearchChoice, SearchState, } from '../features/search/types'; +import { SelectField, type SelectOptionGroup } from '../shared/ui/SelectField'; const DEFAULT_BBOX: BBox = { south: 52.191, @@ -52,7 +53,7 @@ const customRadiusChoice = 'custom-radius'; type RequestStatus = 'idle' | 'loading' | 'success' | 'error'; type NearbyChoice = typeof noNearbyChoice | typeof customChoice | string; -type RadiusChoice = number | typeof customRadiusChoice; +type RadiusChoice = string; export function App() { const [category, setCategory] = useState(searchPresetCategories[0]); @@ -66,7 +67,7 @@ export function App() { const [nearbyChoice, setNearbyChoice] = useState(noNearbyChoice); const [nearbyCustomKey, setNearbyCustomKey] = useState(''); const [nearbyCustomValue, setNearbyCustomValue] = useState(''); - const [radiusChoice, setRadiusChoice] = useState(100); + const [radiusChoice, setRadiusChoice] = useState('100'); const [nearbyRadius, setNearbyRadius] = useState(100); const [areaMode, setAreaMode] = useState('bbox'); const [bbox, setBBox] = useState(DEFAULT_BBOX); @@ -90,6 +91,36 @@ export function App() { const commonFilters = selectedPreset?.commonFilters ?? []; + const nearbyOptionGroups = useMemo(() => { + const suggestedPresetIds = selectedPreset?.usefulNearbyPresetIds ?? []; + const suggestedOptions = suggestedPresetIds.map((presetId) => { + const preset = getPresetById(presetId); + + return { + value: preset.id, + label: preset.label, + }; + }); + + return [ + ...(suggestedOptions.length > 0 + ? [ + { + label: 'Suggested for this search', + options: suggestedOptions, + }, + ] + : []), + ...searchPresetCategories.map((item) => ({ + label: item, + options: getPresetsByCategory(item).map((preset) => ({ + value: preset.id, + label: preset.label, + })), + })), + ]; + }, [selectedPreset]); + useEffect(() => { setSelectedFilterIds([]); setExtraFilters([]); @@ -248,7 +279,7 @@ export function App() { } const radius = Number(value); - setRadiusChoice(radius); + setRadiusChoice(value); setNearbyRadius(radius); } @@ -260,6 +291,10 @@ export function App() { } } + function handleRadiusStep(direction: 1 | -1) { + setNearbyRadius((current) => Math.max(1, current + direction)); + } + function handleClearResults() { setResults([]); setSelectedResultId(null); @@ -299,35 +334,28 @@ export function App() {

Find

{searchPresets.length} presets -
- - -
+ ({ value: item, label: item }))} + onChange={handleCategoryChange} + />
- - + options={[ + ...categoryPresets.map((preset) => ({ + value: preset.id, + label: preset.label, + })), + { value: customChoice, label: 'Custom tag' }, + ]} + onChange={(value) => setChoice(value as SearchChoice)} + /> {selectedPreset ?

{selectedPreset.description}

: null}
@@ -419,45 +447,23 @@ export function App() {

Near

{nearbyChoice !== noNearbyChoice ? ( - ) : null}
-
- - -
+ {nearbyChoice === customChoice ? (
@@ -483,31 +489,49 @@ export function App() { ) : null}
-
- + options={[ + ...radiusOptions.map((radius) => ({ + value: String(radius), + label: `${radius} meters`, + })), + { value: customRadiusChoice, label: 'Custom' }, + ]} + onChange={handleRadiusChoiceChange} + /> {radiusChoice === customRadiusChoice ? ( - handleCustomRadiusChange(event.target.value)} - /> +
+ handleCustomRadiusChange(event.target.value)} + /> +
+
+
) : null}
@@ -536,20 +560,13 @@ export function App() { {areaMode === 'namedArea' ? ( -
- - -
+ ({ value: area, label: area }))} + onChange={setNamedArea} + /> ) : (

Using the visible map viewport. Zoom in if the area is too large. diff --git a/src/shared/ui/SelectField.tsx b/src/shared/ui/SelectField.tsx new file mode 100644 index 0000000..a8895af --- /dev/null +++ b/src/shared/ui/SelectField.tsx @@ -0,0 +1,144 @@ +import { useEffect, useId, useRef, useState } from 'react'; + +export type SelectOption = { + value: string; + label: string; +}; + +export type SelectOptionGroup = { + label: string; + options: SelectOption[]; +}; + +type SelectFieldProps = { + id?: string; + label: string; + value: string; + options: SelectOption[]; + groups?: SelectOptionGroup[]; + disabled?: boolean; + onChange: (value: string) => void; +}; + +export function SelectField({ + id, + label, + value, + options, + groups = [], + disabled = false, + onChange, +}: SelectFieldProps) { + const generatedId = useId(); + const fieldId = id ?? generatedId; + const [isOpen, setIsOpen] = useState(false); + const rootRef = useRef(null); + const selectedOption = findOption(value, options, groups); + + useEffect(() => { + function handlePointerDown(event: PointerEvent) { + if (!rootRef.current?.contains(event.target as Node)) { + setIsOpen(false); + } + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + setIsOpen(false); + } + } + + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, []); + + function handleSelect(nextValue: string) { + onChange(nextValue); + setIsOpen(false); + } + + return ( +

+ + + {isOpen ? ( +
+ {options.map((option) => ( + + ))} + {groups.map((group) => ( +
+
{group.label}
+ {group.options.map((option) => ( + + ))} +
+ ))} +
+ ) : null} +
+ ); +} + +function SelectItem({ + option, + isSelected, + onSelect, +}: { + option: SelectOption; + isSelected: boolean; + onSelect: (value: string) => void; +}) { + return ( + + ); +} + +function findOption( + value: string, + options: SelectOption[], + groups: SelectOptionGroup[], +): SelectOption | undefined { + return ( + options.find((option) => option.value === value) ?? + groups.flatMap((group) => group.options).find((option) => option.value === value) + ); +} diff --git a/src/styles.css b/src/styles.css index 1ef0c72..efd3f89 100644 --- a/src/styles.css +++ b/src/styles.css @@ -26,18 +26,30 @@ select { } button { - border: 1px solid #b8c2b5; - border-radius: 6px; - background: #ffffff; + border: 1px solid #acb8ad; + border-radius: 7px; + background: linear-gradient(#ffffff, #f4f7f3); color: #17211c; cursor: pointer; min-height: 38px; padding: 0.55rem 0.8rem; + box-shadow: 0 1px 1px rgb(23 33 28 / 6%); + transition: + background 120ms ease, + border-color 120ms ease, + box-shadow 120ms ease, + transform 120ms ease; } button:hover:not(:disabled) { - border-color: #5d7f86; - background: #f4f8f7; + border-color: #6b8c86; + background: linear-gradient(#ffffff, #eef6f3); + box-shadow: 0 2px 7px rgb(23 33 28 / 10%); +} + +button:active:not(:disabled) { + transform: translateY(1px); + box-shadow: 0 1px 2px rgb(23 33 28 / 8%); } button:disabled { @@ -48,12 +60,53 @@ button:disabled { input, select { width: 100%; - border: 1px solid #b8c2b5; - border-radius: 6px; + border: 1px solid #acb8ad; + border-radius: 7px; background: #ffffff; color: #17211c; min-height: 38px; padding: 0.5rem 0.65rem; + box-shadow: + inset 0 1px 1px rgb(23 33 28 / 4%), + 0 1px 0 rgb(255 255 255 / 80%); + transition: + border-color 120ms ease, + box-shadow 120ms ease, + background 120ms ease; +} + +input:hover:not(:disabled), +select:hover:not(:disabled) { + border-color: #7f9588; + background: #fcfdfb; +} + +input:focus, +select:focus, +button:focus-visible { + outline: none; + border-color: #226a5d; + box-shadow: + 0 0 0 3px rgb(34 106 93 / 16%), + inset 0 1px 1px rgb(23 33 28 / 4%); +} + +input:disabled, +select:disabled { + cursor: not-allowed; + background: #eff2ee; + color: #8a958d; +} + +input[type="number"] { + padding-right: 2rem; + appearance: textfield; +} + +input[type="number"]::-webkit-outer-spin-button, +input[type="number"]::-webkit-inner-spin-button { + margin: 0; + opacity: 0; } label, @@ -107,6 +160,132 @@ a { padding: 0; } +.select-field { + position: relative; + display: flex; + flex-direction: column; + gap: 0.42rem; + min-width: 0; +} + +.select-trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + width: 100%; + min-height: 40px; + border-color: #a9b7ae; + background: + linear-gradient(180deg, #ffffff 0%, #f6f8f5 100%); + padding: 0.5rem 0.65rem 0.5rem 0.75rem; + text-align: left; +} + +.select-trigger span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.select-trigger[aria-expanded="true"] { + border-color: #226a5d; + background: #ffffff; + box-shadow: + 0 0 0 3px rgb(34 106 93 / 16%), + 0 8px 24px rgb(23 33 28 / 10%); +} + +.select-chevron { + width: 0.56rem; + height: 0.56rem; + border-right: 2px solid #52635a; + border-bottom: 2px solid #52635a; + transform: translateY(-2px) rotate(45deg); + flex: 0 0 auto; +} + +.select-trigger[aria-expanded="true"] .select-chevron { + transform: translateY(2px) rotate(225deg); +} + +.select-menu { + position: absolute; + z-index: 1100; + top: calc(100% + 0.35rem); + left: 0; + right: 0; + display: grid; + gap: 0.18rem; + max-height: min(320px, 55vh); + overflow: auto; + border: 1px solid #9db0a6; + border-radius: 8px; + background: #ffffff; + padding: 0.35rem; + box-shadow: + 0 18px 38px rgb(23 33 28 / 18%), + 0 2px 8px rgb(23 33 28 / 10%); +} + +.select-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + width: 100%; + min-height: 34px; + border: 0; + border-radius: 6px; + background: transparent; + padding: 0.42rem 0.55rem; + text-align: left; + box-shadow: none; +} + +.select-option:hover, +.select-option:focus-visible { + background: #eef6f3; + box-shadow: none; +} + +.select-option-selected { + background: #dcece7; + color: #174d44; + font-weight: 720; +} + +.select-check { + width: 0.42rem; + height: 0.72rem; + border-right: 2px solid #226a5d; + border-bottom: 2px solid #226a5d; + transform: rotate(45deg); + flex: 0 0 auto; +} + +.select-group { + display: grid; + gap: 0.18rem; +} + +.select-group + .select-group, +.select-option + .select-group { + margin-top: 0.25rem; + border-top: 1px solid #e1e6df; + padding-top: 0.3rem; +} + +.select-group-label { + color: #66726a; + padding: 0.35rem 0.55rem 0.15rem; + font-size: 0.74rem; + font-weight: 760; + letter-spacing: 0; + text-transform: uppercase; +} + .search-section { display: grid; gap: 0.75rem; @@ -134,21 +313,25 @@ a { line-height: 1.35; } -.text-button { - min-height: auto; - border: 0; - background: transparent; - color: #0b6977; - padding: 0.15rem 0; - font-size: 0.82rem; - font-weight: 700; +.compact-button { + min-height: 30px; + border-color: #c3d0c7; + border-radius: 6px; + background: linear-gradient(#ffffff, #f3f7f4); + color: #29433b; + padding: 0.32rem 0.58rem; + font-size: 0.78rem; + font-weight: 720; + box-shadow: + 0 1px 2px rgb(23 33 28 / 6%), + inset 0 1px 0 rgb(255 255 255 / 70%); } -.text-button:hover:not(:disabled) { - border-color: transparent; - background: transparent; +.compact-button:hover:not(:disabled) { + border-color: #7f9588; + background: linear-gradient(#ffffff, #e9f3ef); color: #174d44; - text-decoration: underline; + box-shadow: 0 2px 6px rgb(23 33 28 / 9%); } .field-note { @@ -165,6 +348,70 @@ a { display: grid; grid-template-columns: minmax(0, 1fr) minmax(96px, 0.55fr); gap: 0.5rem; + align-items: end; +} + +.number-stepper { + position: relative; +} + +.number-stepper input { + min-height: 40px; +} + +.stepper-buttons { + position: absolute; + top: 4px; + right: 4px; + bottom: 4px; + display: grid; + width: 1.35rem; + overflow: hidden; + border-left: 1px solid #d5ded7; + border-radius: 0 5px 5px 0; + background: #f2f6f3; +} + +.stepper-button { + position: relative; + min-height: 0; + border: 0; + border-radius: 0; + background: transparent; + padding: 0; + box-shadow: none; +} + +.stepper-button:hover:not(:disabled) { + border: 0; + background: #e4eee8; + box-shadow: none; +} + +.stepper-button:focus-visible { + border: 0; + box-shadow: inset 0 0 0 2px rgb(34 106 93 / 28%); +} + +.stepper-button::before { + content: ""; + position: absolute; + left: 50%; + width: 0; + height: 0; + transform: translateX(-50%); + border-left: 4px solid transparent; + border-right: 4px solid transparent; +} + +.stepper-up::before { + top: 7px; + border-bottom: 5px solid #52635a; +} + +.stepper-down::before { + bottom: 7px; + border-top: 5px solid #52635a; } .checkbox-grid { @@ -177,17 +424,68 @@ a { display: flex; align-items: center; gap: 0.45rem; - border: 1px solid #d7ded5; - border-radius: 6px; - background: #ffffff; + border: 1px solid #c8d2ca; + border-radius: 7px; + background: linear-gradient(#ffffff, #f7f9f6); min-height: 36px; padding: 0.42rem 0.55rem; font-weight: 560; + cursor: pointer; + transition: + border-color 120ms ease, + background 120ms ease, + box-shadow 120ms ease; +} + +.checkbox-grid label:hover { + border-color: #7f9588; + background: #f3f9f6; } .checkbox-grid input { - width: auto; - min-height: auto; + position: relative; + flex: 0 0 1rem; + width: 1rem !important; + min-width: 1rem; + max-width: 1rem; + height: 1rem; + min-height: 1rem; + margin: 0; + border: 1px solid #8ea098; + border-radius: 4px; + appearance: none; + background: #ffffff; + padding: 0; + box-shadow: inset 0 1px 1px rgb(23 33 28 / 8%); +} + +.checkbox-grid input::before { + content: ""; + position: absolute; + left: 50%; + top: 46%; + width: 0.28rem; + height: 0.52rem; + border-right: 2px solid #ffffff; + border-bottom: 2px solid #ffffff; + transform: translate(-50%, -50%) rotate(45deg) scale(0); + transform-origin: center; + transition: transform 120ms ease; +} + +.checkbox-grid input:checked { + border-color: #226a5d; + background: #226a5d; +} + +.checkbox-grid input:checked::before { + transform: translate(-50%, -50%) rotate(45deg) scale(1); +} + +.checkbox-grid label:has(input:checked) { + border-color: #5f9488; + background: #e5f2ee; + box-shadow: inset 0 0 0 1px rgb(34 106 93 / 16%); } .active-filter-list { @@ -233,17 +531,67 @@ a { display: flex; align-items: center; gap: 0.45rem; - border: 1px solid #b8c2b5; - border-radius: 6px; - background: #ffffff; - min-height: 38px; - padding: 0.45rem 0.55rem; - font-weight: 560; + border: 1px solid #c8d2ca; + border-radius: 7px; + background: linear-gradient(#ffffff, #f7f9f6); + min-height: 40px; + padding: 0.45rem 0.6rem; + font-weight: 620; + cursor: pointer; + transition: + border-color 120ms ease, + background 120ms ease, + box-shadow 120ms ease; +} + +.segmented-control label:hover { + border-color: #7f9588; + background: #f3f9f6; } .segmented-control input { - width: auto; - min-height: auto; + position: relative; + flex: 0 0 1.05rem; + width: 1.05rem !important; + min-width: 1.05rem; + max-width: 1.05rem; + height: 1.05rem; + min-height: 1.05rem; + margin: 0; + border: 1px solid #8ea098; + border-radius: 50%; + appearance: none; + background: #ffffff; + padding: 0; + box-shadow: inset 0 1px 1px rgb(23 33 28 / 8%); +} + +.segmented-control input::before { + content: ""; + position: absolute; + left: 50%; + top: 50%; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: #ffffff; + transform: translate(-50%, -50%) scale(0); + transition: transform 120ms ease; +} + +.segmented-control input:checked { + border-color: #226a5d; + background: #226a5d; +} + +.segmented-control input:checked::before { + transform: translate(-50%, -50%) scale(1); +} + +.segmented-control label:has(input:checked) { + border-color: #5f9488; + background: #e5f2ee; + box-shadow: inset 0 0 0 1px rgb(34 106 93 / 16%); } .viewport-note { @@ -262,14 +610,17 @@ a { .primary-button { border-color: #226a5d; - background: #226a5d; + background: linear-gradient(180deg, #2b7a6c 0%, #226a5d 100%); color: #ffffff; font-weight: 700; + box-shadow: + 0 2px 7px rgb(34 106 93 / 22%), + inset 0 1px 0 rgb(255 255 255 / 16%); } .primary-button:hover:not(:disabled) { border-color: #174d44; - background: #174d44; + background: linear-gradient(180deg, #2b7368 0%, #174d44 100%); } .query-panel, @@ -347,6 +698,52 @@ a { height: 100%; } +.map-view .leaflet-control-zoom { + overflow: hidden; + border: 1px solid #a7b6ad; + border-radius: 8px; + box-shadow: + 0 10px 24px rgb(23 33 28 / 14%), + 0 2px 6px rgb(23 33 28 / 10%); +} + +.map-view .leaflet-control-zoom a { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border: 0; + border-bottom: 1px solid #d5ded7; + background: linear-gradient(#ffffff, #f3f7f4); + color: #29433b; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + font-size: 1.25rem; + font-weight: 760; + line-height: 1; + text-decoration: none; + transition: + background 120ms ease, + color 120ms ease, + box-shadow 120ms ease; +} + +.map-view .leaflet-control-zoom a:last-child { + border-bottom: 0; +} + +.map-view .leaflet-control-zoom a:hover, +.map-view .leaflet-control-zoom a:focus-visible { + background: linear-gradient(#ffffff, #e6f1ed); + color: #174d44; + box-shadow: inset 0 0 0 2px rgb(34 106 93 / 14%); +} + +.map-view .leaflet-control-zoom a.leaflet-disabled { + background: #eef2ee; + color: #99a59d; + cursor: not-allowed; +} + .result-marker { display: grid; place-items: center;