feat: mobile filters panel

Signed-off-by: Chloe <pinkcloudvnn@gmail.com>
This commit is contained in:
Chloe
2024-05-07 14:40:18 +07:00
parent 145eb3eaed
commit 98d1f5c821
13 changed files with 264 additions and 255 deletions

View File

@@ -1,64 +0,0 @@
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import type { ListItem } from '.';
import { FilterItem } from './item';
export default function FilterItemDropdown({ list }: { list: ListItem[] }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const [active, setActive] = useState('');
const [openSelect, setOpenSelect] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpenSelect(false);
}
};
window.addEventListener('click', handleClickOutside);
return () => window.removeEventListener('click', handleClickOutside);
}, []);
useEffect(() => {
list.forEach((listItem: ListItem) => {
if (
('path' in listItem && pathname === listItem.path) ||
('slug' in listItem && searchParams.get('sort') === listItem.slug)
) {
setActive(listItem.title);
}
});
}, [pathname, list, searchParams]);
return (
<div className="relative" ref={ref}>
<div
onClick={() => {
setOpenSelect(!openSelect);
}}
className="flex w-full items-center justify-between rounded border border-black/30 px-4 py-2 text-sm dark:border-white/30"
>
<div>{active}</div>
<ChevronDownIcon className="h-4" />
</div>
{openSelect && (
<div
onClick={() => {
setOpenSelect(false);
}}
className="absolute z-40 w-full rounded-b-md bg-white p-4 shadow-md dark:bg-black"
>
{list.map((item: ListItem, i) => (
<FilterItem key={i} item={item} />
))}
</div>
)}
</div>
);
}

View File

@@ -1,41 +0,0 @@
import { SortFilterItem } from 'lib/constants';
import { Suspense } from 'react';
import FilterItemDropdown from './dropdown';
import { FilterItem } from './item';
export type ListItem = SortFilterItem | PathFilterItem;
export type PathFilterItem = { title: string; path: string };
function FilterItemList({ list }: { list: ListItem[] }) {
return (
<>
{list.map((item: ListItem, i) => (
<FilterItem key={i} item={item} />
))}
</>
);
}
export default function FilterList({ list, title }: { list: ListItem[]; title?: string }) {
return (
<>
<nav>
{title ? (
<h3 className="hidden text-xs text-neutral-500 md:block dark:text-neutral-400">
{title}
</h3>
) : null}
<ul className="hidden md:block">
<Suspense fallback={null}>
<FilterItemList list={list} />
</Suspense>{' '}
</ul>
<ul className="md:hidden">
<Suspense fallback={null}>
<FilterItemDropdown list={list} />
</Suspense>{' '}
</ul>
</nav>
</>
);
}

View File

@@ -1,67 +0,0 @@
'use client';
import clsx from 'clsx';
import type { SortFilterItem } from 'lib/constants';
import { createUrl } from 'lib/utils';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
import type { ListItem, PathFilterItem } from '.';
function PathFilterItem({ item }: { item: PathFilterItem }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const active = pathname === item.path;
const newParams = new URLSearchParams(searchParams.toString());
const DynamicTag = active ? 'p' : Link;
newParams.delete('q');
return (
<li className="mt-2 flex text-black dark:text-white" key={item.title}>
<DynamicTag
href={createUrl(item.path, newParams)}
className={clsx(
'w-full text-sm underline-offset-4 hover:underline dark:hover:text-neutral-100',
{
'underline underline-offset-4': active
}
)}
>
{item.title}
</DynamicTag>
</li>
);
}
function SortFilterItem({ item }: { item: SortFilterItem }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const active = searchParams.get('sort') === item.slug;
const q = searchParams.get('q');
const href = createUrl(
pathname,
new URLSearchParams({
...(q && { q }),
...(item.slug && item.slug.length && { sort: item.slug })
})
);
const DynamicTag = active ? 'p' : Link;
return (
<li className="mt-2 flex text-sm text-black dark:text-white" key={item.title}>
<DynamicTag
prefetch={!active ? false : undefined}
href={href}
className={clsx('w-full hover:underline hover:underline-offset-4', {
'underline underline-offset-4': active
})}
>
{item.title}
</DynamicTag>
</li>
);
}
export function FilterItem({ item }: { item: ListItem }) {
return 'path' in item ? <PathFilterItem item={item} /> : <SortFilterItem item={item} />;
}

