Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WEB-4087] - New Examples index page with filtering & search #2347

Draft
wants to merge 7 commits into
base: web-4082-docs-nav-redesign
Choose a base branch
from
50 changes: 50 additions & 0 deletions src/components/Examples/ExamplesCheckbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React from 'react';
import Icon from '@ably/ui/core/Icon';
import cn from '@ably/ui/core/utils/cn';

const ExamplesCheckbox = ({
label,
name,
value,
disabled = false,
isChecked = false,
handleSelect,
}: {
label: string;
name: string;
value: string;
disabled?: boolean;
isChecked?: boolean;
handleSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) => {
return (
<div className="flex items-center mb-0">
<input
data-ui-checkbox-native=""
type="checkbox"
id={name}
name={name}
className="ui-checkbox-input"
value={value}
checked={isChecked}
disabled={disabled}
onChange={(e) => handleSelect(e)}
/>
<div
data-ui-checkbox-styled=""
className={cn(['ui-checkbox-styled', disabled && '!border-neutral-800 !bg-orange-600'])}
>
<Icon
size="1rem"
name="icon-gui-tick"
additionalCSS={cn(['ui-checkbox-styled-tick cursor-pointer', disabled && 'text-neutral-000'])}
/>
</div>
<label htmlFor={name} className="ui-text-p4 text-neutral-900">
{label}
</label>
</div>
);
};

export default ExamplesCheckbox;
70 changes: 70 additions & 0 deletions src/components/Examples/ExamplesContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React, { ChangeEvent, useCallback, useEffect, useState } from 'react';
import { StaticImage } from 'gatsby-plugin-image';
import ExamplesGrid from './ExamplesGrid';
import ExamplesFilter from './ExamplesFilter';
import { ImageProps } from '../Image';
import examples from '../../data/examples';
import { filterSearchExamples } from './filter-search-examples';
import ExamplesNoResults from './ExamplesNoResults';

export type SelectedFilters = { products: string[]; useCases: string[] };

const ExamplesContent = ({ exampleImages }: { exampleImages: ImageProps[] }) => {
const [selected, setSelected] = useState<SelectedFilters>({ products: [], useCases: [] });
const [searchTerm, setSearchTerm] = useState('');
const [filteredExamples, setFilteredExamples] = useState(examples.examples);

const handleSearch = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value);
}, []);

useEffect(() => {
const filteredExamples = filterSearchExamples(examples.examples, selected, searchTerm);
setFilteredExamples(filteredExamples);
}, [selected, searchTerm]);

return (
<>
<section className="mx-auto px-24 md:px-0 max-w-[1152px] relative">
<div className="w-full sm:w-1/2 max-w-[600px] pt-80 sm:pt-96">
<h1 className="ui-text-title text-title">Examples</h1>
<p className="ui-text-sub-header mt-16">
From avatar stacks to live cursors, learn how deliver live chat, multiplayer collaboration features, and
more.
</p>
</div>
<div className="w-full my-40 sm:my-64 flex flex-col sm:flex-row gap-x-40">
<div className="w-full sm:w-[20%] relative">
<ExamplesFilter selected={selected} setSelected={setSelected} handleSearch={handleSearch} />
</div>
<div className="w-full sm:w-[80%] mt-40 sm:mt-0">
{filteredExamples.length > 0 ? (
<ExamplesGrid exampleImages={exampleImages} examples={filteredExamples} searchTerm={searchTerm} />
) : (
<ExamplesNoResults />
)}
</div>
</div>
</section>

<StaticImage
src="./images/GridPattern.png"
placeholder="blurred"
width={660}
height={282}
alt="Grid Pattern"
className="absolute -z-10 right-0 top-64 hidden sm:block w-[60%] md:w-[40%]"
/>

<StaticImage
src="./images/GridMobile.png"
placeholder="blurred"
width={260}
alt="Grid Pattern"
className="-z-10 right-0 top-64 absolute block sm:hidden"
/>
</>
);
};

