Skip to content

Commit

Permalink
Composant FilAriane
Browse files Browse the repository at this point in the history
  • Loading branch information
JabX committed Nov 18, 2024
1 parent 69c77d7 commit d5aea7d
Show file tree
Hide file tree
Showing 11 changed files with 270 additions and 32 deletions.
60 changes: 29 additions & 31 deletions packages/core/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,22 @@ export type UrlRouteDescriptor<C, _K = unknown, _T = unknown> = (C extends Param
) => C[K] extends ParamDef<infer _1, infer _2, infer _3>
? UrlRouteDescriptor<C[K]>
: C[K] extends ParamDef<infer _4, infer _5>
? void
: UrlRouteDescriptor<C[K]>) & {spec: C};
? void
: UrlRouteDescriptor<C[K]>) & {spec: C};

/** Callback permettant de décrire une URL. */
export type UrlPathDescriptor<C> =
C extends ParamDef<infer _0, Param<infer T>, infer V>
? (param: T) => UrlPathDescriptor<V>
: <K extends keyof C>(
x: K
) => C[K] extends ParamDef<infer _1, infer _2, infer _3>
? UrlPathDescriptor<C[K]>
: C[K] extends ParamDef<infer _4, infer _5>
? void
: UrlPathDescriptor<C[K]>;
export type UrlPathDescriptor<C> = C extends ParamDef<infer _0, Param<infer T>, infer V>
? (param: T) => UrlPathDescriptor<V>
: <K extends keyof C>(
x: K
) => C[K] extends ParamDef<infer _1, infer _2, infer _3>
? UrlPathDescriptor<C[K]>
: C[K] extends ParamDef<infer _4, infer _5>
? void
: UrlPathDescriptor<C[K]>;

/** Router correspondant à la config donnée. */
export interface Router<C, Q extends QueryParamConfig = {}> {
export interface Router<C = any, Q extends QueryParamConfig = {}> {
/** Valeurs des paramètres de query. */
readonly query: QueryParams<Q>;

Expand All @@ -41,7 +40,7 @@ export interface Router<C, Q extends QueryParamConfig = {}> {
* Si la route demandée est active, retourne le morceau de route suivant.
* @param predicate Callback décrivant la route.
*/
get<C2>(predicate: (x: UrlRouteDescriptor<C>) => UrlRouteDescriptor<C2>): keyof C2 | undefined;
get<C2>(predicate: (x: UrlRouteDescriptor<C>) => UrlRouteDescriptor<C2>): (keyof C2 & string) | undefined;

/**
* Récupère l'URL correspondante à la route demandée.
Expand Down Expand Up @@ -101,14 +100,13 @@ export interface RouterConstraintBuilder<C> {
}

/** Type décrivant l'objet de valeurs de paramètre d'un routeur de configuration quelconque. */
export type ParamObject<C = any> =
C extends ParamDef<infer K1, Param<infer T1>, ParamDef<infer K2, Param<infer T2>>>
? Record<K1, T1> & Record<K2, T2>
: C extends ParamDef<infer A3, Param<infer N3>, infer U>
? Record<A3, N3> & {readonly [P in keyof U]: ParamObject<U[P]>}
: {
readonly [P in keyof C]: ParamObject<C[P]>;
};
export type ParamObject<C = any> = C extends ParamDef<infer K1, Param<infer T1>, ParamDef<infer K2, Param<infer T2>>>
? Record<K1, T1> & Record<K2, T2>
: C extends ParamDef<infer A3, Param<infer N3>, infer U>
? Record<A3, N3> & {readonly [P in keyof U]: ParamObject<U[P]>}
: {
readonly [P in keyof C]: ParamObject<C[P]>;
};

/**
* `makeRouter` permet de construire le routeur de l'application.
Expand Down Expand Up @@ -316,7 +314,7 @@ export function makeRouter<C, Q extends QueryParamConfig>(

return undefined;
})
}) as RouteConfig
} as RouteConfig)
),
{
// Route non matchée => on revient là où on était avant (ou à la racine si premier appel).
Expand Down Expand Up @@ -556,14 +554,14 @@ function buildQueryMap<Q extends QueryParamConfig>(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;
};
Expand Down
10 changes: 10 additions & 0 deletions packages/docs/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
}
}
}
}
}
Expand Down Expand Up @@ -77,6 +86,7 @@ export default {
"Scrollable",
"HeaderScrolling",
"MainMenu",
"FilAriane",
"Panel",
"ScrollspyContainer",
"Popin",
Expand Down
49 changes: 49 additions & 0 deletions packages/docs/layout/FilAriane.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof FilAriane>;

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<typeof FilAriane> = {
render(props) {
return <FilAriane {...props} router={router} />;
}
};
3 changes: 3 additions & 0 deletions packages/layout/src/focus4.layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ export {Panel, PanelButtons, ScrollspyContainer, ScrollspyMenu, panelCss, scroll
export {
Content,
Dialog,
FilAriane,
LateralMenu,
Popin,
Scrollable,
dialogCss,
filArianeCss,
lateralMenuCss,
layoutCss,
overlayCss,
Expand All @@ -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,
Expand Down
37 changes: 37 additions & 0 deletions packages/layout/src/presentation/__style__/fil-ariane.css
Original file line number Diff line number Diff line change
@@ -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);
}
132 changes: 132 additions & 0 deletions packages/layout/src/presentation/fil-ariane.tsx
Original file line number Diff line number Diff line change
@@ -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<FilArianeCss>;
}

/**
* 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<any>);
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 (
<span className={theme.container()}>
{finalRoutes.map((x, i) => (
<Fragment key={x.route}>
{i !== finalRoutes.length - 1 ? (
<a className={theme.item()} href={x.url}>
{x.dictionaryKey}
</a>
) : (
<span className={theme.item({active: true})}>{x.dictionaryKey}</span>
)}
{i !== finalRoutes.length - 1 ? (
<FontIcon
className={theme.separator()}
icon={{i18nKey: `${i18nPrefix}.icons.filAriane.separator`}}
/>
) : null}
</Fragment>
))}
</span>
);
});
}
2 changes: 2 additions & 0 deletions packages/layout/src/presentation/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion packages/layout/src/presentation/lateral-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Loading

0 comments on commit d5aea7d

Please sign in to comment.