style: Improve code formatting for better readability
This commit is contained in:
parent
e4f1152723
commit
20cb60be16
@ -8,123 +8,102 @@ 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) => link.href === props.href || link.subitems.some((subitem) => subitem.href === props.href),
|
||||||
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">
|
<dt class="font-display text-sm font-medium text-slate-900">{props.dir === "next" ? "Suivant" : "Précédent"}</dt>
|
||||||
{props.dir === "next" ? "Suivant" : "Précédent"}
|
<dd class="mt-1">
|
||||||
</dt>
|
<Link
|
||||||
<dd class="mt-1">
|
href={props.href}
|
||||||
<Link
|
class={clsx(
|
||||||
href={props.href}
|
"flex items-center gap-x-2 text-base font-semibold text-slate-500 hover:text-slate-600",
|
||||||
class={clsx(
|
props.dir === "previous" && "flex-row-reverse",
|
||||||
"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() && (
|
||||||
<p class="flex flex-col gap-0">
|
<span class="text-violet-600 text-sm mb-1 leading-3">{getPageCategory()?.title}</span>
|
||||||
{getPageCategory() && (
|
)}
|
||||||
<span class="text-violet-600 text-sm mb-1 leading-3">
|
<span class="leading-4">{props.title}</span>
|
||||||
{getPageCategory()?.title}
|
</p>
|
||||||
</span>
|
<ArrowIcon class={clsx("h-6 w-6 flex-none fill-current", props.dir === "previous" && "-scale-x-100")} />
|
||||||
)}
|
</Link>
|
||||||
<span class="leading-4">{props.title}</span>
|
</dd>
|
||||||
</p>
|
</div>
|
||||||
<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(
|
const linkIndex = allLinks.findIndex((link) => link.href === pageContext.urlPathname);
|
||||||
(link) => link.href === pageContext.urlPathname,
|
if (linkIndex === -1) return [null, null];
|
||||||
);
|
|
||||||
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] && (
|
{getNeighboringLinks()[0] && <PageLink dir="previous" {...(getNeighboringLinks()[0] as NavigationSubItem)} />}
|
||||||
<PageLink
|
|
||||||
dir="previous"
|
|
||||||
{...(getNeighboringLinks()[0] as NavigationSubItem)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{getNeighboringLinks()[1] && (
|
{getNeighboringLinks()[1] && (
|
||||||
<PageLink
|
<PageLink class="ml-auto text-right" dir="next" {...(getNeighboringLinks()[1] as NavigationSubItem)} />
|
||||||
class="ml-auto text-right"
|
)}
|
||||||
{...(getNeighboringLinks()[1] as NavigationSubItem)}
|
</dl>
|
||||||
/>
|
);
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,7 @@
|
|||||||
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 {
|
import { createContext, useContext, For, createEffect, createSignal } from "solid-js";
|
||||||
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";
|
||||||
@ -18,334 +12,305 @@ 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
|
<path stroke={`url(#${id})`} stroke-linecap="round" stroke-linejoin="round" d="M15.5 10a5.5 5.5 0 1 0-5.5 5.5" />
|
||||||
stroke={`url(#${id})`}
|
<defs>
|
||||||
stroke-linecap="round"
|
<linearGradient id={id} x1="13" x2="9.5" y1="9" y2="15" gradientUnits="userSpaceOnUse">
|
||||||
stroke-linejoin="round"
|
<stop stop-color="currentColor" />
|
||||||
d="M15.5 10a5.5 5.5 0 1 0-5.5 5.5"
|
<stop offset="1" stop-color="currentColor" stop-opacity="0" />
|
||||||
/>
|
</linearGradient>
|
||||||
<defs>
|
</defs>
|
||||||
<linearGradient
|
</svg>
|
||||||
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(
|
return section.links.find((link) => link.href === props.result.url.split("#")[0]);
|
||||||
(link) => link.href === props.result.url.split("#")[0],
|
})?.title;
|
||||||
);
|
|
||||||
})?.title;
|
|
||||||
|
|
||||||
return [sectionTitle, props.result.pageTitle].filter(
|
return [sectionTitle, props.result.pageTitle].filter((x): x is string => typeof x === "string");
|
||||||
(x): x is string => typeof x === "string",
|
};
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
const handleNavigate = (url: string) => {
|
||||||
<li
|
navigate(`/${url}`.replace(/\/+/g, "/"));
|
||||||
class="group block cursor-default rounded-lg px-3 py-2 aria-selected:bg-slate-100 hover:bg-slate-100"
|
close();
|
||||||
aria-labelledby={`${id}-hierarchy ${id}-title`}
|
};
|
||||||
tab-index={0}
|
|
||||||
onKeyDown={(event) => {
|
return (
|
||||||
if (event.key === "Enter") {
|
<li
|
||||||
navigate(props.result.url);
|
class="group block cursor-default rounded-lg px-3 py-2 aria-selected:bg-slate-100 hover:bg-slate-100"
|
||||||
close();
|
aria-labelledby={`${id}-hierarchy ${id}-title`}
|
||||||
}
|
tab-index={0}
|
||||||
}}
|
onKeyDown={(event) => {
|
||||||
onClick={() => {
|
if (event.key === "Enter") handleNavigate(props.result.url);
|
||||||
navigate(props.result.url);
|
}}
|
||||||
close();
|
onClick={() => handleNavigate(props.result.url)}
|
||||||
}}
|
>
|
||||||
>
|
<div id={`${id}-title`} aria-hidden="true" class="text-sm text-slate-700 group-aria-selected:text-violet-600">
|
||||||
<div
|
<HighlightQuery text={props.result.title} query={props.query} />
|
||||||
id={`${id}-title`}
|
</div>
|
||||||
aria-hidden="true"
|
{getHierarchy().length > 0 && (
|
||||||
class="text-sm text-slate-700 group-aria-selected:text-violet-600"
|
<div
|
||||||
>
|
id={`${id}-hierarchy`}
|
||||||
<HighlightQuery text={props.result.title} query={props.query} />
|
aria-hidden="true"
|
||||||
</div>
|
class="mt-0.5 truncate text-xs whitespace-nowrap text-slate-500 dark:text-slate-400"
|
||||||
{getHierarchy().length > 0 && (
|
>
|
||||||
<div
|
<For each={getHierarchy()}>
|
||||||
id={`${id}-hierarchy`}
|
{(item, itemIndex) => (
|
||||||
aria-hidden="true"
|
<>
|
||||||
class="mt-0.5 truncate text-xs whitespace-nowrap text-slate-500 dark:text-slate-400"
|
<HighlightQuery text={item} query={props.query} />
|
||||||
>
|
<span
|
||||||
<For each={getHierarchy()}>
|
class={
|
||||||
{(item, itemIndex) => (
|
itemIndex() === getHierarchy().length - 1 ? "sr-only" : "mx-2 text-slate-300 dark:text-slate-700"
|
||||||
<>
|
}
|
||||||
<HighlightQuery text={item} query={props.query} />
|
>
|
||||||
<span
|
/
|
||||||
class={
|
</span>
|
||||||
itemIndex() === getHierarchy().length - 1
|
</>
|
||||||
? "sr-only"
|
)}
|
||||||
: "mx-2 text-slate-300 dark:text-slate-700"
|
</For>
|
||||||
}
|
</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
|
<Dialog isOpen={isOpened()} onClose={close} class={clsx("fixed inset-0 z-50", props.class)}>
|
||||||
isOpen={isOpened()}
|
<div class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm" />
|
||||||
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">
|
<span class="sr-only md:not-sr-only md:ml-2 md:text-slate-500">Rechercher...</span>
|
||||||
Rechercher...
|
{modifierKey && (
|
||||||
</span>
|
<kbd class="ml-auto hidden font-medium text-slate-400 md:block">
|
||||||
{modifierKey && (
|
<kbd class="font-sans">{modifierKey()}</kbd>
|
||||||
<kbd class="ml-auto hidden font-medium text-slate-400 md:block">
|
<kbd class="font-sans">K</kbd>
|
||||||
<kbd class="font-sans">{modifierKey()}</kbd>
|
</kbd>
|
||||||
<kbd class="font-sans">K</kbd>
|
)}
|
||||||
</kbd>
|
</button>
|
||||||
)}
|
<SearchDialog />
|
||||||
</button>
|
</SearchContext.Provider>
|
||||||
<SearchDialog />
|
);
|
||||||
</SearchContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,39 +30,43 @@ 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.
|
Il existe de nombreux schémas qui expliquent le design pattern MVC mais ils ne sont pas tous corrects. Enfin, si, ils
|
||||||
Enfin, si, ils sont corrects... mais certains ne s'appliquent pas à tous les frameworks et architectures.
|
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="/patterns/mvc.webp" class="max-h-96 mx-auto" />
|
<Image alt="Schéma MVC pour une application web basique" src="/images/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.
|
||||||
On ne s'en rend pas forcément compte, mais la vue est généralement générée par le contrôleur via un moteur de template _(EJS, Twig, etc.)_.
|
On ne s'en rend pas forcément compte, mais la vue est généralement générée par le contrôleur via un moteur de template _(EJS, Twig, etc.)_.
|
||||||
|
|
||||||
Une fois le HTML généré, le contrôleur s'occupe de l'envoyer dans la réponse HTTP au client.
|
Une fois le HTML généré, le contrôleur s'occupe de l'envoyer dans la réponse HTTP au client.
|
||||||
C'est ce qui permet de garder une séparation entre la logique métier et l'interface utilisateur.
|
C'est ce qui permet de garder une séparation entre la logique métier et l'interface utilisateur.
|
||||||
|
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
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 moments de la circulation de la donnée.
|
Dans la majorité des cas, les middlewares s'exécutent avant le contrôleur même si on peut en avoir à différents
|
||||||
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.
|
moments de la circulation de la donnée. 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.
|
||||||
</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.)">
|
||||||
D'après toi, est-ce que React doit être considéré comme la vue dans le design pattern MVC ?
|
D'après toi, est-ce que React doit être considéré comme la vue dans le design pattern MVC ?
|
||||||
La réponse est **non** !
|
La réponse est **non** !
|
||||||
|
|
||||||
React est une bibliothèque _(pas une "librarie" et encore moins un framework ⚠️)_ JavaScript qui permet de créer des interfaces utilisateur, mais elle n'est pas liée de manière directe à un serveur.
|
React est une bibliothèque _(pas une "librarie" et encore moins un framework ⚠️)_ JavaScript qui permet de créer des interfaces utilisateur, mais elle n'est pas liée de manière directe à un serveur.
|
||||||
Certes, on va consommer une API pour récupérer des données, mais React n'est que le réceptacle de ces données côté client _(navigateur)_.
|
Certes, on va consommer une API pour récupérer des données, mais React n'est que le réceptacle de ces données côté client _(navigateur)_.
|
||||||
|
|
||||||
|
On va donc faire simple : on parlera plutôt d'une architecture "client-serveur" avec React côté client et notre API côté serveur.
|
||||||
|
Mais ça n'empêche pas que ton API puisse être une API REST _(ou GraphQL)_ qui respecte le design pattern MVC !
|
||||||
|
Tout dépendra de si tu demandes dans ton serveur back-end de retourner une vue _(HTML)_ au navigateur.
|
||||||
|
|
||||||
On va donc faire simple : on parlera plutôt d'une architecture "client-serveur" avec React côté client et notre API côté serveur.
|
|
||||||
Mais ça n'empêche pas que ton API puisse être une API REST _(ou GraphQL)_ qui respecte le design pattern MVC !
|
|
||||||
Tout dépendra de si tu demandes dans ton serveur back-end de retourner une vue _(HTML)_ au navigateur.
|
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
## 🧑⚖️ Règles et conventions de nommage
|
## 🧑⚖️ Règles et conventions de nommage
|
||||||
@ -73,15 +77,15 @@ Si tu fais un projet personnel, tu peux définir les tiennes, du moment que tu e
|
|||||||
<Callout type="note" title="La cohérence, c'est la clé">
|
<Callout type="note" title="La cohérence, c'est la clé">
|
||||||
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.
|
Évite de mélanger plusieurs langues dans tes nommages. Si tu choisis de travailler en français, reste en français. Si
|
||||||
Si tu choisis de travailler en français, reste en français.
|
tu choisis de travailler en anglais, reste en anglais.
|
||||||
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.
|
||||||
|
|
||||||
|
Tu as évidemment le droit d'utiliser des traducteurs en ligne pour t'aider à trouver le bon mot _(ou la bonne expression)_, on ne te demande pas d'être bilingue !
|
||||||
|
|
||||||
Tu as évidemment le droit d'utiliser des traducteurs en ligne pour t'aider à trouver le bon mot _(ou la bonne expression)_, on ne te demande pas d'être bilingue !
|
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
Au delà de la langue utilisée, on va également parler de la syntaxe des noms de fichiers, dossiers, classes, méthodes, variables, etc.
|
Au delà de la langue utilisée, on va également parler de la syntaxe des noms de fichiers, dossiers, classes, méthodes, variables, etc.
|
||||||
@ -117,9 +121,10 @@ Si on prend l'exemple d'un formulaire d'inscription où nous vérifions que l'ut
|
|||||||
Si je te parle de client HTTP, tu me réponds... ?
|
Si je te parle de client HTTP, tu me réponds... ?
|
||||||
[Bruno](https://www.usebruno.com/) ? [Postman](https://www.postman.com/) ? [Insomnia](https://insomnia.rest/) ?
|
[Bruno](https://www.usebruno.com/) ? [Postman](https://www.postman.com/) ? [Insomnia](https://insomnia.rest/) ?
|
||||||
|
|
||||||
Bingo ! 🎉
|
Bingo ! 🎉
|
||||||
|
|
||||||
|
Utiliser un client HTTP comme Bruno, Postman ou Insomnia te permettra de tester facilement les routes de ton API, et de vérifier que les données que tu envoies sont bien traitées par ton serveur.
|
||||||
|
|
||||||
Utiliser un client HTTP comme Bruno, Postman ou Insomnia te permettra de tester facilement les routes de ton API, et de vérifier que les données que tu envoies sont bien traitées par ton serveur.
|
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
### 🧪 Les tests unitaires
|
### 🧪 Les tests unitaires
|
||||||
|
|||||||
BIN
app/public/images/patterns/mvc.webp
Normal file
BIN
app/public/images/patterns/mvc.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Loading…
Reference in New Issue
Block a user