From d5aea7dcf8fe5b2280daa4d38fe1ea62c3c33b33 Mon Sep 17 00:00:00 2001 From: Damien Date: Mon, 18 Nov 2024 20:32:24 +0100 Subject: [PATCH] Composant `FilAriane` --- packages/core/src/router/index.ts | 60 ++++---- packages/docs/.storybook/preview.tsx | 10 ++ packages/docs/layout/FilAriane.stories.tsx | 49 +++++++ packages/layout/src/focus4.layout.tsx | 3 + .../src/presentation/__style__/fil-ariane.css | 37 +++++ .../layout/src/presentation/fil-ariane.tsx | 132 ++++++++++++++++++ packages/layout/src/presentation/index.ts | 2 + .../layout/src/presentation/lateral-menu.tsx | 2 +- packages/layout/src/translation/icons.ts | 5 + packages/styling/src/theme/theme-provider.tsx | 1 + scripts/docgen.mjs | 1 + 11 files changed, 270 insertions(+), 32 deletions(-) create mode 100644 packages/docs/layout/FilAriane.stories.tsx create mode 100644 packages/layout/src/presentation/__style__/fil-ariane.css create mode 100644 packages/layout/src/presentation/fil-ariane.tsx diff --git a/packages/core/src/router/index.ts b/packages/core/src/router/index.ts index 92b71586..b3fb312b 100644 --- a/packages/core/src/router/index.ts +++ b/packages/core/src/router/index.ts @@ -14,23 +14,22 @@ export type UrlRouteDescriptor = (C extends Param ) => C[K] extends ParamDef ? UrlRouteDescriptor : C[K] extends ParamDef - ? void - : UrlRouteDescriptor) & {spec: C}; + ? void + : UrlRouteDescriptor) & {spec: C}; /** Callback permettant de décrire une URL. */ -export type UrlPathDescriptor = - C extends ParamDef, infer V> - ? (param: T) => UrlPathDescriptor - : ( - x: K - ) => C[K] extends ParamDef - ? UrlPathDescriptor - : C[K] extends ParamDef - ? void - : UrlPathDescriptor; +export type UrlPathDescriptor = C extends ParamDef, infer V> + ? (param: T) => UrlPathDescriptor + : ( + x: K + ) => C[K] extends ParamDef + ? UrlPathDescriptor + : C[K] extends ParamDef + ? void + : UrlPathDescriptor; /** Router correspondant à la config donnée. */ -export interface Router { +export interface Router { /** Valeurs des paramètres de query. */ readonly query: QueryParams; @@ -41,7 +40,7 @@ export interface Router { * Si la route demandée est active, retourne le morceau de route suivant. * @param predicate Callback décrivant la route. */ - get(predicate: (x: UrlRouteDescriptor) => UrlRouteDescriptor): keyof C2 | undefined; + get(predicate: (x: UrlRouteDescriptor) => UrlRouteDescriptor): (keyof C2 & string) | undefined; /** * Récupère l'URL correspondante à la route demandée. @@ -101,14 +100,13 @@ export interface RouterConstraintBuilder { } /** Type décrivant l'objet de valeurs de paramètre d'un routeur de configuration quelconque. */ -export type ParamObject = - C extends ParamDef, ParamDef>> - ? Record & Record - : C extends ParamDef, infer U> - ? Record & {readonly [P in keyof U]: ParamObject} - : { - readonly [P in keyof C]: ParamObject; - }; +export type ParamObject = C extends ParamDef, ParamDef>> + ? Record & Record + : C extends ParamDef, infer U> + ? Record & {readonly [P in keyof U]: ParamObject} + : { + readonly [P in keyof C]: ParamObject; + }; /** * `makeRouter` permet de construire le routeur de l'application. @@ -316,7 +314,7 @@ export function makeRouter( return undefined; }) - }) as RouteConfig + } as RouteConfig) ), { // Route non matchée => on revient là où on était avant (ou à la racine si premier appel). @@ -556,14 +554,14 @@ function buildQueryMap(query: Q, object: QueryParams value === undefined ? undefined : query[key] === "number" - ? parseFloat(value) - : query[key] === "boolean" - ? value === "true" - ? true - : value === "false" - ? false - : Number.NaN - : value; + ? parseFloat(value) + : query[key] === "boolean" + ? value === "true" + ? true + : value === "false" + ? false + : Number.NaN + : value; (object as any)[key] = newValue; return newValue; }; diff --git a/packages/docs/.storybook/preview.tsx b/packages/docs/.storybook/preview.tsx index 068da0c7..08531e16 100644 --- a/packages/docs/.storybook/preview.tsx +++ b/packages/docs/.storybook/preview.tsx @@ -33,6 +33,15 @@ i18next.init({ ...collections.fr, ...forms.fr, icons: {...collections.icons, ...forms.icons, ...layout.icons} + }, + router: { + root: "Accueil", + utilisateurs: { + root: "Utilisateurs", + utiId: { + root: "Détail de l'utilisateur : {{param}}" + } + } } } } @@ -77,6 +86,7 @@ export default { "Scrollable", "HeaderScrolling", "MainMenu", + "FilAriane", "Panel", "ScrollspyContainer", "Popin", diff --git a/packages/docs/layout/FilAriane.stories.tsx b/packages/docs/layout/FilAriane.stories.tsx new file mode 100644 index 00000000..864aefb7 --- /dev/null +++ b/packages/docs/layout/FilAriane.stories.tsx @@ -0,0 +1,49 @@ +import {FilAriane} from "@focus4/layout"; + +import {FilArianeMeta} from "./metas/fil-ariane"; + +import type {Meta, StoryObj} from "@storybook/react"; + +export default { + ...FilArianeMeta, + tags: ["autodocs"], + title: "Mise en page/FilAriane" +} as Meta; + +const router = { + get() { + return "utilisateurs"; + }, + href() { + return "#/"; + }, + sub() { + return { + get() { + return "utiId"; + }, + href() { + return window.location.href; + }, + sub() { + return { + get() { + return undefined; + } + }; + }, + state: { + utiId: 1 + } + }; + }, + state: { + utilisateurs: {} + } +} as any; + +export const Showcase: StoryObj = { + render(props) { + return ; + } +}; diff --git a/packages/layout/src/focus4.layout.tsx b/packages/layout/src/focus4.layout.tsx index 1f05e34f..6699a181 100644 --- a/packages/layout/src/focus4.layout.tsx +++ b/packages/layout/src/focus4.layout.tsx @@ -8,10 +8,12 @@ export {Panel, PanelButtons, ScrollspyContainer, ScrollspyMenu, panelCss, scroll export { Content, Dialog, + FilAriane, LateralMenu, Popin, Scrollable, dialogCss, + filArianeCss, lateralMenuCss, layoutCss, overlayCss, @@ -29,6 +31,7 @@ export type {MainMenuProps, MainMenuCss} from "./menu"; export type {PanelButtonsProps, PanelCss, PanelProps, ScrollspyContainerProps, ScrollspyContainerRef} from "./panels"; export type { DialogCss, + FilArianeCss, LateralMenuCss, LateralMenuProps, LayoutCss, diff --git a/packages/layout/src/presentation/__style__/fil-ariane.css b/packages/layout/src/presentation/__style__/fil-ariane.css new file mode 100644 index 00000000..61ca08ba --- /dev/null +++ b/packages/layout/src/presentation/__style__/fil-ariane.css @@ -0,0 +1,37 @@ +:root { + --fil-ariane-spacing: var(--button-spacing); + --fil-ariane-height: 20px; + --fil-ariane-font-size: var(--font-size-small); + --fil-ariane-item-color: rgb(var(--color-black)); + --fil-ariane-item-active-color: rgb(var(--color-primary)); +} + +.container { + display: inline-flex; + height: var(--fil-ariane-height); + padding: var(--fil-ariane-spacing) calc(2 * var(--fil-ariane-spacing)); + gap: var(--fil-ariane-spacing); + align-items: center; +} + +.item { + color: var(--fil-ariane-item-color); + font-size: var(--fil-ariane-font-size); +} + +a.item { + text-decoration: none; +} + +a.item:hover { + text-decoration: underline; +} + +.item--active { + color: var(--fil-ariane-item-active-color); + font-weight: var(--font-weight-bold); +} + +.separator { + font-size: var(--fil-ariane-height); +} diff --git a/packages/layout/src/presentation/fil-ariane.tsx b/packages/layout/src/presentation/fil-ariane.tsx new file mode 100644 index 00000000..f65322e1 --- /dev/null +++ b/packages/layout/src/presentation/fil-ariane.tsx @@ -0,0 +1,132 @@ +/* eslint-disable no-unmodified-loop-condition */ +/* eslint-disable @typescript-eslint/no-loop-func */ +import i18next from "i18next"; +import {useObserver} from "mobx-react"; +import {Fragment} from "react/jsx-runtime"; + +import {Router} from "@focus4/core"; +import {UrlRouteDescriptor} from "@focus4/core/lib/router"; +import {CSSProp, useTheme} from "@focus4/styling"; +import {FontIcon} from "@focus4/toolbox"; + +import filArianeCss, {FilArianeCss} from "./__style__/fil-ariane.css"; +export {filArianeCss}; +export type {FilArianeCss}; + +/** + * Props pour le fil d'Ariane. + */ +export interface FilArianeProps { + /** Préfixe i18n pour l'icône du séparateur. Par défaut : "focus". */ + i18nPrefix?: string; + /** Si renseigné, n'affiche pas de nouvel élément dans le fil d'ariane au delà de la profondeur demandée. */ + maxDepth?: number; + /** Permet de résoudre une valeur personnalisée pour la variable {{param}} dans les traductions en fonction du paramètre et de sa valeur. */ + paramResolver?: (paramName: string, paramValue: string | number) => string | undefined; + /** Nom de la clé i18n pour l'élement racine de chaque route. Par défaut : "root". */ + rootName?: string; + /** Routeur sur lequel construire le fil d'ariane. */ + router: Router; + /** Préfixe i18n pour les clés de traductions calculées par route. Par défaut : "router". */ + routerI18nPrefix?: string; + /** CSS. */ + theme?: CSSProp; +} + +/** + * Le composant `FilAriane` permet de poser un "fil d'Ariane", construit automatiquement à partir d'un routeur. + * + * Il se basera sur la route active dans le routeur pour proposer un lien vers chacune des sections d'URL qui la contienne. + * Le libellé de chaque lien doit être décrit dans les fichiers de traductions i18n du projet. + * + * Par exemple, pour la route `/utilisateurs/:utiId`, l'objet i18n doit être décrit de la forme : + * ``` + * const router = { + * root: "Accueil", + * utilisateurs: { + * root: "Utilisateurs", + * utiId: { + * root: "Détail de l'utilisateur : {{param}}" + * } + * } + * } + * ``` + * + * _Remarque : (Les noms `router` et `root` sont paramétrables)._ + * + * Pour les libellés correspondant à des paramètres (comme `utiId` dans l'exemple précédent), vous pouvez utiliser la variable `{{param}}` + * qui référence soit la valeur du paramètre, soit une valeur calculée par une fonction dédiée `paramResolver` à partir de son nom et de sa valeur. + * + * Si vous voulez qu'une section ne soit pas affichée dans le fil d'ariane, il suffit que son libellé soit vide. + */ +export function FilAriane({ + i18nPrefix = "focus", + maxDepth, + paramResolver = (_, x) => `${x}`, + rootName = "root", + router, + routerI18nPrefix = "router", + theme: pTheme +}: FilArianeProps) { + const theme = useTheme("filAriane", filArianeCss, pTheme); + return useObserver(() => { + let currentRouter = router; + let currentRoute = currentRouter.get(x => x); + const routesList = []; + let key = currentRoute!; + + while (currentRoute !== undefined && (maxDepth === undefined || routesList.length < maxDepth)) { + const currentState = currentRouter.state[currentRoute]; + if ( + typeof currentState === "object" || + typeof currentState === "number" || + typeof currentState === "string" + ) { + routesList.push({ + route: currentRoute, + dictionaryKey: i18next.t(`${routerI18nPrefix}.${key}.${rootName}`, { + param: + typeof currentState !== "object" + ? paramResolver(currentRoute, currentState) ?? currentState + : undefined + }), + url: currentRouter.href(x => x(typeof currentState === "object" ? currentRoute! : currentState)) + }); + + currentRouter = currentRouter.sub(x => x(currentRoute!) as UrlRouteDescriptor); + currentRoute = currentRouter.get(x => x); + + key += `.${currentRoute}`; + } else { + break; + } + } + + if (!routesList.length && (maxDepth ?? Infinity) > 0) { + routesList.push({route: "/", dictionaryKey: i18next.t(`${routerI18nPrefix}.${rootName}`), url: "#/"}); + } + + const finalRoutes = routesList.filter(x => x.dictionaryKey); + return ( + + {finalRoutes.map((x, i) => ( + + {i !== finalRoutes.length - 1 ? ( + + {x.dictionaryKey} + + ) : ( + {x.dictionaryKey} + )} + {i !== finalRoutes.length - 1 ? ( + + ) : null} + + ))} + + ); + }); +} diff --git a/packages/layout/src/presentation/index.ts b/packages/layout/src/presentation/index.ts index bf043b92..be144d12 100644 --- a/packages/layout/src/presentation/index.ts +++ b/packages/layout/src/presentation/index.ts @@ -1,6 +1,7 @@ export {useActiveTransition} from "./active-transition"; export {Content} from "./content"; export {Dialog, dialogCss} from "./dialog"; +export {FilAriane, filArianeCss} from "./fil-ariane"; export {LateralMenu, lateralMenuCss} from "./lateral-menu"; export {LayoutBase, layoutCss} from "./layout"; export {overlayCss} from "./overlay"; @@ -9,6 +10,7 @@ export {Scrollable, scrollableCss} from "./scrollable"; export {useStickyClip} from "./sticky-clip"; export type {DialogCss} from "./dialog"; +export type {FilArianeCss} from "./fil-ariane"; export type {LateralMenuCss, LateralMenuProps} from "./lateral-menu"; export type {LayoutCss, LayoutProps} from "./layout"; export type {OverlayCss} from "./overlay"; diff --git a/packages/layout/src/presentation/lateral-menu.tsx b/packages/layout/src/presentation/lateral-menu.tsx index 40f12be8..8668fbbc 100644 --- a/packages/layout/src/presentation/lateral-menu.tsx +++ b/packages/layout/src/presentation/lateral-menu.tsx @@ -25,7 +25,7 @@ export interface LateralMenuProps { * Il doit être posé dans un conteneur avec un `display: flex`, et s'il est rétractable, alors son contenu devrait idéalement avoir une taille fixe. */ export function LateralMenu({children, headerHeight = 0, retractable = true, theme: pTheme}: LateralMenuProps) { - const theme = useTheme("lateral-menu", lateralMenuCss, pTheme); + const theme = useTheme("lateralMenu", lateralMenuCss, pTheme); const [opened, setOpened] = useState(true); return ( diff --git a/packages/layout/src/translation/icons.ts b/packages/layout/src/translation/icons.ts index 756df353..ad85dc6b 100644 --- a/packages/layout/src/translation/icons.ts +++ b/packages/layout/src/translation/icons.ts @@ -3,5 +3,10 @@ export const icons = { secondary: { name: "more_vert" } + }, + filAriane: { + separator: { + name: "keyboard_arrow_right" + } } }; diff --git a/packages/styling/src/theme/theme-provider.tsx b/packages/styling/src/theme/theme-provider.tsx index 2624b594..7f6a1cd0 100644 --- a/packages/styling/src/theme/theme-provider.tsx +++ b/packages/styling/src/theme/theme-provider.tsx @@ -35,6 +35,7 @@ export interface FocusCSSContext extends CSSContext { // Layout dialog: {}; + filAriane: {}; header: {}; lateralMenu: {}; layout: {}; diff --git a/scripts/docgen.mjs b/scripts/docgen.mjs index 1312a2c2..87119b41 100644 --- a/scripts/docgen.mjs +++ b/scripts/docgen.mjs @@ -223,6 +223,7 @@ generateDocFile("collections", "./packages/collections/src/**/*.tsx", [ generateDocFile("layout", "./packages/layout/src/**/*.tsx", [ "Content", "Dialog", + "FilAriane", "HeaderActions", "HeaderContent", "HeaderItem",