(frontend) add Quick Search component suite

- Introduced a new Quick Search feature with multiple components
- Implemented styling for the Quick Search components to
ensure a cohesive look and feel across the application.
This commit is contained in:
Nathan Panchout
2024-12-09 18:00:31 +01:00
committed by Anthony LC
parent 2882348547
commit 157f6200f2
8 changed files with 436 additions and 3 deletions

View File

@@ -76,9 +76,7 @@ test.describe('Header mobile', () => {
const header = page.locator('header').first();
await expect(header.getByLabel('Open the header menu')).toBeVisible();
await expect(
header.getByRole('link', { name: 'Docs Logo Docs' }),
).toBeVisible();
await expect(header.getByRole('link', { name: 'Docs Logo' })).toBeVisible();
await expect(
header.getByRole('button', {
name: 'Les services de La Suite numérique',

View File

@@ -0,0 +1,88 @@
import { Command } from 'cmdk';
import { ReactNode, useRef } from 'react';
import { Box } from '../Box';
import { QuickSearchGroup } from './QuickSearchGroup';
import { QuickSearchInput } from './QuickSearchInput';
import { QuickSearchStyle } from './QuickSearchStyle';
export type QuickSearchAction = {
onSelect?: () => void;
content: ReactNode;
};
export type QuickSearchData<T> = {
groupName: string;
elements: T[];
emptyString?: string;
startActions?: QuickSearchAction[];
endActions?: QuickSearchAction[];
showWhenEmpty?: boolean;
};
export type QuickSearchProps<T> = {
data?: QuickSearchData<T>[];
onFilter?: (str: string) => void;
renderElement?: (element: T) => ReactNode;
onSelect?: (element: T) => void;
inputValue?: string;
inputContent?: ReactNode;
showInput?: boolean;
loading?: boolean;
label?: string;
placeholder?: string;
children?: ReactNode;
};
export const QuickSearch = <T,>({
onSelect,
onFilter,
inputContent,
inputValue,
loading,
showInput = true,
data,
renderElement,
label,
placeholder,
children,
}: QuickSearchProps<T>) => {
const ref = useRef<HTMLDivElement | null>(null);
return (
<>
<QuickSearchStyle />
<div className="quick-search-container">
<Command label={label} shouldFilter={false} ref={ref}>
{showInput && (
<QuickSearchInput
loading={loading}
inputValue={inputValue}
onFilter={onFilter}
placeholder={placeholder}
>
{inputContent}
</QuickSearchInput>
)}
<Command.List>
<Box>
{!loading &&
data?.map((group) => {
return (
<QuickSearchGroup
key={group.groupName}
group={group}
onSelect={onSelect}
renderElement={renderElement}
/>
);
})}
{children}
</Box>
</Command.List>
</Command>
</div>
</>
);
};

View File

@@ -0,0 +1,64 @@
import { Command } from 'cmdk';
import { Box } from '../Box';
import { QuickSearchData, QuickSearchProps } from './QuickSearch';
import { QuickSearchItem } from './QuickSearchItem';
type Props<T> = {
group: QuickSearchData<T>;
onSelect?: QuickSearchProps<T>['onSelect'];
renderElement: QuickSearchProps<T>['renderElement'];
};
export const QuickSearchGroup = <T,>({
group,
onSelect,
renderElement,
}: Props<T>) => {
return (
<Box $margin={{ top: 'base' }}>
<Command.Group
key={group.groupName}
heading={group.groupName}
forceMount={false}
>
{group.startActions?.map((action, index) => {
return (
<QuickSearchItem
key={`${group.groupName}-action-${index}`}
onSelect={action.onSelect}
>
{action.content}
</QuickSearchItem>
);
})}
{group.elements.map((groupElement, index) => {
return (
<QuickSearchItem
key={`${group.groupName}-element-${index}`}
onSelect={() => {
onSelect?.(groupElement);
}}
>
{renderElement?.(groupElement)}
</QuickSearchItem>
);
})}
{group.endActions?.map((action, index) => {
return (
<QuickSearchItem
key={`${group.groupName}-action-${index}`}
onSelect={action.onSelect}
>
{action.content}
</QuickSearchItem>
);
})}
{group.emptyString && group.elements.length === 0 && (
<span className="ml-b clr-greyscale-500">{group.emptyString}</span>
)}
</Command.Group>
</Box>
);
};

View File

@@ -0,0 +1,67 @@
import { Loader } from '@openfun/cunningham-react';
import { Command } from 'cmdk';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { HorizontalSeparator } from '@/components/separators/HorizontalSeparator';
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
import { Icon } from '../Icon';
type Props = {
loading?: boolean;
inputValue?: string;
onFilter?: (str: string) => void;
placeholder?: string;
children?: ReactNode;
};
export const QuickSearchInput = ({
loading,
inputValue,
onFilter,
placeholder,
children,
}: Props) => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
const spacing = spacingsTokens();
if (children) {
return (
<>
{children}
<HorizontalSeparator />
</>
);
}
return (
<>
<Box
$direction="row"
$align="center"
className="quick-search-input"
$gap={spacing['2xs']}
$padding={{ all: 'base' }}
>
{!loading && <Icon iconName="search" $variation="600" />}
{loading && (
<div>
<Loader size="small" />
</div>
)}
<Command.Input
/* eslint-disable-next-line jsx-a11y/no-autofocus */
autoFocus={true}
aria-label={t('Quick search input')}
value={inputValue}
role="combobox"
placeholder={placeholder ?? t('Search')}
onValueChange={onFilter}
/>
</Box>
<HorizontalSeparator $withPadding={false} />
</>
);
};

View File

@@ -0,0 +1,12 @@
import { Command } from 'cmdk';
import { PropsWithChildren } from 'react';
type Props = {
onSelect?: (value: string) => void;
};
export const QuickSearchItem = ({
children,
onSelect,
}: PropsWithChildren<Props>) => {
return <Command.Item onSelect={onSelect}>{children}</Command.Item>;
};

View File

@@ -0,0 +1,44 @@
import { ReactNode } from 'react';
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
export type QuickSearchItemContentProps = {
alwaysShowRight?: boolean;
left: ReactNode;
right?: ReactNode;
};
export const QuickSearchItemContent = ({
alwaysShowRight = false,
left,
right,
}: QuickSearchItemContentProps) => {
const { spacingsTokens } = useCunninghamTheme();
const spacings = spacingsTokens();
return (
<Box
$direction="row"
$align="center"
$padding={{ horizontal: '2xs', vertical: '3xs' }}
$justify="space-between"
$width="100%"
>
<Box $direction="row" $align="center" $gap={spacings['2xs']}>
{left}
</Box>
{right && (
<Box
className={!alwaysShowRight ? 'show-right-on-focus' : ''}
$direction="row"
$align="center"
>
{right}
</Box>
)}
</Box>
);
};

View File

@@ -0,0 +1,145 @@
import { createGlobalStyle } from 'styled-components';
export const QuickSearchStyle = createGlobalStyle`
.quick-search-container {
[cmdk-root] {
width: 100%;
background: #ffffff;
border-radius: 12px;
overflow: hidden;
transition: transform 100ms ease;
outline: none;
}
[cmdk-input] {
border: none;
width: 100%;
font-size: 17px;
padding: 8px;
background: white;
outline: none;
color: var(--c--theme--colors--greyscale-1000);
border-radius: 0;
&::placeholder {
color: var(--c--theme--colors--greyscale-500);
}
}
[cmdk-item] {
content-visibility: auto;
cursor: pointer;
border-radius: var(--c--theme--spacings--xs);
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
user-select: none;
will-change: background, color;
transition: all 150ms ease;
transition-property: none;
.show-right-on-focus {
opacity: 0;
}
&:hover,
&[data-selected='true'] {
background: var(--c--theme--colors--greyscale-100);
.show-right-on-focus {
opacity: 1;
}
}
&[data-disabled='true'] {
color: var(--c--theme--colors--greyscale-500);
cursor: not-allowed;
}
& + [cmdk-item] {
margin-top: 4px;
}
}
[cmdk-list] {
padding: 0 var(--c--theme--spacings--sm) var(--c--theme--spacings--sm)
var(--c--theme--spacings--sm);
flex:1;
overflow-y: auto;
overscroll-behavior: contain;
transition: 100ms ease;
transition-property: height;
}
[cmdk-vercel-shortcuts] {
display: flex;
margin-left: auto;
gap: 8px;
kbd {
font-size: 12px;
min-width: 20px;
padding: 4px;
height: 20px;
border-radius: 4px;
color: white;
background: var(--c--theme--colors--greyscale-500);
display: inline-flex;
align-items: center;
justify-content: center;
text-transform: uppercase;
}
}
[cmdk-separator] {
height: 1px;
width: 100%;
background: var(--c--theme--colors--greyscale-500);
margin: 4px 0;
}
*:not([hidden]) + [cmdk-group] {
margin-top: 8px;
}
[cmdk-group-heading] {
user-select: none;
font-size: var(--c--theme--font--sizes--sm);
color: var(--c--theme--colors--greyscale-700);
font-weight: bold;
display: flex;
align-items: center;
margin-bottom: var(--c--theme--spacings--base);
}
[cmdk-empty] {
}
}
.c__modal__scroller:has(.quick-search-container),
.c__modal__scroller:has(.noPadding) {
padding: 0 !important;
.c__modal__close .c__button {
right: 5px;
top: 5px;
padding: 1.5rem 1rem;
}
.c__modal__title {
font-size: var(--c--theme--font--sizes--xs);
padding: var(--c--theme--spacings--base);
margin-bottom: 0;
}
}
`;

View File

@@ -0,0 +1,15 @@
import { useEffect } from 'react';
export const useCmdK = (callback: () => void) => {
useEffect(() => {
const down = (e: KeyboardEvent) => {
if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
callback();
}
};
document.addEventListener('keydown', down);
return () => document.removeEventListener('keydown', down);
}, [callback]);
};