1 | import React, { memo, forwardRef, type ReactNode, type CSSProperties } from "react";
|
2 | import { getLink } from "./link";
|
3 | import type { RegisteredLinkProps } from "./link";
|
4 | import { symToStr } from "tsafe/symToStr";
|
5 | import { fr } from "./fr";
|
6 | import { cx } from "./tools/cx";
|
7 | import { assert } from "tsafe/assert";
|
8 | import type { Equals } from "tsafe";
|
9 | import { createComponentI18nApi } from "./i18n";
|
10 | import type { FrIconClassName, RiIconClassName } from "./fr/generatedFromCss/classNames";
|
11 | import { getBrandTopAndHomeLinkProps } from "./zz_internal/brandTopAndHomeLinkProps";
|
12 | import { typeGuard } from "tsafe/typeGuard";
|
13 | import { id } from "tsafe/id";
|
14 |
|
15 | export type FooterProps = {
|
16 | id?: string;
|
17 | className?: string;
|
18 | accessibility: "non compliant" | "partially compliant" | "fully compliant";
|
19 | contentDescription?: ReactNode;
|
20 | websiteMapLinkProps?: RegisteredLinkProps;
|
21 | accessibilityLinkProps?: RegisteredLinkProps;
|
22 | termsLinkProps?: RegisteredLinkProps;
|
23 | bottomItems?: (FooterProps.BottomItem | ReactNode)[];
|
24 | partnersLogos?: FooterProps.PartnersLogos;
|
25 | operatorLogo?: {
|
26 | orientation: "horizontal" | "vertical";
|
27 | /**
|
28 | * Expected ratio:
|
29 | * If "vertical": 9x16
|
30 | * If "horizontal": 16x9
|
31 | */
|
32 | imgUrl: string;
|
33 | /** Textual alternative of the image, it MUST include the text present in the image*/
|
34 | alt: string;
|
35 | };
|
36 | license?: ReactNode;
|
37 | /** If not provided the brandTop from the Header will be used,
|
38 | * Be aware that if your Header is not used as a server component while the Footer is
|
39 | * you need to provide the brandTop to the Footer.
|
40 | */
|
41 | brandTop?: ReactNode;
|
42 | /** If not provided the homeLinkProps from the Header will be used,
|
43 | * Be aware that if your Header is not used as a server component while the Footer is
|
44 | * you need to provide the homeLinkProps to the Footer.
|
45 | */
|
46 | homeLinkProps?: RegisteredLinkProps & { title: string };
|
47 | classes?: Partial<
|
48 | Record<
|
49 | | "root"
|
50 | | "body"
|
51 | | "brand"
|
52 | | "content"
|
53 | | "contentDesc"
|
54 | | "contentList"
|
55 | | "contentItem"
|
56 | | "contentLink"
|
57 | | "bottom"
|
58 | | "bottomList"
|
59 | | "bottomItem"
|
60 | | "bottomLink"
|
61 | | "bottomCopy"
|
62 | | "brandLink"
|
63 | | "logo"
|
64 | | "operatorLogo"
|
65 | | "partners"
|
66 | | "partnersTitle"
|
67 | | "partnersLogos"
|
68 | | "partnersMain"
|
69 | | "partnersLink"
|
70 | | "partnersSub",
|
71 | string
|
72 | >
|
73 | >;
|
74 | style?: CSSProperties;
|
75 | linkList?: FooterProps.LinkList.List;
|
76 | domains?: string[];
|
77 | };
|
78 |
|
79 | export namespace FooterProps {
|
80 | export type BottomItem = BottomItem.Link | BottomItem.Button;
|
81 |
|
82 | export namespace BottomItem {
|
83 | export type Common = {
|
84 | iconId?: FrIconClassName | RiIconClassName;
|
85 | text: ReactNode;
|
86 | };
|
87 |
|
88 | export type Link = Common & {
|
89 | linkProps: RegisteredLinkProps;
|
90 | buttonProps?: never;
|
91 | };
|
92 |
|
93 | export type Button = Common & {
|
94 | linkProps?: undefined;
|
95 | buttonProps: React.DetailedHTMLProps<
|
96 | React.ButtonHTMLAttributes<HTMLButtonElement>,
|
97 | HTMLButtonElement
|
98 | >;
|
99 | };
|
100 | }
|
101 |
|
102 | export namespace LinkList {
|
103 | export type List = [Column, Column?, Column?, Column?, Column?, Column?];
|
104 | export type Links = [
|
105 | LinkList.Link,
|
106 | LinkList.Link?,
|
107 | LinkList.Link?,
|
108 | LinkList.Link?,
|
109 | LinkList.Link?,
|
110 | LinkList.Link?,
|
111 | LinkList.Link?,
|
112 | LinkList.Link?
|
113 | ];
|
114 | export interface Column {
|
115 | categoryName?: string;
|
116 | links: Links;
|
117 | }
|
118 | export interface Link {
|
119 | text: string;
|
120 | linkProps: RegisteredLinkProps;
|
121 | }
|
122 | }
|
123 |
|
124 | export type PartnersLogos = PartnersLogos.MainOnly | PartnersLogos.SubOnly;
|
125 |
|
126 | export namespace PartnersLogos {
|
127 | export type MainOnly = {
|
128 | main: Logo;
|
129 | sub?: Logo[];
|
130 | };
|
131 |
|
132 | export type SubOnly = {
|
133 | main?: Logo;
|
134 | sub: [Logo, ...Logo[]];
|
135 | };
|
136 |
|
137 | export type Logo = {
|
138 | alt: string;
|
139 | /**
|
140 | * @deprecated use linkProps instead
|
141 | */
|
142 | href?: string;
|
143 | imgUrl: string;
|
144 | linkProps?: RegisteredLinkProps & { title: string };
|
145 | };
|
146 | }
|
147 | }
|
148 |
|
149 | /** @see <https://components.react-dsfr.codegouv.studio/?path=/docs/components-footer> */
|
150 | export const Footer = memo(
|
151 | forwardRef<HTMLDivElement, FooterProps>((props, ref) => {
|
152 | const {
|
153 | id: id_props,
|
154 | className,
|
155 | classes = {},
|
156 | contentDescription,
|
157 | websiteMapLinkProps,
|
158 | accessibilityLinkProps,
|
159 | accessibility,
|
160 | termsLinkProps,
|
161 | bottomItems = [],
|
162 | partnersLogos,
|
163 | operatorLogo,
|
164 | license,
|
165 | brandTop: brandTop_prop,
|
166 | homeLinkProps: homeLinkProps_prop,
|
167 | style,
|
168 | linkList,
|
169 | domains = [
|
170 | "legifrance.gouv.fr",
|
171 | "gouvernement.fr",
|
172 | "service-public.fr",
|
173 | "data.gouv.fr"
|
174 | ],
|
175 | ...rest
|
176 | } = props;
|
177 |
|
178 | assert<Equals<keyof typeof rest, never>>();
|
179 |
|
180 | const rootId = id_props ?? "fr-footer";
|
181 |
|
182 | const { brandTop, homeLinkProps } = (() => {
|
183 | const wrap = getBrandTopAndHomeLinkProps();
|
184 |
|
185 | const brandTop = brandTop_prop ?? wrap?.brandTop;
|
186 | const homeLinkProps = homeLinkProps_prop ?? wrap?.homeLinkProps;
|
187 |
|
188 | const exceptionMessage =
|
189 | " hasn't been provided to the Footer and we cannot retrieve it from the Header (it's probably client side)";
|
190 |
|
191 | if (brandTop === undefined) {
|
192 | throw new Error(symToStr({ brandTop }) + exceptionMessage);
|
193 | }
|
194 |
|
195 | if (homeLinkProps === undefined) {
|
196 | throw new Error(symToStr({ homeLinkProps }) + exceptionMessage);
|
197 | }
|
198 |
|
199 | return { brandTop, homeLinkProps };
|
200 | })();
|
201 |
|
202 | const { Link } = getLink();
|
203 |
|
204 | const { t } = useTranslation();
|
205 |
|
206 | const { main: mainPartnersLogo, sub: subPartnersLogos = [] } = partnersLogos ?? {};
|
207 |
|
208 | return (
|
209 | <footer
|
210 | id={rootId}
|
211 | className={cx(fr.cx("fr-footer"), classes.root, className)}
|
212 | role="contentinfo"
|
213 | ref={ref}
|
214 | style={style}
|
215 | {...rest}
|
216 | >
|
217 | {linkList !== undefined && (
|
218 | <div className={fr.cx("fr-footer__top")}>
|
219 | <div className={fr.cx("fr-container")}>
|
220 | <div
|
221 | className={fr.cx(
|
222 | "fr-grid-row",
|
223 | // "fr-grid-row--start", // why is this class used in dsfr doc?
|
224 | "fr-grid-row--gutters"
|
225 | )}
|
226 | >
|
227 | {linkList.map(
|
228 | (column, columnIndex) =>
|
229 | column !== undefined && (
|
230 | <div
|
231 | key={`fr-footer__top-cat-${columnIndex}`}
|
232 | className={fr.cx(
|
233 | "fr-col-12",
|
234 | "fr-col-sm-3",
|
235 | "fr-col-md-2"
|
236 | )}
|
237 | >
|
238 | {column?.categoryName && (
|
239 | <h3 className={fr.cx("fr-footer__top-cat")}>
|
240 | {column?.categoryName}
|
241 | </h3>
|
242 | )}
|
243 | <ul className={fr.cx("fr-footer__top-list")}>
|
244 | {column?.links.map(
|
245 | (linkItem, linkItemIndex) => (
|
246 | <li
|
247 | key={`fr-footer__top-link-${linkItemIndex}`}
|
248 | >
|
249 | <Link
|
250 | {...(linkItem?.linkProps as any)}
|
251 | className={fr.cx(
|
252 | "fr-footer__top-link"
|
253 | )}
|
254 | >
|
255 | {linkItem?.text}
|
256 | </Link>
|
257 | </li>
|
258 | )
|
259 | )}
|
260 | </ul>
|
261 | </div>
|
262 | )
|
263 | )}
|
264 | </div>
|
265 | </div>
|
266 | </div>
|
267 | )}
|
268 | <div className={fr.cx("fr-container")}>
|
269 | <div className={cx(fr.cx("fr-footer__body"), classes.body)}>
|
270 | <div
|
271 | className={cx(
|
272 | fr.cx("fr-footer__brand", "fr-enlarge-link"),
|
273 | classes.brand
|
274 | )}
|
275 | >
|
276 | {(() => {
|
277 | const children = (
|
278 | <p className={cx(fr.cx("fr-logo"), classes.logo)}>{brandTop}</p>
|
279 | );
|
280 |
|
281 | return operatorLogo !== undefined ? (
|
282 | children
|
283 | ) : (
|
284 | <Link {...homeLinkProps}>{children}</Link>
|
285 | );
|
286 | })()}
|
287 | {operatorLogo !== undefined && (
|
288 | <Link
|
289 | {...homeLinkProps}
|
290 | className={cx(
|
291 | fr.cx("fr-footer__brand-link"),
|
292 | classes.brandLink,
|
293 | homeLinkProps.className
|
294 | )}
|
295 | >
|
296 | <img
|
297 | className={cx(
|
298 | fr.cx("fr-footer__logo"),
|
299 | classes.operatorLogo
|
300 | )}
|
301 | style={(() => {
|
302 | switch (operatorLogo.orientation) {
|
303 | case "vertical":
|
304 | return { "width": "3.5rem" };
|
305 | case "horizontal":
|
306 | return { "maxWidth": "9.0625rem" };
|
307 | }
|
308 | })()}
|
309 | src={operatorLogo.imgUrl}
|
310 | alt={operatorLogo.alt}
|
311 | />
|
312 | </Link>
|
313 | )}
|
314 | </div>
|
315 | <div className={cx(fr.cx("fr-footer__content"), classes.content)}>
|
316 | {contentDescription !== undefined && (
|
317 | <p
|
318 | className={cx(
|
319 | fr.cx("fr-footer__content-desc"),
|
320 | classes.contentDesc
|
321 | )}
|
322 | >
|
323 | {contentDescription}
|
324 | </p>
|
325 | )}
|
326 | <ul
|
327 | className={cx(
|
328 | fr.cx("fr-footer__content-list"),
|
329 | classes.contentList
|
330 | )}
|
331 | >
|
332 | {domains.map((domain, i) => (
|
333 | <li
|
334 | className={cx(
|
335 | fr.cx("fr-footer__content-item" as any),
|
336 | classes.contentItem
|
337 | )}
|
338 | key={i}
|
339 | >
|
340 | <a
|
341 | className={cx(
|
342 | fr.cx("fr-footer__content-link"),
|
343 | classes.contentLink
|
344 | )}
|
345 | target="_blank"
|
346 | href={`https://${domain}`}
|
347 | title={`${domain} - ${t("open new window")}`}
|
348 | >
|
349 | {domain}
|
350 | </a>
|
351 | </li>
|
352 | ))}
|
353 | </ul>
|
354 | </div>
|
355 | </div>
|
356 | {partnersLogos !== undefined && (
|
357 | <div className={cx(fr.cx("fr-footer__partners"), classes.partners)}>
|
358 | <h4
|
359 | className={cx(
|
360 | fr.cx("fr-footer__partners-title"),
|
361 | classes.partnersTitle
|
362 | )}
|
363 | >
|
364 | {t("our partners")}
|
365 | </h4>
|
366 | <div
|
367 | className={cx(
|
368 | fr.cx("fr-footer__partners-logos"),
|
369 | classes.partnersLogos
|
370 | )}
|
371 | >
|
372 | <div
|
373 | className={cx(
|
374 | fr.cx("fr-footer__partners-main"),
|
375 | classes.partnersMain
|
376 | )}
|
377 | >
|
378 | {mainPartnersLogo !== undefined &&
|
379 | (() => {
|
380 | const children = (
|
381 | <img
|
382 | alt={mainPartnersLogo.alt}
|
383 | style={{ height: "5.625rem" }} // should not be hardcoded. Can conflict with ContentSecurityPolicy when "unsafe-inline" is not allowed
|
384 | src={mainPartnersLogo.imgUrl}
|
385 | className={cx(
|
386 | fr.cx("fr-footer__logo"),
|
387 | classes.logo
|
388 | )}
|
389 | />
|
390 | );
|
391 |
|
392 | const hasLinkProps =
|
393 | mainPartnersLogo.linkProps !== undefined ||
|
394 | mainPartnersLogo.href !== undefined;
|
395 |
|
396 | return hasLinkProps ? (
|
397 | <Link
|
398 | {...mainPartnersLogo.linkProps}
|
399 | href={
|
400 | mainPartnersLogo.href ??
|
401 | mainPartnersLogo.linkProps?.href
|
402 | }
|
403 | className={cx(
|
404 | fr.cx(
|
405 | "fr-footer__partners-link",
|
406 | "fr-raw-link"
|
407 | ),
|
408 | classes.partnersLink
|
409 | )}
|
410 | >
|
411 | {children}
|
412 | </Link>
|
413 | ) : (
|
414 | children
|
415 | );
|
416 | })()}
|
417 | </div>
|
418 | {subPartnersLogos.length !== 0 && (
|
419 | <div
|
420 | className={cx(
|
421 | fr.cx("fr-footer__partners-sub"),
|
422 | classes.partnersSub
|
423 | )}
|
424 | >
|
425 | <ul>
|
426 | {subPartnersLogos.map((logo, i) => {
|
427 | const children = (
|
428 | <img
|
429 | alt={logo.alt}
|
430 | src={logo.imgUrl}
|
431 | style={{ "height": "5.625rem" }} // should not be hardcoded. Can conflict with ContentSecurityPolicy when "unsafe-inline" is not allowed
|
432 | className={cx(
|
433 | fr.cx("fr-footer__logo"),
|
434 | classes.logo
|
435 | )}
|
436 | />
|
437 | );
|
438 |
|
439 | const hasLinkProps =
|
440 | logo.linkProps !== undefined ||
|
441 | logo.href !== undefined;
|
442 |
|
443 | return (
|
444 | <li key={i}>
|
445 | {hasLinkProps ? (
|
446 | <Link
|
447 | {...logo.linkProps}
|
448 | href={
|
449 | logo.href ??
|
450 | logo.linkProps?.href
|
451 | }
|
452 | className={cx(
|
453 | fr.cx(
|
454 | "fr-footer__partners-link",
|
455 | "fr-raw-link"
|
456 | ),
|
457 | classes.partnersLink
|
458 | )}
|
459 | >
|
460 | {children}
|
461 | </Link>
|
462 | ) : (
|
463 | children
|
464 | )}
|
465 | </li>
|
466 | );
|
467 | })}
|
468 | </ul>
|
469 | </div>
|
470 | )}
|
471 | </div>
|
472 | </div>
|
473 | )}
|
474 | <div className={cx(fr.cx("fr-footer__bottom"), classes.bottom)}>
|
475 | <ul className={cx(fr.cx("fr-footer__bottom-list"), classes.bottomList)}>
|
476 | {[
|
477 | ...(websiteMapLinkProps === undefined
|
478 | ? []
|
479 | : [
|
480 | id<FooterProps.BottomItem>({
|
481 | "text": t("website map"),
|
482 | "linkProps": websiteMapLinkProps
|
483 | })
|
484 | ]),
|
485 | id<FooterProps.BottomItem>({
|
486 | "text": `${t("accessibility")} : ${t(accessibility)}`,
|
487 | "linkProps": accessibilityLinkProps ?? ({} as any)
|
488 | }),
|
489 | ...(termsLinkProps === undefined
|
490 | ? []
|
491 | : [
|
492 | id<FooterProps.BottomItem>({
|
493 | "text": t("terms"),
|
494 | "linkProps": termsLinkProps
|
495 | })
|
496 | ]),
|
497 | ...bottomItems
|
498 | ].map((bottomItem, i) => (
|
499 | <li
|
500 | className={cx(
|
501 | fr.cx("fr-footer__bottom-item"),
|
502 | classes.bottomItem,
|
503 | className
|
504 | )}
|
505 | key={i}
|
506 | >
|
507 | {!typeGuard<FooterProps.BottomItem>(
|
508 | bottomItem,
|
509 | bottomItem instanceof Object && "text" in bottomItem
|
510 | ) ? (
|
511 | bottomItem
|
512 | ) : (
|
513 | <FooterBottomItem
|
514 | classes={{
|
515 | "bottomLink": classes.bottomLink
|
516 | }}
|
517 | bottomItem={bottomItem}
|
518 | />
|
519 | )}
|
520 | </li>
|
521 | ))}
|
522 | </ul>
|
523 | <div className={cx(fr.cx("fr-footer__bottom-copy"), classes.bottomCopy)}>
|
524 | <p>
|
525 | {license === undefined
|
526 | ? t("license mention", {
|
527 | "licenseUrl":
|
528 | "https://github.com/etalab/licence-ouverte/blob/master/LO.md"
|
529 | })
|
530 | : license}
|
531 | </p>
|
532 | </div>
|
533 | </div>
|
534 | </div>
|
535 | </footer>
|
536 | );
|
537 | })
|
538 | );
|
539 |
|
540 | Footer.displayName = symToStr({ Footer });
|
541 |
|
542 | export default Footer;
|
543 |
|
544 | const { useTranslation, addFooterTranslations } = createComponentI18nApi({
|
545 | "componentName": symToStr({ Footer }),
|
546 | "frMessages": {
|
547 | /* spell-checker: disable */
|
548 | "hide message": "Masquer le message",
|
549 | "website map": "Plan du site",
|
550 | "accessibility": "Accessibilité",
|
551 | "non compliant": "non conforme",
|
552 | "partially compliant": "partiellement conforme",
|
553 | "fully compliant": "totalement conforme",
|
554 | "terms": "Mentions légales",
|
555 | "cookies management": "Gestion des cookies",
|
556 | "license mention": (p: { licenseUrl: string }) => (
|
557 | <>
|
558 | Sauf mention explicite de propriété intellectuelle détenue par des tiers, les
|
559 | contenus de ce site sont proposés sous{" "}
|
560 | <a
|
561 | href={p.licenseUrl}
|
562 | target="_blank"
|
563 | title="licence etalab-2.0 - ouvre une nouvelle fenêtre"
|
564 | >
|
565 | licence etalab-2.0
|
566 | </a>
|
567 | </>
|
568 | ),
|
569 | "our partners": "Nos partenaires",
|
570 | "open new window": "ouvre une nouvelle fenêtre"
|
571 | /* spell-checker: enable */
|
572 | }
|
573 | });
|
574 |
|
575 | addFooterTranslations({
|
576 | "lang": "en",
|
577 | "messages": {
|
578 | "hide message": "Hide the message",
|
579 | "website map": "Website map",
|
580 | "accessibility": "Accessibility",
|
581 | "non compliant": "non compliant",
|
582 | "partially compliant": "partially compliant",
|
583 | "fully compliant": "fully compliant",
|
584 | "license mention": p => (
|
585 | <>
|
586 | Unless stated otherwise, all content of this website is under the{" "}
|
587 | <a
|
588 | href={p.licenseUrl}
|
589 | target="_blank"
|
590 | title="etalab-2.0 license - open a new window"
|
591 | >
|
592 | etalab-2.0 license
|
593 | </a>
|
594 | </>
|
595 | ),
|
596 | "open new window": "open new window"
|
597 | }
|
598 | });
|
599 |
|
600 | addFooterTranslations({
|
601 | "lang": "es",
|
602 | "messages": {
|
603 | /* spell-checker: disable */
|
604 | "hide message": "Occultar el mesage"
|
605 | /* spell-checker: enable */
|
606 | }
|
607 | });
|
608 |
|
609 | export { addFooterTranslations };
|
610 |
|
611 | export type FooterBottomItemProps = {
|
612 | className?: string;
|
613 | bottomItem: FooterProps.BottomItem;
|
614 | classes?: Partial<Record<"root" | "bottomLink", string>>;
|
615 | };
|
616 |
|
617 | export function FooterBottomItem(props: FooterBottomItemProps): JSX.Element {
|
618 | const { className: className_props, bottomItem, classes = {} } = props;
|
619 |
|
620 | const { Link } = getLink();
|
621 |
|
622 | const className = cx(
|
623 | fr.cx(
|
624 | "fr-footer__bottom-link",
|
625 | ...(bottomItem.iconId !== undefined
|
626 | ? ([bottomItem.iconId, "fr-link--icon-left"] as const)
|
627 | : [])
|
628 | ),
|
629 | classes.bottomLink,
|
630 | classes.root,
|
631 | className_props
|
632 | );
|
633 |
|
634 | return bottomItem.linkProps !== undefined ? (
|
635 | Object.keys(bottomItem.linkProps).length === 0 ? (
|
636 | <span className={className}>{bottomItem.text}</span>
|
637 | ) : (
|
638 | <Link
|
639 | {...bottomItem.linkProps}
|
640 | className={cx(className, bottomItem.linkProps.className)}
|
641 | >
|
642 | {bottomItem.text}
|
643 | </Link>
|
644 | )
|
645 | ) : (
|
646 | <button
|
647 | {...bottomItem.buttonProps}
|
648 | className={cx(className, bottomItem.buttonProps.className)}
|
649 | >
|
650 | {bottomItem.text}
|
651 | </button>
|
652 | );
|
653 | }
|
654 |
|
\ | No newline at end of file |