LogoLaviecn

Zone Filter

Demo

Installation

Shadcn CLI

npx shadcn@latest add https://laviecn.vercel.app/r/zone-filter.json
pnpm dlx shadcn@latest add https://laviecn.vercel.app/r/zone-filter.json
yarn dlx shadcn@latest add https://laviecn.vercel.app/r/zone-filter.json
bun x shadcn@latest add https://laviecn.vercel.app/r/zone-filter.json

Manual

Copy and paste the following code into your project.

zone-filter.tsx
"use client"

import React, { useState } from "react"
import { ArrowLeft, MapPin } from "lucide-react"

import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"

import type { District, Province, Ward } from "../hooks/use-filter-zone"

interface ZoneFilterProps {
  provinces: Province[]
  districts: District[]
  wards: Ward[]
  filteredProvinces: Province[]
  filteredDistricts: District[]
  filteredWards: Ward[]
  selectedProvince?: string
  selectedDistrict?: string
  selectedWard?: string
  handleSelectProvince: (id: string) => void
  handleSelectDistrict: (id: string) => void
  handleSelectWard: (id: string) => void
  onChange?: (zone: {
    province?: Province
    district?: District
    ward?: Ward
  }) => void
}

export function ZoneFilter({
  provinces,
  districts,
  wards,
  filteredProvinces,
  filteredDistricts,
  filteredWards,
  selectedProvince,
  selectedDistrict,
  selectedWard,
  handleSelectProvince,
  handleSelectDistrict,
  handleSelectWard,
  onChange,
}: ZoneFilterProps) {
  const [step, setStep] = useState(0)
  const [search, setSearch] = useState("")

  const stepMap = [
    {
      label: "Tỉnh/Thành phố",
      items: filteredProvinces,
      selectedId: selectedProvince,
      handler: handleSelectProvince,
    },
    {
      label: "Quận/Huyện",
      items: filteredDistricts,
      selectedId: selectedDistrict,
      handler: handleSelectDistrict,
    },
    {
      label: "Phường/Xã",
      items: filteredWards,
      selectedId: selectedWard,
      handler: handleSelectWard,
    },
  ]

  const { label, items, selectedId, handler } = stepMap[step]

  const getFilteredItems = () => {
    const keyword = search.trim().toLowerCase()
    if (!keyword) return items
    return items.filter((item) =>
      [item.name, item.full_name, item.name_slug].some((field) =>
        field?.toLowerCase().includes(keyword)
      )
    )
  }

  const filteredItems = getFilteredItems()

  const rows = 4
  const columns = Math.max(5, Math.ceil(filteredItems.length / rows))
  const height = 46 * rows

  const triggerOnChange = (id: string) => {
    onChange?.({
      province: provinces.find(
        (p) => p.id === (step === 0 ? id : selectedProvince)
      ),
      district: districts.find(
        (d) => d.id === (step === 1 ? id : selectedDistrict)
      ),
      ward: wards.find((w) => w.id === (step === 2 ? id : selectedWard)),
    })
  }

  const handleSelect = (id: string) => {
    handler(id)
    triggerOnChange(id)

    if (step < 2) {
      setStep(step + 1)
      setSearch("")
    }
  }

  const onBack = () => {
    let newStep = step

    if (step === 2) {
      handleSelectWard("")
      newStep = 1
    } else if (step === 1) {
      handleSelectDistrict("")
      newStep = 0
    }

    setStep(newStep)
    setSearch("")

    onChange?.({
      province: provinces.find((p) => p.id === selectedProvince),
      district:
        newStep >= 1
          ? districts.find((d) => d.id === selectedDistrict)
          : undefined,
      ward: undefined,
    })
  }

  return (
    <div className="w-full rounded-md border">
      <div className="flex h-10 items-center justify-between gap-2 px-2 pt-2">
        <Input
          placeholder={`Tìm kiếm ${label.toLowerCase()}...`}
          value={search}
          onChange={(e) => setSearch(e.target.value)}
          className="h-8 w-full"
        />
        <Button
          disabled={step === 0}
          variant="outline"
          size="icon"
          className="size-8"
          onClick={onBack}
        >
          <ArrowLeft />
        </Button>
      </div>

      <ScrollArea className="p-2" style={{ height, overflowY: "hidden" }}>
        <div
          className="grid gap-2"
          style={{
            gridTemplateColumns: `repeat(${columns}, max-content)`,
            gridAutoFlow: "row",
          }}
        >
          {filteredItems.map((item) => (
            <Button
              key={item.id}
              variant={selectedId === item.id ? "default" : "outline"}
              onClick={() => handleSelect(item.id)}
              className="justify-start gap-0.5 text-xs whitespace-nowrap has-[>svg]:px-1"
              style={{ width: 142 }}
            >
              <MapPin className="size-3" />
              {item.full_name}
            </Button>
          ))}
        </div>
        <ScrollBar orientation="horizontal" />
      </ScrollArea>
    </div>
  )
}
hooks/use-filter-zone.ts
"use client"

