| 1 | |
| 2 | |
| 3 | import { clsx } from "@/lib/cx"; |
| 4 | import { Card } from "@/components/ui"; |
| 5 | import type { ReactNode } from "react"; |
| 6 | |
| 7 | export function SearchFilterBar({ |
| 8 | children, |
| 9 | count, |
| 10 | total, |
| 11 | countLabel = "result", |
| 12 | onClear, |
| 13 | clearHref, |
| 14 | className, |
| 15 | }: { |
| 16 | children: ReactNode; |
| 17 | count?: number; |
| 18 | total?: number; |
| 19 | countLabel?: string; |
| 20 | onClear?: () => void; |
| 21 | clearHref?: string; |
| 22 | className?: string; |
| 23 | }) { |
| 24 | const showClear = !!(onClear || clearHref); |
| 25 | return ( |
| 26 | <Card className={clsx("p-4 sm:p-5", className)}> |
| 27 | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">{children}</div> |
| 28 | {(count != null || showClear) && ( |
| 29 | <div className="mt-3 flex flex-wrap items-center justify-between gap-2"> |
| 30 | {count != null && ( |
| 31 | <p className="label text-ash-2"> |
| 32 | {count.toLocaleString()} {countLabel} |
| 33 | {count !== 1 ? "s" : ""} |
| 34 | {total != null && count !== total |
| 35 | ? ` of ${total.toLocaleString()}` |
| 36 | : ""} |
| 37 | </p> |
| 38 | )} |
| 39 | {onClear && ( |
| 40 | <button |
| 41 | type="button" |
| 42 | onClick={onClear} |
| 43 | className="focus-ring label rounded-sm px-2 py-1 text-ash underline underline-offset-4 hover:text-paper" |
| 44 | > |
| 45 | Clear filters |
| 46 | </button> |
| 47 | )} |
| 48 | {clearHref && !onClear && ( |
| 49 | <a |
| 50 | href={clearHref} |
| 51 | className="focus-ring label rounded-sm px-2 py-1 text-ash underline underline-offset-4 hover:text-paper" |
| 52 | > |
| 53 | Clear filters |
| 54 | </a> |
| 55 | )} |
| 56 | </div> |
| 57 | )} |
| 58 | </Card> |
| 59 | ); |
| 60 | } |
| 61 | |
| 62 | export function FilterField({ |
| 63 | label, |
| 64 | span, |
| 65 | children, |
| 66 | }: { |
| 67 | label: string; |
| 68 | span?: "2"; |
| 69 | children: ReactNode; |
| 70 | }) { |
| 71 | return ( |
| 72 | <label className={clsx("block", span === "2" && "sm:col-span-2")}> |
| 73 | <span className="label mb-1.5 block text-ash">{label}</span> |
| 74 | {children} |
| 75 | </label> |
| 76 | ); |
| 77 | } |
| 78 | |
| 79 | export function FilterInput({ |
| 80 | name, |
| 81 | value, |
| 82 | defaultValue, |
| 83 | onChange, |
| 84 | placeholder, |
| 85 | "aria-label": ariaLabel, |
| 86 | }: { |
| 87 | name?: string; |
| 88 | value?: string; |
| 89 | defaultValue?: string; |
| 90 | onChange?: (v: string) => void; |
| 91 | placeholder?: string; |
| 92 | "aria-label"?: string; |
| 93 | }) { |
| 94 | const className = |
| 95 | "focus-ring w-full min-h-[52px] rounded-[3px] border border-line bg-ink px-3 py-2 text-[18px] leading-snug text-paper placeholder:text-ash-2"; |
| 96 | if (onChange !== undefined) { |
| 97 | return ( |
| 98 | <input |
| 99 | type="search" |
| 100 | value={value ?? ""} |
| 101 | onChange={(e) => onChange(e.target.value)} |
| 102 | placeholder={placeholder} |
| 103 | aria-label={ariaLabel} |
| 104 | className={className} |
| 105 | /> |
| 106 | ); |
| 107 | } |
| 108 | return ( |
| 109 | <input |
| 110 | type="search" |
| 111 | name={name} |
| 112 | defaultValue={defaultValue ?? ""} |
| 113 | placeholder={placeholder} |
| 114 | aria-label={ariaLabel} |
| 115 | className={className} |
| 116 | /> |
| 117 | ); |
| 118 | } |
| 119 | |
| 120 | export function FilterSelect({ |
| 121 | name, |
| 122 | value, |
| 123 | defaultValue, |
| 124 | onChange, |
| 125 | "aria-label": ariaLabel, |
| 126 | options, |
| 127 | }: { |
| 128 | name?: string; |
| 129 | value?: string; |
| 130 | defaultValue?: string; |
| 131 | onChange?: (v: string) => void; |
| 132 | "aria-label"?: string; |
| 133 | options: Array<{ value: string; label: string }>; |
| 134 | }) { |
| 135 | const className = |
| 136 | "focus-ring w-full min-h-[44px] rounded-[3px] border border-line bg-ink px-3 py-2 font-mono text-[13px] text-paper"; |
| 137 | if (onChange !== undefined) { |
| 138 | return ( |
| 139 | <select |
| 140 | value={value ?? ""} |
| 141 | onChange={(e) => onChange(e.target.value)} |
| 142 | aria-label={ariaLabel} |
| 143 | className={className} |
| 144 | > |
| 145 | {options.map((o) => ( |
| 146 | <option key={o.value} value={o.value} className="bg-surface text-paper"> |
| 147 | {o.label} |
| 148 | </option> |
| 149 | ))} |
| 150 | </select> |
| 151 | ); |
| 152 | } |
| 153 | return ( |
| 154 | <select |
| 155 | name={name} |
| 156 | defaultValue={defaultValue ?? ""} |
| 157 | aria-label={ariaLabel} |
| 158 | className={className} |
| 159 | > |
| 160 | {options.map((o) => ( |
| 161 | <option key={o.value} value={o.value} className="bg-surface text-paper"> |
| 162 | {o.label} |
| 163 | </option> |
| 164 | ))} |
| 165 | </select> |
| 166 | ); |
| 167 | } |
| 168 | |
| 169 | export function FilterSubmit({ label = "Filter" }: { label?: string }) { |
| 170 | return ( |
| 171 | <div className="flex items-end"> |
| 172 | <button |
| 173 | type="submit" |
| 174 | className="focus-ring label inline-flex min-h-[44px] w-full items-center justify-center rounded-[3px] border border-line bg-surface px-4 text-paper hover:bg-graphite" |
| 175 | > |
| 176 | {label} |
| 177 | </button> |
| 178 | </div> |
| 179 | ); |
| 180 | } |