product and sales fixers
This commit is contained in:
@@ -22,6 +22,8 @@ interface FilterBarProps {
|
||||
advancedActive?: boolean
|
||||
/** Shown as a "Clear advanced filters" action inside the popover when provided. */
|
||||
onClearAdvanced?: () => void
|
||||
/** Puts the search box on the left and groups `children` + "Advanced" together on the right. */
|
||||
searchFirst?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -33,59 +35,76 @@ interface FilterBarProps {
|
||||
*/
|
||||
export function FilterBar({
|
||||
search, onSearchChange, searchPlaceholder = "Search…", searchAriaLabel,
|
||||
children, advanced, advancedActive, onClearAdvanced, className,
|
||||
children, advanced, advancedActive, onClearAdvanced, searchFirst, className,
|
||||
}: FilterBarProps) {
|
||||
const searchBox = (
|
||||
<div className="relative w-full lg:max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-10 pl-9 pr-9"
|
||||
aria-label={searchAriaLabel ?? searchPlaceholder}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchChange("")}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const advancedPopover = advanced && (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm" className="relative shrink-0">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
Advanced
|
||||
{advancedActive && (
|
||||
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-primary" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-72 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
{advanced}
|
||||
{onClearAdvanced && (
|
||||
<Button variant="ghost" size="sm" onClick={onClearAdvanced} className="self-start">
|
||||
Clear advanced filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
if (searchFirst) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between", className)}>
|
||||
{searchBox}
|
||||
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||
{children}
|
||||
{advancedPopover}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3 lg:flex-row lg:items-center", className)}>
|
||||
{children}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
|
||||
<div className="relative w-full lg:max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-10 pl-9 pr-9"
|
||||
aria-label={searchAriaLabel ?? searchPlaceholder}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchChange("")}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{advanced && (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm" className="relative shrink-0">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
Advanced
|
||||
{advancedActive && (
|
||||
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-primary" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-72 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
{advanced}
|
||||
{onClearAdvanced && (
|
||||
<Button variant="ghost" size="sm" onClick={onClearAdvanced} className="self-start">
|
||||
Clear advanced filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
{searchBox}
|
||||
{advancedPopover}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { ChevronDown, Search } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
export interface SuggestOption {
|
||||
value: string
|
||||
label: string
|
||||
/** Optional secondary text shown on the right of the suggestion (e.g. a price). */
|
||||
hint?: string
|
||||
}
|
||||
|
||||
interface SuggestInputProps {
|
||||
options: SuggestOption[]
|
||||
/** Called with the picked option's value; the box then clears for the next entry. */
|
||||
onSelect: (value: string) => void
|
||||
placeholder?: string
|
||||
emptyText?: string
|
||||
maxResults?: number
|
||||
/** Enter with text but no suggestion to pick (e.g. a barcode from a USB scanner, which types
|
||||
* the code then presses Enter) — receives the typed text; the box then clears. */
|
||||
onEnterWithoutMatch?: (text: string) => void
|
||||
autoFocus?: boolean
|
||||
/** Also works as a dropdown: clicking the box (or its arrow, or pressing ↓) lists every option
|
||||
* before anything is typed. Off by default, so plain type-ahead boxes stay quiet on focus. */
|
||||
browsable?: boolean
|
||||
/** With `browsable`: after a pick the box stays focused, keeps its (selected) search text and
|
||||
* the list stays open, for adding several options in a row. Click away or press Esc to close. */
|
||||
keepOpenOnSelect?: boolean
|
||||
className?: string
|
||||
"aria-label"?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-ahead "add" box: shows no list until the user types, then suggests matching options
|
||||
* below the input. Every typed word must appear in the label (so "bolt 10" finds
|
||||
* "BLT-10 — Hex bolt 10mm"); labels starting with the query rank first. Picking one fires
|
||||
* `onSelect` and clears the box, so it works as a quick "add another" field rather than a
|
||||
* value holder.
|
||||
*/
|
||||
export function SuggestInput({
|
||||
options,
|
||||
onSelect,
|
||||
placeholder = "Type to search…",
|
||||
emptyText = "No matches.",
|
||||
maxResults = 8,
|
||||
onEnterWithoutMatch,
|
||||
autoFocus,
|
||||
browsable = false,
|
||||
keepOpenOnSelect = false,
|
||||
className,
|
||||
...rest
|
||||
}: SuggestInputProps) {
|
||||
const [query, setQuery] = React.useState("")
|
||||
const [open, setOpen] = React.useState(false)
|
||||
// Set only by an explicit click/arrow/↓ (never by plain focus), so an autoFocused box doesn't
|
||||
// pop its full list open on page load.
|
||||
const [browsing, setBrowsing] = React.useState(false)
|
||||
const [highlight, setHighlight] = React.useState(0)
|
||||
const listId = React.useId()
|
||||
const listRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const matches = React.useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return browsing ? options : []
|
||||
const words = q.split(/\s+/)
|
||||
return options
|
||||
.filter((o) => {
|
||||
const label = o.label.toLowerCase()
|
||||
return words.every((w) => label.includes(w))
|
||||
})
|
||||
.sort((a, b) => Number(b.label.toLowerCase().startsWith(q)) - Number(a.label.toLowerCase().startsWith(q)))
|
||||
.slice(0, maxResults)
|
||||
}, [options, query, maxResults, browsing])
|
||||
|
||||
const showList = open && (query.trim().length > 0 || browsing)
|
||||
|
||||
function openBrowse() {
|
||||
if (!browsable) return
|
||||
setBrowsing(true)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
function close() {
|
||||
setOpen(false)
|
||||
setBrowsing(false)
|
||||
}
|
||||
|
||||
// Keep the keyboard-highlighted option visible when arrowing through a long browse list.
|
||||
React.useEffect(() => {
|
||||
listRef.current?.querySelector(`[data-idx="${highlight}"]`)?.scrollIntoView({ block: "nearest" })
|
||||
}, [highlight])
|
||||
|
||||
// The list is portalled to <body> with fixed positioning so it isn't clipped when the box sits
|
||||
// inside a scroll container (e.g. a table wrapped in overflow-x-auto) — so track the box's
|
||||
// on-screen position while it's open.
|
||||
const wrapperRef = React.useRef<HTMLDivElement>(null)
|
||||
const [rect, setRect] = React.useState<DOMRect | null>(null)
|
||||
React.useLayoutEffect(() => {
|
||||
if (!showList) return
|
||||
const update = () => setRect(wrapperRef.current?.getBoundingClientRect() ?? null)
|
||||
update()
|
||||
window.addEventListener("scroll", update, true)
|
||||
window.addEventListener("resize", update)
|
||||
return () => {
|
||||
window.removeEventListener("scroll", update, true)
|
||||
window.removeEventListener("resize", update)
|
||||
}
|
||||
}, [showList])
|
||||
|
||||
// The box can also move without any scroll/resize — e.g. a pick adds a table row above it and
|
||||
// pushes it down — so re-measure after every render too (only storing a real change, so this
|
||||
// can't loop).
|
||||
React.useLayoutEffect(() => {
|
||||
if (!showList) return
|
||||
const next = wrapperRef.current?.getBoundingClientRect()
|
||||
if (next && (next.bottom !== rect?.bottom || next.left !== rect?.left || next.width !== rect?.width)) setRect(next)
|
||||
})
|
||||
|
||||
function choose(option: SuggestOption) {
|
||||
onSelect(option.value)
|
||||
if (browsable && keepOpenOnSelect) {
|
||||
// Keep the search text (and its filtered list) so similar items can be added one after
|
||||
// another; the text is selected, so simply typing replaces it with a new search. The
|
||||
// just-picked option stays highlighted for a quick repeat.
|
||||
setBrowsing(true)
|
||||
setOpen(true)
|
||||
setHighlight(Math.max(matches.findIndex((o) => o.value === option.value), 0))
|
||||
wrapperRef.current?.querySelector("input")?.select()
|
||||
} else {
|
||||
setQuery("")
|
||||
close()
|
||||
setHighlight(0)
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setOpen(true)
|
||||
if (!showList) {
|
||||
openBrowse()
|
||||
setHighlight(0)
|
||||
return
|
||||
}
|
||||
setHighlight((h) => Math.min(h + 1, Math.max(matches.length - 1, 0)))
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setHighlight((h) => Math.max(h - 1, 0))
|
||||
} else if (e.key === "Enter") {
|
||||
// Never submit a surrounding form from here — Enter only picks a suggestion.
|
||||
e.preventDefault()
|
||||
const option = matches[highlight]
|
||||
if (option) {
|
||||
choose(option)
|
||||
} else if (onEnterWithoutMatch && query.trim()) {
|
||||
onEnterWithoutMatch(query.trim())
|
||||
setQuery("")
|
||||
close()
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={cn("relative", className)}>
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setOpen(true)
|
||||
setHighlight(0)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onClick={openBrowse}
|
||||
onBlur={close}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
// Stop the browser's own autofill history from dropping down "previous entries".
|
||||
autoComplete="off"
|
||||
autoFocus={autoFocus}
|
||||
className={cn("h-11 pl-9 text-sm", browsable && "pr-9")}
|
||||
role="combobox"
|
||||
aria-expanded={showList}
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-label={rest["aria-label"]}
|
||||
/>
|
||||
{browsable && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="Show all options"
|
||||
// mousedown + preventDefault keeps focus in the input, so its blur doesn't close the list.
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
wrapperRef.current?.querySelector("input")?.focus()
|
||||
if (showList) close()
|
||||
else {
|
||||
openBrowse()
|
||||
setHighlight(0)
|
||||
}
|
||||
}}
|
||||
className="absolute top-1/2 right-2 flex size-7 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<ChevronDown className={cn("size-4 transition-transform", showList && "rotate-180")} />
|
||||
</button>
|
||||
)}
|
||||
{showList && rect && createPortal(
|
||||
<div
|
||||
ref={listRef}
|
||||
id={listId}
|
||||
role="listbox"
|
||||
style={{ top: rect.bottom + 4, left: rect.left, width: rect.width }}
|
||||
className="fixed z-50 max-h-72 min-w-72 overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10"
|
||||
>
|
||||
{matches.length === 0 ? (
|
||||
<p className="px-2.5 py-4 text-center text-sm text-muted-foreground">{emptyText}</p>
|
||||
) : (
|
||||
matches.map((option, idx) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
data-idx={idx}
|
||||
aria-selected={idx === highlight}
|
||||
// mousedown (not click) so the input's blur doesn't close the list first.
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
choose(option)
|
||||
}}
|
||||
onMouseEnter={() => setHighlight(idx)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-3 rounded-md px-2.5 py-2 text-left text-sm",
|
||||
idx === highlight ? "bg-secondary text-secondary-foreground" : "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{option.label}</span>
|
||||
{option.hint && <span className="shrink-0 text-xs text-muted-foreground tabular-nums">{option.hint}</span>}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user