diff --git a/src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts index 5f78d90c..5ef24c46 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts @@ -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', diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearch.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearch.tsx new file mode 100644 index 00000000..4ba6edb3 --- /dev/null +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearch.tsx @@ -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 = { + groupName: string; + elements: T[]; + emptyString?: string; + startActions?: QuickSearchAction[]; + endActions?: QuickSearchAction[]; + showWhenEmpty?: boolean; +}; + +export type QuickSearchProps = { + data?: QuickSearchData[]; + 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 = ({ + onSelect, + onFilter, + inputContent, + inputValue, + loading, + showInput = true, + data, + renderElement, + label, + placeholder, + children, +}: QuickSearchProps) => { + const ref = useRef(null); + + return ( + <> + +
+ + {showInput && ( + + {inputContent} + + )} + + + {!loading && + data?.map((group) => { + return ( + + ); + })} + {children} + + + +
+ + ); +}; diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearchGroup.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearchGroup.tsx new file mode 100644 index 00000000..e735e949 --- /dev/null +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearchGroup.tsx @@ -0,0 +1,64 @@ +import { Command } from 'cmdk'; + +import { Box } from '../Box'; + +import { QuickSearchData, QuickSearchProps } from './QuickSearch'; +import { QuickSearchItem } from './QuickSearchItem'; + +type Props = { + group: QuickSearchData; + onSelect?: QuickSearchProps['onSelect']; + renderElement: QuickSearchProps['renderElement']; +}; + +export const QuickSearchGroup = ({ + group, + onSelect, + renderElement, +}: Props) => { + return ( + + + {group.startActions?.map((action, index) => { + return ( + + {action.content} + + ); + })} + {group.elements.map((groupElement, index) => { + return ( + { + onSelect?.(groupElement); + }} + > + {renderElement?.(groupElement)} + + ); + })} + {group.endActions?.map((action, index) => { + return ( + + {action.content} + + ); + })} + {group.emptyString && group.elements.length === 0 && ( + {group.emptyString} + )} + + + ); +}; diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx new file mode 100644 index 00000000..54d4f76a --- /dev/null +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx @@ -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} + + + ); + } + + return ( + <> + + {!loading && } + {loading && ( +
+ +
+ )} + +
+ + + ); +}; diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearchItem.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearchItem.tsx new file mode 100644 index 00000000..3ede8311 --- /dev/null +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearchItem.tsx @@ -0,0 +1,12 @@ +import { Command } from 'cmdk'; +import { PropsWithChildren } from 'react'; + +type Props = { + onSelect?: (value: string) => void; +}; +export const QuickSearchItem = ({ + children, + onSelect, +}: PropsWithChildren) => { + return {children}; +}; diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearchItemContent.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearchItemContent.tsx new file mode 100644 index 00000000..702a11ca --- /dev/null +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearchItemContent.tsx @@ -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 ( + + + {left} + + + {right && ( + + {right} + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx new file mode 100644 index 00000000..0b636ec6 --- /dev/null +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx @@ -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; + } +} + + +`; diff --git a/src/frontend/apps/impress/src/hook/useCmdK.tsx b/src/frontend/apps/impress/src/hook/useCmdK.tsx new file mode 100644 index 00000000..dd828865 --- /dev/null +++ b/src/frontend/apps/impress/src/hook/useCmdK.tsx @@ -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]); +};