import { useMemo, useState } from "react"

export interface Province {
  id: string
  name: string
  name_slug: string
  full_name: string
}

export interface District {
  id: string
  name: string
  name_slug: string
  full_name: string
  province_id: string
}

export interface Ward {
  id: string
  name: string
  name_slug: string
  full_name: string
  district_id: string
}

interface UseFilterZoneProps {
  provinces: Province[]
  districts: District[]
  wards: Ward[]
}

export function useFilterZone({
  provinces,
  districts,
  wards,
}: UseFilterZoneProps) {
  const [selectedProvince, setSelectedProvince] = useState<string>("")
  const [selectedDistrict, setSelectedDistrict] = useState<string>("")
  const [selectedWard, setSelectedWard] = useState<string>("")

  const filteredDistricts = useMemo(() => {
    return districts.filter(
      (district) => district.province_id === selectedProvince
    )
  }, [districts, selectedProvince])

  const filteredWards = useMemo(() => {
    return wards.filter((ward) => ward.district_id === selectedDistrict)
  }, [wards, selectedDistrict])

  const handleSelectProvince = (provinceId: string) => {
    setSelectedProvince(provinceId)
    setSelectedDistrict("")
    setSelectedWard("")
  }

  const handleSelectDistrict = (districtId: string) => {
    setSelectedDistrict(districtId)
    setSelectedWard("")
  }

  const handleSelectWard = (wardId: string) => {
    setSelectedWard(wardId)
  }

  return {
    filteredProvinces: provinces,
    filteredDistricts,
    filteredWards,
    selectedProvince,
    selectedDistrict,
    selectedWard,
    handleSelectProvince,
    handleSelectDistrict,
    handleSelectWard,
  }
}

Update the import paths to match your project setup.

Zone Data

Props

ZoneFilterProps

PropTypeDefault
provinces
Province[]
-
districts
District[]
-
wards
Ward[]
-
filteredProvinces
Province[]
-
filteredDistricts
District[]
-
filteredWards
Ward[]
-
selectedProvince?
string
-
selectedDistrict?
string
-
selectedWard?
string
-
handleSelectProvince
(id: string) => void
-
handleSelectDistrict
(id: string) => void
-
handleSelectWard
(id: string) => void
-
onChange?
(zone: { province?: Province; district?: District; ward?: Ward }) => void
-

UseFilterZoneProps

PropTypeDefault
districts
District[]
-
provinces
Province[]
-
wards
Ward[]
-

Province

PropTypeDefault
id
string
-
name
string
-
name_slug
string
-
full_name
string
-

District

PropTypeDefault
id
string
-
name
string
-
name_slug
string
-
full_name
string
-
province_id
string
-

Ward

PropTypeDefault
id
string
-
name
string
-
name_slug
string
-
full_name
string
-
district_id
string
-