UNPKG

28.7 kBTypeScriptView Raw
1import React, { memo, forwardRef, type ReactNode, type CSSProperties } from "react";
2import { getLink } from "./link";
3import type { RegisteredLinkProps } from "./link";
4import { symToStr } from "tsafe/symToStr";
5import { fr } from "./fr";
6import { cx } from "./tools/cx";
7import { assert } from "tsafe/assert";
8import type { Equals } from "tsafe";
9import { createComponentI18nApi } from "./i18n";
10import type { FrIconClassName, RiIconClassName } from "./fr/generatedFromCss/classNames";
11import { getBrandTopAndHomeLinkProps } from "./zz_internal/brandTopAndHomeLinkProps";
12import { typeGuard } from "tsafe/typeGuard";
13import { id } from "tsafe/id";
14
15export 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
79export 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> */
150export 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
540Footer.displayName = symToStr({ Footer });
541
542export default Footer;
543
544const { 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
575addFooterTranslations({
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
600addFooterTranslations({
601 "lang": "es",
602 "messages": {
603 /* spell-checker: disable */
604 "hide message": "Occultar el mesage"
605 /* spell-checker: enable */
606 }
607});
608
609export { addFooterTranslations };
610
611export type FooterBottomItemProps = {
612 className?: string;
613 bottomItem: FooterProps.BottomItem;
614 classes?: Partial<Record<"root" | "bottomLink", string>>;
615};
616
617export 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