mirror of
https://github.com/vercel/commerce.git
synced 2025-07-23 04:36:49 +00:00
feat: mobile filters panel
Signed-off-by: Chloe <pinkcloudvnn@gmail.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -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} />;
|
||||
}
|
@@ -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>
|
||||
);
|
||||
|
@@ -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;
|
79
components/layout/search/filters/mobile-filters.tsx
Normal file
79
components/layout/search/filters/mobile-filters.tsx
Normal 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;
|
26
components/layout/search/filters/sub-menu.tsx
Normal file
26
components/layout/search/filters/sub-menu.tsx
Normal 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;
|
@@ -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>
|
||||
);
|
||||
|
@@ -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}
|
||||
|
Reference in New Issue
Block a user