Compare commits
No commits in common. "2a0283785a923d59d28a9b9181fae1c5e1a9bbe2" and "e4f11527230d4e7be162dde9480e367d5d3091bd" have entirely different histories.
2a0283785a
...
e4f1152723
@ -8,102 +8,123 @@ import { Link } from "@/components/Link";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
function ArrowIcon(props: JSX.IntrinsicElements["svg"]) {
|
function ArrowIcon(props: JSX.IntrinsicElements["svg"]) {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true" {...props}>
|
<svg viewBox="0 0 16 16" aria-hidden="true" {...props}>
|
||||||
<path d="m9.182 13.423-1.17-1.16 3.505-3.505H3V7.065h8.517l-3.506-3.5L9.181 2.4l5.512 5.511-5.511 5.512Z" />
|
<path d="m9.182 13.423-1.17-1.16 3.505-3.505H3V7.065h8.517l-3.506-3.5L9.181 2.4l5.512 5.511-5.511 5.512Z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type PageLinkProps = Omit<JSX.IntrinsicElements["div"], "dir" | "title"> & {
|
type PageLinkProps = Omit<JSX.IntrinsicElements["div"], "dir" | "title"> & {
|
||||||
title: string;
|
title: string;
|
||||||
href: string;
|
href: string;
|
||||||
dir: "previous" | "next";
|
dir?: "previous" | "next";
|
||||||
};
|
};
|
||||||
|
|
||||||
function PageLink(props: PageLinkProps) {
|
function PageLink(props: PageLinkProps) {
|
||||||
const getPageCategory = () =>
|
const getPageCategory = () =>
|
||||||
navigation.find((section) => {
|
navigation.find((section) => {
|
||||||
return section.links.some(
|
return section.links.some(
|
||||||
(link) => link.href === props.href || link.subitems.some((subitem) => subitem.href === props.href),
|
(link) =>
|
||||||
);
|
link.href === props.href ||
|
||||||
});
|
link.subitems.some((subitem) => subitem.href === props.href),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...cleanProps(props, "dir", "title", "href", "subitems")}>
|
<div {...cleanProps(props, "dir", "title", "href", "subitems")}>
|
||||||
<dt class="font-display text-sm font-medium text-slate-900">{props.dir === "next" ? "Suivant" : "Précédent"}</dt>
|
<dt class="font-display text-sm font-medium text-slate-900">
|
||||||
<dd class="mt-1">
|
{props.dir === "next" ? "Suivant" : "Précédent"}
|
||||||
<Link
|
</dt>
|
||||||
href={props.href}
|
<dd class="mt-1">
|
||||||
class={clsx(
|
<Link
|
||||||
"flex items-center gap-x-2 text-base font-semibold text-slate-500 hover:text-slate-600",
|
href={props.href}
|
||||||
props.dir === "previous" && "flex-row-reverse",
|
class={clsx(
|
||||||
)}
|
"flex items-center gap-x-2 text-base font-semibold text-slate-500 hover:text-slate-600",
|
||||||
>
|
props.dir === "previous" && "flex-row-reverse",
|
||||||
<p class="flex flex-col gap-0">
|
)}
|
||||||
{getPageCategory() && (
|
>
|
||||||
<span class="text-violet-600 text-sm mb-1 leading-3">{getPageCategory()?.title}</span>
|
<p class="flex flex-col gap-0">
|
||||||
)}
|
{getPageCategory() && (
|
||||||
<span class="leading-4">{props.title}</span>
|
<span class="text-violet-600 text-sm mb-1 leading-3">
|
||||||
</p>
|
{getPageCategory()?.title}
|
||||||
<ArrowIcon class={clsx("h-6 w-6 flex-none fill-current", props.dir === "previous" && "-scale-x-100")} />
|
</span>
|
||||||
</Link>
|
)}
|
||||||
</dd>
|
<span class="leading-4">{props.title}</span>
|
||||||
</div>
|
</p>
|
||||||
);
|
<ArrowIcon
|
||||||
|
class={clsx(
|
||||||
|
"h-6 w-6 flex-none fill-current",
|
||||||
|
props.dir === "previous" && "-scale-x-100",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PrevNextLinks() {
|
export function PrevNextLinks() {
|
||||||
const pageContext = usePageContext();
|
const pageContext = usePageContext();
|
||||||
|
|
||||||
const allLinks = navigation
|
const allLinks = navigation
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// positions order (for sorting):
|
// positions order (for sorting):
|
||||||
// 1. start
|
// 1. start
|
||||||
// 2. auto | undefined
|
// 2. auto | undefined
|
||||||
// 3. end
|
// 3. end
|
||||||
|
|
||||||
if (a.position === "start" && b.position !== "start") return -1;
|
if (a.position === "start" && b.position !== "start") return -1;
|
||||||
if (a.position !== "start" && b.position === "start") return 1;
|
if (a.position !== "start" && b.position === "start") return 1;
|
||||||
|
|
||||||
if (a.position === "end" && b.position !== "end") return 1;
|
if (a.position === "end" && b.position !== "end") return 1;
|
||||||
if (a.position !== "end" && b.position === "end") return -1;
|
if (a.position !== "end" && b.position === "end") return -1;
|
||||||
|
|
||||||
if (a.position === "auto" && b.position !== "auto") return -1;
|
if (a.position === "auto" && b.position !== "auto") return -1;
|
||||||
if (a.position !== "auto" && b.position === "auto") return 1;
|
if (a.position !== "auto" && b.position === "auto") return 1;
|
||||||
|
|
||||||
if (a.position === undefined && b.position !== undefined) return -1;
|
if (a.position === undefined && b.position !== undefined) return -1;
|
||||||
if (a.position !== undefined && b.position === undefined) return 1;
|
if (a.position !== undefined && b.position === undefined) return 1;
|
||||||
return 0;
|
return 0;
|
||||||
})
|
})
|
||||||
.flatMap((section) => section.links)
|
.flatMap((section) => section.links)
|
||||||
.flatMap((link) => {
|
.flatMap((link) => {
|
||||||
return link.subitems ? [link, ...link.subitems] : link;
|
return link.subitems ? [link, ...link.subitems] : link;
|
||||||
});
|
});
|
||||||
|
|
||||||
const getNeighboringLinks = () => {
|
const getNeighboringLinks = () => {
|
||||||
const linkIndex = allLinks.findIndex((link) => link.href === pageContext.urlPathname);
|
const linkIndex = allLinks.findIndex(
|
||||||
if (linkIndex === -1) return [null, null];
|
(link) => link.href === pageContext.urlPathname,
|
||||||
|
);
|
||||||
|
if (linkIndex === -1) return [null, null];
|
||||||
|
|
||||||
const previousPage = allLinks[linkIndex - 1] || null;
|
const previousPage = allLinks[linkIndex - 1] || null;
|
||||||
let nextPage = allLinks[linkIndex + 1] || null;
|
let nextPage = allLinks[linkIndex + 1] || null;
|
||||||
|
|
||||||
if (nextPage?.href === pageContext.urlPathname) {
|
if (nextPage?.href === pageContext.urlPathname) {
|
||||||
nextPage = allLinks[linkIndex + 2] || null;
|
nextPage = allLinks[linkIndex + 2] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [previousPage, nextPage];
|
return [previousPage, nextPage];
|
||||||
};
|
};
|
||||||
|
|
||||||
if (getNeighboringLinks().length === 0) return null;
|
if (getNeighboringLinks().length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dl class="mt-12 mx-4 lg:mr-0 flex gap-4 border-t border-slate-200 pt-6">
|
<dl class="mt-12 mx-4 lg:mr-0 flex gap-4 border-t border-slate-200 pt-6">
|
||||||
{getNeighboringLinks()[0] && <PageLink dir="previous" {...(getNeighboringLinks()[0] as NavigationSubItem)} />}
|
{getNeighboringLinks()[0] && (
|
||||||
|
<PageLink
|
||||||
|
dir="previous"
|
||||||
|
{...(getNeighboringLinks()[0] as NavigationSubItem)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{getNeighboringLinks()[1] && (
|
{getNeighboringLinks()[1] && (
|
||||||
<PageLink class="ml-auto text-right" dir="next" {...(getNeighboringLinks()[1] as NavigationSubItem)} />
|
<PageLink
|
||||||
)}
|
class="ml-auto text-right"
|
||||||
</dl>
|
{...(getNeighboringLinks()[1] as NavigationSubItem)}
|
||||||
);
|
/>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
import type { SearchResult } from "@/services/FlexSearchService";
|
import type { SearchResult } from "@/services/FlexSearchService";
|
||||||
import type { JSX, Accessor, Setter } from "solid-js";
|
import type { JSX, Accessor, Setter } from "solid-js";
|
||||||
|
|
||||||
import { createContext, useContext, For, createEffect, createSignal } from "solid-js";
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
For,
|
||||||
|
createEffect,
|
||||||
|
createSignal,
|
||||||
|
} from "solid-js";
|
||||||
|
|
||||||
import { Highlighter } from "solid-highlight-words";
|
import { Highlighter } from "solid-highlight-words";
|
||||||
import { useDebounce } from "@/hooks/useDebounce";
|
import { useDebounce } from "@/hooks/useDebounce";
|
||||||
@ -12,305 +18,334 @@ import { useId } from "@/hooks/useId";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
const SearchContext = createContext<{
|
const SearchContext = createContext<{
|
||||||
query: Accessor<string>;
|
query: Accessor<string>;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
results: Accessor<SearchResult[]>;
|
results: Accessor<SearchResult[]>;
|
||||||
isLoading: Accessor<boolean>;
|
isLoading: Accessor<boolean>;
|
||||||
isOpened: Accessor<boolean>;
|
isOpened: Accessor<boolean>;
|
||||||
setQuery: Setter<string>;
|
setQuery: Setter<string>;
|
||||||
setIsOpened: Setter<boolean>;
|
setIsOpened: Setter<boolean>;
|
||||||
setIsLoading: Setter<boolean>;
|
setIsLoading: Setter<boolean>;
|
||||||
setResults: Setter<SearchResult[]>;
|
setResults: Setter<SearchResult[]>;
|
||||||
}>({
|
}>({
|
||||||
query: () => "",
|
query: () => "",
|
||||||
close: () => {},
|
close: () => {},
|
||||||
results: () => [],
|
results: () => [],
|
||||||
isLoading: () => false,
|
isLoading: () => false,
|
||||||
isOpened: () => false,
|
isOpened: () => false,
|
||||||
setQuery: () => {},
|
setQuery: () => {},
|
||||||
setIsOpened: () => {},
|
setIsOpened: () => {},
|
||||||
setIsLoading: () => {},
|
setIsLoading: () => {},
|
||||||
setResults: () => {},
|
setResults: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
function SearchIcon(props: JSX.IntrinsicElements["svg"]) {
|
function SearchIcon(props: JSX.IntrinsicElements["svg"]) {
|
||||||
return (
|
return (
|
||||||
<svg aria-hidden="true" viewBox="0 0 20 20" {...props}>
|
<svg aria-hidden="true" viewBox="0 0 20 20" {...props}>
|
||||||
<path d="M16.293 17.707a1 1 0 0 0 1.414-1.414l-1.414 1.414ZM9 14a5 5 0 0 1-5-5H2a7 7 0 0 0 7 7v-2ZM4 9a5 5 0 0 1 5-5V2a7 7 0 0 0-7 7h2Zm5-5a5 5 0 0 1 5 5h2a7 7 0 0 0-7-7v2Zm8.707 12.293-3.757-3.757-1.414 1.414 3.757 3.757 1.414-1.414ZM14 9a4.98 4.98 0 0 1-1.464 3.536l1.414 1.414A6.98 6.98 0 0 0 16 9h-2Zm-1.464 3.536A4.98 4.98 0 0 1 9 14v2a6.98 6.98 0 0 0 4.95-2.05l-1.414-1.414Z" />
|
<path d="M16.293 17.707a1 1 0 0 0 1.414-1.414l-1.414 1.414ZM9 14a5 5 0 0 1-5-5H2a7 7 0 0 0 7 7v-2ZM4 9a5 5 0 0 1 5-5V2a7 7 0 0 0-7 7h2Zm5-5a5 5 0 0 1 5 5h2a7 7 0 0 0-7-7v2Zm8.707 12.293-3.757-3.757-1.414 1.414 3.757 3.757 1.414-1.414ZM14 9a4.98 4.98 0 0 1-1.464 3.536l1.414 1.414A6.98 6.98 0 0 0 16 9h-2Zm-1.464 3.536A4.98 4.98 0 0 1 9 14v2a6.98 6.98 0 0 0 4.95-2.05l-1.414-1.414Z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoadingIcon(props: JSX.IntrinsicElements["svg"]) {
|
function LoadingIcon(props: JSX.IntrinsicElements["svg"]) {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||||
<circle cx="10" cy="10" r="5.5" stroke-linejoin="round" />
|
<circle cx="10" cy="10" r="5.5" stroke-linejoin="round" />
|
||||||
<path stroke={`url(#${id})`} stroke-linecap="round" stroke-linejoin="round" d="M15.5 10a5.5 5.5 0 1 0-5.5 5.5" />
|
<path
|
||||||
<defs>
|
stroke={`url(#${id})`}
|
||||||
<linearGradient id={id} x1="13" x2="9.5" y1="9" y2="15" gradientUnits="userSpaceOnUse">
|
stroke-linecap="round"
|
||||||
<stop stop-color="currentColor" />
|
stroke-linejoin="round"
|
||||||
<stop offset="1" stop-color="currentColor" stop-opacity="0" />
|
d="M15.5 10a5.5 5.5 0 1 0-5.5 5.5"
|
||||||
</linearGradient>
|
/>
|
||||||
</defs>
|
<defs>
|
||||||
</svg>
|
<linearGradient
|
||||||
);
|
id={id}
|
||||||
|
x1="13"
|
||||||
|
x2="9.5"
|
||||||
|
y1="9"
|
||||||
|
y2="15"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<stop stop-color="currentColor" />
|
||||||
|
<stop offset="1" stop-color="currentColor" stop-opacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchInput() {
|
function SearchInput() {
|
||||||
const { close, setQuery, query, isLoading } = useContext(SearchContext);
|
const { close, setQuery, query, isLoading } = useContext(SearchContext);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="group relative flex h-12">
|
<div class="group relative flex h-12">
|
||||||
<SearchIcon class="pointer-events-none absolute top-0 left-4 h-full w-5 fill-slate-400" />
|
<SearchIcon class="pointer-events-none absolute top-0 left-4 h-full w-5 fill-slate-400" />
|
||||||
<input
|
<input
|
||||||
data-autofocus
|
data-autofocus
|
||||||
class={clsx(
|
class={clsx(
|
||||||
"flex-auto appearance-none bg-transparent pl-12 text-slate-900 outline-hidden placeholder:text-slate-400 focus:w-full focus:flex-none sm:text-sm [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden [&::-webkit-search-results-button]:hidden [&::-webkit-search-results-decoration]:hidden",
|
"flex-auto appearance-none bg-transparent pl-12 text-slate-900 outline-hidden placeholder:text-slate-400 focus:w-full focus:flex-none sm:text-sm [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden [&::-webkit-search-results-button]:hidden [&::-webkit-search-results-decoration]:hidden",
|
||||||
isLoading() ? "pr-11" : "pr-4",
|
isLoading() ? "pr-11" : "pr-4",
|
||||||
)}
|
)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
// In Safari, closing the dialog with the escape key can sometimes cause the scroll position to jump to the
|
// In Safari, closing the dialog with the escape key can sometimes cause the scroll position to jump to the
|
||||||
// bottom of the page. This is a workaround for that until we can figure out a proper fix in Headless UI.
|
// bottom of the page. This is a workaround for that until we can figure out a proper fix in Headless UI.
|
||||||
if (document.activeElement instanceof HTMLElement) {
|
if (document.activeElement instanceof HTMLElement) {
|
||||||
document.activeElement.blur();
|
document.activeElement.blur();
|
||||||
}
|
}
|
||||||
|
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
value={query()}
|
value={query()}
|
||||||
onInput={(event) => {
|
onInput={(event) => {
|
||||||
const { value } = event.currentTarget;
|
const { value } = event.currentTarget;
|
||||||
setQuery(value);
|
setQuery(value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{isLoading() && (
|
{isLoading() && (
|
||||||
<div class="absolute inset-y-0 right-3 flex items-center">
|
<div class="absolute inset-y-0 right-3 flex items-center">
|
||||||
<LoadingIcon class="h-6 w-6 animate-spin stroke-slate-200 text-slate-400" />
|
<LoadingIcon class="h-6 w-6 animate-spin stroke-slate-200 text-slate-400" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HighlightQuery(props: { text: string; query: string }) {
|
function HighlightQuery(props: { text: string; query: string }) {
|
||||||
return (
|
return (
|
||||||
<Highlighter
|
<Highlighter
|
||||||
highlightClass="group-aria-selected:underline bg-transparent text-violet-600"
|
highlightClass="group-aria-selected:underline bg-transparent text-violet-600"
|
||||||
searchWords={[props.query]}
|
searchWords={[props.query]}
|
||||||
autoEscape={true}
|
autoEscape={true}
|
||||||
textToHighlight={props.text}
|
textToHighlight={props.text}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchResultItem(props: { result: SearchResult; query: string }) {
|
function SearchResultItem(props: { result: SearchResult; query: string }) {
|
||||||
const { close } = useContext(SearchContext);
|
const { close } = useContext(SearchContext);
|
||||||
const id = useId();
|
const id = useId();
|
||||||
|
|
||||||
const getHierarchy = (): string[] => {
|
const getHierarchy = (): string[] => {
|
||||||
const sectionTitle = navigation.find((section) => {
|
const sectionTitle = navigation.find((section) => {
|
||||||
return section.links.find((link) => link.href === props.result.url.split("#")[0]);
|
return section.links.find(
|
||||||
})?.title;
|
(link) => link.href === props.result.url.split("#")[0],
|
||||||
|
);
|
||||||
|
})?.title;
|
||||||
|
|
||||||
return [sectionTitle, props.result.pageTitle].filter((x): x is string => typeof x === "string");
|
return [sectionTitle, props.result.pageTitle].filter(
|
||||||
};
|
(x): x is string => typeof x === "string",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleNavigate = (url: string) => {
|
return (
|
||||||
navigate(`/${url}`.replace(/\/+/g, "/"));
|
<li
|
||||||
close();
|
class="group block cursor-default rounded-lg px-3 py-2 aria-selected:bg-slate-100 hover:bg-slate-100"
|
||||||
};
|
aria-labelledby={`${id}-hierarchy ${id}-title`}
|
||||||
|
tab-index={0}
|
||||||
return (
|
onKeyDown={(event) => {
|
||||||
<li
|
if (event.key === "Enter") {
|
||||||
class="group block cursor-default rounded-lg px-3 py-2 aria-selected:bg-slate-100 hover:bg-slate-100"
|
navigate(props.result.url);
|
||||||
aria-labelledby={`${id}-hierarchy ${id}-title`}
|
close();
|
||||||
tab-index={0}
|
}
|
||||||
onKeyDown={(event) => {
|
}}
|
||||||
if (event.key === "Enter") handleNavigate(props.result.url);
|
onClick={() => {
|
||||||
}}
|
navigate(props.result.url);
|
||||||
onClick={() => handleNavigate(props.result.url)}
|
close();
|
||||||
>
|
}}
|
||||||
<div id={`${id}-title`} aria-hidden="true" class="text-sm text-slate-700 group-aria-selected:text-violet-600">
|
>
|
||||||
<HighlightQuery text={props.result.title} query={props.query} />
|
<div
|
||||||
</div>
|
id={`${id}-title`}
|
||||||
{getHierarchy().length > 0 && (
|
aria-hidden="true"
|
||||||
<div
|
class="text-sm text-slate-700 group-aria-selected:text-violet-600"
|
||||||
id={`${id}-hierarchy`}
|
>
|
||||||
aria-hidden="true"
|
<HighlightQuery text={props.result.title} query={props.query} />
|
||||||
class="mt-0.5 truncate text-xs whitespace-nowrap text-slate-500 dark:text-slate-400"
|
</div>
|
||||||
>
|
{getHierarchy().length > 0 && (
|
||||||
<For each={getHierarchy()}>
|
<div
|
||||||
{(item, itemIndex) => (
|
id={`${id}-hierarchy`}
|
||||||
<>
|
aria-hidden="true"
|
||||||
<HighlightQuery text={item} query={props.query} />
|
class="mt-0.5 truncate text-xs whitespace-nowrap text-slate-500 dark:text-slate-400"
|
||||||
<span
|
>
|
||||||
class={
|
<For each={getHierarchy()}>
|
||||||
itemIndex() === getHierarchy().length - 1 ? "sr-only" : "mx-2 text-slate-300 dark:text-slate-700"
|
{(item, itemIndex) => (
|
||||||
}
|
<>
|
||||||
>
|
<HighlightQuery text={item} query={props.query} />
|
||||||
/
|
<span
|
||||||
</span>
|
class={
|
||||||
</>
|
itemIndex() === getHierarchy().length - 1
|
||||||
)}
|
? "sr-only"
|
||||||
</For>
|
: "mx-2 text-slate-300 dark:text-slate-700"
|
||||||
</div>
|
}
|
||||||
)}
|
>
|
||||||
</li>
|
/
|
||||||
);
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchResults() {
|
function SearchResults() {
|
||||||
const { results, query } = useContext(SearchContext);
|
const { results, query } = useContext(SearchContext);
|
||||||
|
|
||||||
if (results().length === 0) {
|
if (results().length === 0) {
|
||||||
return (
|
return (
|
||||||
<p class="px-4 py-8 text-center text-sm text-slate-700">
|
<p class="px-4 py-8 text-center text-sm text-slate-700">
|
||||||
Aucun résultat pour “
|
Aucun résultat pour “
|
||||||
<span class="break-words text-slate-900">{query()}</span>
|
<span class="break-words text-slate-900">{query()}</span>
|
||||||
”
|
”
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul>
|
<ul>
|
||||||
<For each={results()}>
|
<For each={results()}>
|
||||||
{(result) => (
|
{(result) => (
|
||||||
<li>
|
<li>
|
||||||
<SearchResultItem result={result} query={query()} />
|
<SearchResultItem result={result} query={query()} />
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchDialog(props: { class?: string }) {
|
function SearchDialog(props: { class?: string }) {
|
||||||
const { close, isOpened, setIsOpened, results } = useContext(SearchContext);
|
const { close, isOpened, setIsOpened, results } = useContext(SearchContext);
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (isOpened()) return;
|
if (isOpened()) return;
|
||||||
|
|
||||||
function onKeyDown(event: KeyboardEvent) {
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
|
if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setIsOpened(true);
|
setIsOpened(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", onKeyDown);
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
};
|
};
|
||||||
}, [isOpened, setIsOpened]);
|
}, [isOpened, setIsOpened]);
|
||||||
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
const { target, currentTarget } = event;
|
const { target, currentTarget } = event;
|
||||||
|
|
||||||
if (target instanceof Node && currentTarget instanceof Node) {
|
if (target instanceof Node && currentTarget instanceof Node) {
|
||||||
if (target === currentTarget) close();
|
if (target === currentTarget) close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog isOpen={isOpened()} onClose={close} class={clsx("fixed inset-0 z-50", props.class)}>
|
<Dialog
|
||||||
<div class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm" />
|
isOpen={isOpened()}
|
||||||
|
onClose={close}
|
||||||
|
class={clsx("fixed inset-0 z-50", props.class)}
|
||||||
|
>
|
||||||
|
<div class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm" />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 overflow-y-auto px-4 py-4 sm:px-6 sm:py-20 md:py-32 lg:px-8 lg:py-[15vh]"
|
class="fixed inset-0 overflow-y-auto px-4 py-4 sm:px-6 sm:py-20 md:py-32 lg:px-8 lg:py-[15vh]"
|
||||||
onClick={handleClickOutside}
|
onClick={handleClickOutside}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Escape") close();
|
if (event.key === "Escape") close();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogPanel class="mx-auto transform-gpu overflow-hidden rounded-xl bg-white shadow-xl sm:max-w-xl">
|
<DialogPanel class="mx-auto transform-gpu overflow-hidden rounded-xl bg-white shadow-xl sm:max-w-xl">
|
||||||
<form onSubmit={(event) => event.preventDefault()}>
|
<form onSubmit={(event) => event.preventDefault()}>
|
||||||
<SearchInput />
|
<SearchInput />
|
||||||
<div class="border-t border-slate-200 bg-white px-2 py-3 empty:hidden">
|
<div class="border-t border-slate-200 bg-white px-2 py-3 empty:hidden">
|
||||||
{results().length > 0 && <SearchResults />}
|
{results().length > 0 && <SearchResults />}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DialogPanel>
|
</DialogPanel>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Search() {
|
export function Search() {
|
||||||
const [results, setResults] = createSignal<SearchResult[]>([]);
|
const [results, setResults] = createSignal<SearchResult[]>([]);
|
||||||
const [modifierKey, setModifierKey] = createSignal<string>();
|
const [modifierKey, setModifierKey] = createSignal<string>();
|
||||||
const [isLoading, setIsLoading] = createSignal(false);
|
const [isLoading, setIsLoading] = createSignal(false);
|
||||||
const [isOpened, setIsOpened] = createSignal(false);
|
const [isOpened, setIsOpened] = createSignal(false);
|
||||||
const [query, setQuery] = createSignal("");
|
const [query, setQuery] = createSignal("");
|
||||||
|
|
||||||
const debouncedQuery = useDebounce(query, 300);
|
const debouncedQuery = useDebounce(query, 300);
|
||||||
|
|
||||||
const onSearch = async (query: string) => {
|
const onSearch = async (query: string) => {
|
||||||
const response = await fetch(`/search?query=${query}`);
|
const response = await fetch(`/search?query=${query}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Network response was not ok");
|
throw new Error("Network response was not ok");
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const platform = navigator.userAgentData?.platform || navigator.platform;
|
const platform = navigator.userAgentData?.platform || navigator.platform;
|
||||||
setModifierKey(/(Mac|iPhone|iPod|iPad)/i.test(platform) ? "⌘" : "Ctrl ");
|
setModifierKey(/(Mac|iPhone|iPod|iPad)/i.test(platform) ? "⌘" : "Ctrl ");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const query = debouncedQuery();
|
const query = debouncedQuery();
|
||||||
|
|
||||||
if (query.length === 0) {
|
if (query.length === 0) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
onSearch(query)
|
onSearch(query)
|
||||||
.then(setResults)
|
.then(setResults)
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SearchContext.Provider
|
<SearchContext.Provider
|
||||||
value={{
|
value={{
|
||||||
query,
|
query,
|
||||||
close: () => setIsOpened(false),
|
close: () => setIsOpened(false),
|
||||||
results,
|
results,
|
||||||
isLoading,
|
isLoading,
|
||||||
isOpened,
|
isOpened,
|
||||||
setQuery,
|
setQuery,
|
||||||
setIsOpened,
|
setIsOpened,
|
||||||
setIsLoading,
|
setIsLoading,
|
||||||
setResults,
|
setResults,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="group flex h-6 w-6 items-center justify-center sm:justify-start md:h-auto md:w-80 md:flex-none md:rounded-lg md:py-2.5 md:pr-3.5 md:pl-4 md:text-sm md:ring-1 md:ring-slate-200 md:hover:ring-slate-300 lg:w-96"
|
class="group flex h-6 w-6 items-center justify-center sm:justify-start md:h-auto md:w-80 md:flex-none md:rounded-lg md:py-2.5 md:pr-3.5 md:pl-4 md:text-sm md:ring-1 md:ring-slate-200 md:hover:ring-slate-300 lg:w-96"
|
||||||
onClick={() => setIsOpened(true)}
|
onClick={() => setIsOpened(true)}
|
||||||
>
|
>
|
||||||
<SearchIcon class="h-5 w-5 flex-none fill-slate-400 group-hover:fill-slate-500 md:group-hover:fill-slate-400" />
|
<SearchIcon class="h-5 w-5 flex-none fill-slate-400 group-hover:fill-slate-500 md:group-hover:fill-slate-400" />
|
||||||
<span class="sr-only md:not-sr-only md:ml-2 md:text-slate-500">Rechercher...</span>
|
<span class="sr-only md:not-sr-only md:ml-2 md:text-slate-500">
|
||||||
{modifierKey && (
|
Rechercher...
|
||||||
<kbd class="ml-auto hidden font-medium text-slate-400 md:block">
|
</span>
|
||||||
<kbd class="font-sans">{modifierKey()}</kbd>
|
{modifierKey && (
|
||||||
<kbd class="font-sans">K</kbd>
|
<kbd class="ml-auto hidden font-medium text-slate-400 md:block">
|
||||||
</kbd>
|
<kbd class="font-sans">{modifierKey()}</kbd>
|
||||||
)}
|
<kbd class="font-sans">K</kbd>
|
||||||
</button>
|
</kbd>
|
||||||
<SearchDialog />
|
)}
|
||||||
</SearchContext.Provider>
|
</button>
|
||||||
);
|
<SearchDialog />
|
||||||
|
</SearchContext.Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,13 +30,13 @@ Le design pattern MVC est un modèle d'architecture logicielle qui sépare les d
|
|||||||
- **Contrôleur** : fait le lien entre le modèle et la vue. Il contient la logique métier de l'application.
|
- **Contrôleur** : fait le lien entre le modèle et la vue. Il contient la logique métier de l'application.
|
||||||
|
|
||||||
<Callout type="warning" title="Les schémas disponibles en ligne">
|
<Callout type="warning" title="Les schémas disponibles en ligne">
|
||||||
Il existe de nombreux schémas qui expliquent le design pattern MVC mais ils ne sont pas tous corrects. Enfin, si, ils
|
Il existe de nombreux schémas qui expliquent le design pattern MVC mais ils ne sont pas tous corrects.
|
||||||
sont corrects... mais certains ne s'appliquent pas à tous les frameworks et architectures.
|
Enfin, si, ils sont corrects... mais certains ne s'appliquent pas à tous les frameworks et architectures.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
Pour t'aider à mieux te représenter un schéma MVC avec les ordres de flux de données et de contrôle :
|
Pour t'aider à mieux te représenter un schéma MVC avec les ordres de flux de données et de contrôle :
|
||||||
|
|
||||||
<Image alt="Schéma MVC pour une application web basique" src="/images/patterns/mvc.webp" class="max-h-96 mx-auto" />
|
<Image alt="Schéma MVC pour une application web basique" src="/patterns/mvc.webp" class="max-h-96 mx-auto" />
|
||||||
|
|
||||||
<Callout type="question" title="Pourquoi la Vue ne retourne pas directement au client ?">
|
<Callout type="question" title="Pourquoi la Vue ne retourne pas directement au client ?">
|
||||||
La vue ne retourne pas directement au client car elle doit passer par le contrôleur.
|
La vue ne retourne pas directement au client car elle doit passer par le contrôleur.
|
||||||
@ -49,10 +49,8 @@ Pour t'aider à mieux te représenter un schéma MVC avec les ordres de flux de
|
|||||||
Le concept est simple : chaque partie de l'application a un **rôle bien défini** et ne doit pas empiéter sur le rôle des autres.
|
Le concept est simple : chaque partie de l'application a un **rôle bien défini** et ne doit pas empiéter sur le rôle des autres.
|
||||||
|
|
||||||
<Callout type="question" title="Et si j'ai des middlewares ?">
|
<Callout type="question" title="Et si j'ai des middlewares ?">
|
||||||
Dans la majorité des cas, les middlewares s'exécutent avant le contrôleur même si on peut en avoir à différents
|
Dans la majorité des cas, les middlewares s'exécutent avant le contrôleur même si on peut en avoir à différents moments de la circulation de la donnée.
|
||||||
moments de la circulation de la donnée. Si tu as déjà utilisé Express, tu as probablement déjà utilisé un middleware
|
Si tu as déjà utilisé Express, tu as probablement déjà utilisé un middleware pour vérifier si l'utilisateur est connecté avant de lui afficher une page qui est réservée aux utilisateurs connectés.
|
||||||
pour vérifier si l'utilisateur est connecté avant de lui afficher une page qui est réservée aux utilisateurs
|
|
||||||
connectés.
|
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
<Callout type="note" title="Le cas de React (ou Vue, Angular, Solid, etc.)">
|
<Callout type="note" title="Le cas de React (ou Vue, Angular, Solid, etc.)">
|
||||||
@ -76,8 +74,9 @@ Si tu fais un projet personnel, tu peux définir les tiennes, du moment que tu e
|
|||||||
Pense à être cohérent en ce qui concerne la langue utilisée.
|
Pense à être cohérent en ce qui concerne la langue utilisée.
|
||||||
|
|
||||||
<Callout type="warning" title="Pas de franglais !">
|
<Callout type="warning" title="Pas de franglais !">
|
||||||
Évite de mélanger plusieurs langues dans tes nommages. Si tu choisis de travailler en français, reste en français. Si
|
Évite de mélanger plusieurs langues dans tes nommages.
|
||||||
tu choisis de travailler en anglais, reste en anglais.
|
Si tu choisis de travailler en français, reste en français.
|
||||||
|
Si tu choisis de travailler en anglais, reste en anglais.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
D'ailleurs, je te recommande chaudement de travailler en anglais ne serait-ce que pour te familiariser avec la langue de Shakespeare qui est, on le rappelle, la langue la plus répandue dans le monde de l'informatique.
|
D'ailleurs, je te recommande chaudement de travailler en anglais ne serait-ce que pour te familiariser avec la langue de Shakespeare qui est, on le rappelle, la langue la plus répandue dans le monde de l'informatique.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
Loading…
Reference in New Issue
Block a user