export default ExamplesContent;
183 changes: 183 additions & 0 deletions src/components/Examples/ExamplesFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import React, { ChangeEvent, Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import Icon from '@ably/ui/core/Icon';
import { products } from '@ably/ui/core/ProductTile/data';
import ExamplesCheckbox from './ExamplesCheckbox';
import examples from '../../data/examples';
import Button from '@ably/ui/core/Button';
import cn from '@ably/ui/core/utils/cn';
import { useOnClickOutside } from 'src/hooks';
import Badge from '@ably/ui/core/Badge';
import { SelectedFilters } from './ExamplesContent';

const ExamplesFilter = ({
selected,
setSelected,
handleSearch,
}: {
selected: SelectedFilters;
setSelected: Dispatch<SetStateAction<SelectedFilters>>;
handleSearch: (e: ChangeEvent<HTMLInputElement>) => void;
}) => {
const filterMenuRef = useRef<HTMLDivElement>(null);
const [expandFilterMenu, setExpandFilterMenu] = useState(false);
const [localSelected, setLocalSelected] = useState<SelectedFilters>(selected);

const handleSelect = (e: ChangeEvent<HTMLInputElement>, filterType: keyof SelectedFilters) => {
setLocalSelected((prevSelected) => {
if (e.target.value === 'all') {
return {
...prevSelected,
[filterType]: [],
};
}

const newSelected = prevSelected[filterType].includes(e.target.value)
? prevSelected[filterType].filter((item) => item !== e.target.value)
: [...prevSelected[filterType], e.target.value];

return {
...prevSelected,
[filterType]: Array.from(new Set(newSelected)),
};
});
};

const filters = [
{
key: 'product',
data: products,
selected: localSelected.products,
handleSelect: (e: ChangeEvent<HTMLInputElement>) => handleSelect(e, 'products'),
},
{
key: 'use-case',
data: examples.useCases,
selected: localSelected.useCases,
handleSelect: (e: ChangeEvent<HTMLInputElement>) => handleSelect(e, 'useCases'),
},
];

const closeFilterMenu = useCallback(() => {
setExpandFilterMenu(false);
setLocalSelected(selected);
}, [selected]);

useOnClickOutside(closeFilterMenu, filterMenuRef);

useEffect(() => {
const handleResize = () => {
if (window.innerWidth >= 1040) {
setExpandFilterMenu(false);
}
};

window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);

useEffect(() => {
if (window.innerWidth >= 1040) {
setSelected(localSelected);
}
}, [expandFilterMenu, localSelected, setSelected]);

const activeFilters = useMemo(
() => selected.products.length + selected.useCases.length,
[selected.products, selected.useCases],
);

const handleApply = () => {
setSelected(localSelected);
setExpandFilterMenu(false);
};

return (
<>
<div className="h-[34px] sm:h-[30px] w-20 absolute left-8 top-4 flex items-center justify-center select-none cursor-default">
<Icon name={'icon-gui-search'} size="1rem" />
</div>
<input
type="search"
className="ui-input pl-36 w-full h-40 sm:h-34 ui-text-p3"
placeholder="Find an example"
autoComplete="off"
aria-label="Search examples"
role="searchbox"
onChange={(e) => handleSearch(e)}
/>
<Button
className="flex sm:hidden mt-16 w-full"
variant="secondary"
leftIcon="icon-gui-copy"
onClick={() => setExpandFilterMenu(true)}
>
Filter
{activeFilters > 0 ? <Badge>{activeFilters}</Badge> : null}
</Button>
{expandFilterMenu &&
ReactDOM.createPortal(
<div className="fixed inset-0 bg-neutral-1300 opacity-10 z-20" onClick={() => setExpandFilterMenu(false)} />,
document.body,
)}
<div
ref={filterMenuRef}
className={cn(
'fixed bottom-0 bg-white dark:bg-neutral-1300 z-30 w-full left-0 gap-20 mt-20 translate-y-full sm:static sm:translate-y-0 sm:flex sm:flex-col sm:bg-transparent sm:z-0 transition-[transform,colors] rounded-t-2xl sm:rounded-none max-h-[calc(100%-64px)] overflow-y-scroll',
{
'translate-y-0': expandFilterMenu,
},
)}
>
<div className="flex justify-between items-center sm:hidden h-64 px-16 py-8 bg-neutral-000 dark:bg-neutral-1300 border border-neutral-300 dark:border-neutral-1000 rounded-t-2xl sm:rounded-none">
<p className="ui-text-p1 font-bold text-neutral-1300 dark:text-neutral-000">Filters</p>
<button onClick={closeFilterMenu} aria-label="Close filter menu">
<Icon name="icon-gui-close" size="24px" />
</button>
</div>
{filters.map(({ key, selected, handleSelect, data }) => (
<div key={key} className="p-16 pt-24">
<p className="ui-text-overline2 font-medium text-neutral-700">{key.replace(/-/g, ' ').toUpperCase()}</p>
<div className="mt-8 flex ui-text-p4 flex-col gap-8">
<ExamplesCheckbox
label="All"
name={`${key}-all`}
value="all"
disabled={selected.length === 0}
isChecked={selected.length === 0}
handleSelect={handleSelect}
/>
{Object.entries(data).map(([itemKey, item]) => (
<ExamplesCheckbox
key={itemKey}
label={item.label}
name={`${key}-${itemKey}`}
value={itemKey}
handleSelect={handleSelect}
isChecked={selected.includes(itemKey)}
/>
))}
</div>
</div>
))}
<div className="sm:hidden p-16 flex gap-12">
<Button
className="w-full flex-1"
variant="primary"
disabled={
localSelected.products.length === selected.products.length &&
localSelected.useCases.length === selected.useCases.length &&
localSelected.products.every((product) => selected.products.includes(product)) &&
localSelected.useCases.every((useCase) => selected.useCases.includes(useCase))
}
onClick={handleApply}
>
Apply
</Button>
</div>
</div>
</>
);
};

export default ExamplesFilter;
Loading