✨(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:
committed by
Anthony LC
parent
2882348547
commit
157f6200f2
@@ -76,9 +76,7 @@ test.describe('Header mobile', () => {
|
|||||||
const header = page.locator('header').first();
|
const header = page.locator('header').first();
|
||||||
|
|
||||||
await expect(header.getByLabel('Open the header menu')).toBeVisible();
|
await expect(header.getByLabel('Open the header menu')).toBeVisible();
|
||||||
await expect(
|
await expect(header.getByRole('link', { name: 'Docs Logo' })).toBeVisible();
|
||||||
header.getByRole('link', { name: 'Docs Logo Docs' }),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
await expect(
|
||||||
header.getByRole('button', {
|
header.getByRole('button', {
|
||||||
name: 'Les services de La Suite numérique',
|
name: 'Les services de La Suite numérique',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
`;
|
||||||
15
src/frontend/apps/impress/src/hook/useCmdK.tsx
Normal file
15
src/frontend/apps/impress/src/hook/useCmdK.tsx
Normal 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]);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user