View File

@@ -1,10 +1,12 @@
'use client';
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { Filter } from 'lib/shopify/types';
import { createUrl } from 'lib/utils';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
const Filters = ({ filters }: { filters: Filter[] }) => {
const Filters = ({ filters, defaultOpen = true }: { filters: Filter[]; defaultOpen?: boolean }) => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -31,9 +33,17 @@ const Filters = ({ filters }: { filters: Filter[] }) => {
return (
<form onChange={handleChange} className="space-y-5 divide-y divide-gray-200">
{filters.map(({ label, id, values }) => (
<div key={id} className="flex h-auto max-h-[550px] flex-col gap-y-3 overflow-hidden pt-5">
<div className="block text-sm font-medium text-gray-900">{label}</div>
<div className="flex-grow space-y-3 overflow-auto pb-1 pl-1 pt-2">
<Disclosure
key={id}
as="div"
className="flex h-auto max-h-[550px] flex-col gap-y-3 overflow-hidden pt-5"
defaultOpen={defaultOpen}
>
<DisclosureButton className="group flex items-center justify-between">
<div className="text-sm font-medium text-gray-900">{label}</div>
<ChevronDownIcon className="size-4 group-data-[open]:rotate-180" />
</DisclosureButton>
<DisclosurePanel className="flex-grow space-y-3 overflow-auto pb-1 pl-1 pt-2">
{values.map(({ id: valueId, label, count, value }) => (
<label
key={valueId}
@@ -54,8 +64,8 @@ const Filters = ({ filters }: { filters: Filter[] }) => {
<span>{`${label} (${count})`}</span>
</label>
))}
</div>
</div>
</DisclosurePanel>
</Disclosure>
))}
</form>
);

View File

@@ -1,34 +0,0 @@
import { getMenu } from 'lib/shopify';
import { Filter } from 'lib/shopify/types';
import Link from 'next/link';
import FiltersList from './filters-list';
const Filters = async ({ collection, filters }: { collection: string; filters: Filter[] }) => {
const menu = await getMenu('main-menu');
const subMenu = menu.find((item) => item.path === `/search/${collection}`)?.items || [];
return (
<div>
{subMenu.length ? (
<>
<h3 className="sr-only">Categories</h3>
<ul
role="list"
className="space-y-4 border-b border-gray-200 pb-6 text-sm font-medium text-gray-900"
>
{subMenu.map((subMenuItem) => (
<li key={subMenuItem.title}>
<Link href={subMenuItem.path} className="hover:underline">
{subMenuItem.title}
</Link>
</li>
))}
</ul>
</>
) : null}
<h3 className="sr-only">Filters</h3>
<FiltersList filters={filters} />
</div>
);
};
export default Filters;

View File

@@ -0,0 +1,79 @@
'use client';
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react';
import { FunnelIcon } from '@heroicons/react/24/outline';
import { XMarkIcon } from '@heroicons/react/24/solid';
import { Filter, Menu } from 'lib/shopify/types';
import { Fragment, useState } from 'react';
import Filters from './filters-list';
import SubMenu from './sub-menu';
const MobileFilters = ({
collection,
filters,
menu
}: {
collection: string;
filters: Filter[];
menu: Menu[];
}) => {
const [openDialog, setOpenDialog] = useState(false);
return (
<div className="lg:hidden">
<button
className="flex items-center gap-2 rounded border border-gray-300 px-3 py-1 text-sm text-gray-700"
onClick={() => setOpenDialog(true)}
>
Filters
<FunnelIcon className="size-4" />
</button>
<Transition show={openDialog}>
<Dialog as="div" className="relative z-40" onClose={setOpenDialog}>
<TransitionChild
as={Fragment}
enter="transition-opacity ease-linear duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-linear duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-25" />
</TransitionChild>
<div className="fixed inset-0 z-40 flex">
<TransitionChild
as={Fragment}
enter="transition ease-in-out duration-300 transform"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<DialogPanel className="relative ml-auto flex h-full w-full max-w-xs flex-col overflow-y-auto bg-white py-4 pb-6 shadow-xl">
<div className="flex items-center justify-between px-4">
<h2 className="text-lg font-medium text-gray-900">Filters</h2>
<button
type="button"
className="-mr-2 flex h-10 w-10 items-center justify-center p-2 text-gray-400 hover:text-gray-500"
onClick={() => setOpenDialog(false)}
>
<span className="sr-only">Close menu</span>
<XMarkIcon className="size-6" aria-hidden="true" />
</button>
</div>
<div className="mt-4 border-t border-gray-200 px-4 pt-4">
<SubMenu collection={collection} menu={menu} />
<Filters filters={filters} defaultOpen={false} />
</div>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</Transition>
</div>
);
};
export default MobileFilters;

View File

@@ -0,0 +1,26 @@
import { Menu } from 'lib/shopify/types';
import Link from 'next/link';
const SubMenu = ({ menu, collection }: { menu: Menu[]; collection: string }) => {
const subMenu = menu.find((item) => item.path === `/search/${collection}`)?.items || [];
return subMenu.length ? (
<>
<h3 className="sr-only">Categories</h3>
<ul
role="list"
className="space-y-4 border-b border-gray-200 pb-6 text-sm font-medium text-gray-900"
>
{subMenu.map((subMenuItem) => (
<li key={subMenuItem.title}>
<Link href={subMenuItem.path} className="hover:underline">
{subMenuItem.title}
</Link>
</li>
))}
</ul>
</>
) : null;
};
export default SubMenu;

View File

@@ -1,6 +1,6 @@
'use client';
import { Menu, Transition } from '@headlessui/react';
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/20/solid';
import { defaultSort, sorting } from 'lib/constants';
import { useSearchParams } from 'next/navigation';
@@ -14,7 +14,7 @@ const SortingMenu = () => {
return (
<Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button className="group inline-flex justify-center rounded border border-gray-300 px-3 py-1 text-sm text-gray-700 hover:bg-gray-100">
<MenuButton className="group inline-flex justify-center rounded border border-gray-300 px-3 py-1 text-sm text-gray-700 hover:bg-gray-100">
<div className="flex items-center gap-2">
Sort by:{' '}
<span>
@@ -25,7 +25,7 @@ const SortingMenu = () => {
className="-mr-1 ml-1.5 h-5 w-5 flex-shrink-0 text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
</Menu.Button>
</MenuButton>
</div>
<Transition
@@ -37,19 +37,17 @@ const SortingMenu = () => {
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 z-10 mt-2 w-full origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<MenuItems className="absolute right-0 z-10 mt-2 w-full origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1">
{sorting.map((option) => (
<Menu.Item key={option.title}>
{({ active }) => (
<div>
<SortingItem item={option} hover={active} />
</div>
)}
</Menu.Item>
<MenuItem key={option.title}>
<div className="data-[focus]:bg-gray-100">
<SortingItem item={option} />
</div>
</MenuItem>
))}
</div>
</Menu.Items>
</MenuItems>
</Transition>
</Menu>
);

View File

@@ -4,7 +4,7 @@ import { createUrl } from 'lib/utils';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
const SortingItem = ({ item, hover }: { item: SortFilterItem; hover: boolean }) => {
const SortingItem = ({ item }: { item: SortFilterItem }) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const active = searchParams.get('sort') === item.slug;
@@ -23,11 +23,9 @@ const SortingItem = ({ item, hover }: { item: SortFilterItem; hover: boolean })
<DynamicTag
prefetch={!active ? false : undefined}
href={href}
className={clsx('block px-4 py-2 text-sm', {
className={clsx('block bg-transparent px-4 py-2 text-sm', {
'font-medium text-gray-900': active,
'text-gray-500': !active,
'bg-gray-100': hover,
'bg-transparent': !hover
'text-gray-500': !active
})}
>
{item.title}