@purpurds/footer 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,192 @@
1
+ import React, { forwardRef } from "react";
2
+ import { facebook, instagram, linkedin, x, youtube } from "@purpurds/icon";
3
+ import { Link } from "@purpurds/link";
4
+ import type { Meta, StoryObj } from "@storybook/react";
5
+
6
+ import "@purpurds/link/styles";
7
+ import "@purpurds/grid/styles";
8
+ import "@purpurds/accordion/styles";
9
+ import "@purpurds/icon/styles";
10
+ import "@purpurds/heading/styles";
11
+ import { Footer } from "./footer";
12
+
13
+ const meta = {
14
+ title: "Components/Footer",
15
+ component: Footer,
16
+ parameters: {
17
+ design: [
18
+ {
19
+ name: "Footer",
20
+ type: "figma",
21
+ url: "https://www.figma.com/design/XEaIIFskrrxIBHMZDkIuIg/Purpur-DS---Component-library-%26-guidelines?node-id=58165-9763",
22
+ },
23
+ ],
24
+ },
25
+ } satisfies Meta<typeof Footer>;
26
+
27
+ export default meta;
28
+ type Story = StoryObj<typeof Footer>;
29
+
30
+ type CustomTestLinkProps = {
31
+ children?: React.ReactNode;
32
+ href?: string;
33
+ target?: string;
34
+ };
35
+
36
+ const CustomTestLink = forwardRef(({ children, href, target, ...props }: CustomTestLinkProps) => {
37
+ return (
38
+ <a href={href} target={target} {...props}>
39
+ {children}
40
+ </a>
41
+ );
42
+ });
43
+
44
+ const navigationLinks = [
45
+ {
46
+ heading: "Recommended",
47
+ links: [
48
+ { text: "iPhone 16", href: "https://telia.se" },
49
+ { text: "Samsung Galaxy s24", href: "https://telia.se" },
50
+ { text: "Premier League", href: "https://telia.se" },
51
+ { text: "SHL", href: "https://telia.se" },
52
+ { text: "Mobile Phones", href: "https://telia.se" },
53
+ { text: "Broadband", href: "https://telia.se" },
54
+ { text: "Fiber", href: "https://telia.se" },
55
+ { text: "TV and Streaming", href: "https://telia.se" },
56
+ { text: "Black Friday", href: "https://telia.se" },
57
+ { text: "Telia's Offers", href: "https://telia.se" },
58
+ ],
59
+ },
60
+ {
61
+ heading: "This is Telia",
62
+ links: [
63
+ { text: "About Telia", href: "https://telia.se" },
64
+ { text: "Press", href: "https://telia.se" },
65
+ { text: "Network of the Future", href: "https://telia.se" },
66
+ { text: "Telia's Networks and Cables", href: "https://telia.se" },
67
+ { text: "Jobs at Telia", href: "https://telia.se" },
68
+ { text: "5G", href: "https://telia.se" },
69
+ { text: "2G/3G Phase-out", href: "https://telia.se" },
70
+ { text: "Management Team", href: "https://telia.se" },
71
+ { text: "Privacy Policy", href: "https://telia.se" },
72
+ { text: "About the Website", href: "https://telia.se" },
73
+ { text: "Cookies", href: "https://telia.se" },
74
+ ],
75
+ },
76
+ {
77
+ heading: "Shop at Telia",
78
+ links: [
79
+ { text: "Support", href: "https://telia.se" },
80
+ { text: "Contact Us", href: "https://telia.se" },
81
+ { text: "Operational Information", href: "https://telia.se" },
82
+ { text: "Purchase Information", href: "https://telia.se" },
83
+ { text: "Terms and Conditions", href: "https://telia.se" },
84
+ { text: "Prices", href: "https://telia.se" },
85
+ { text: "Telia Stores", href: "https://telia.se" },
86
+ { text: "Reports and Violations", href: "https://telia.se" },
87
+ { text: "Coverage Maps", href: "https://telia.se" },
88
+ { text: "Email to Telia", href: "https://telia.se" },
89
+ ],
90
+ },
91
+ {
92
+ heading: "Sustainability",
93
+ links: [
94
+ { text: "Recycle Your Phone", href: "https://telia.se" },
95
+ { text: "Used Phones", href: "https://telia.se" },
96
+ { text: "Repair Your Phone", href: "https://telia.se" },
97
+ { text: "Sustainability Work", href: "https://telia.se" },
98
+ { text: "Environmental Work", href: "https://telia.se" },
99
+ { text: "Eco-rating", href: "https://telia.se" },
100
+ { text: "Social Sustainability", href: "https://telia.se" },
101
+ { text: "Surf Safely", href: "https://telia.se" },
102
+ ],
103
+ },
104
+ ];
105
+
106
+ const socialLinks = [
107
+ { href: "https://telia.se", icon: instagram, "aria-label": "Link to Telia's Instagram page" },
108
+ { href: "https://telia.se", icon: linkedin, "aria-label": "Link to Telia's LinkedIn page" },
109
+ { href: "https://telia.se", icon: facebook, "aria-label": "Link to Telia's Facebook page" },
110
+ { href: "https://telia.se", icon: youtube, "aria-label": "Link to Telia's YouTube page" },
111
+ { href: "https://telia.se", icon: x, "aria-label": "Link to Telia's X page" },
112
+ ];
113
+
114
+ const contacts = {
115
+ copyright: "© Telia Company 2024",
116
+ address: {
117
+ content: (
118
+ <>
119
+ Address line 1<br />
120
+ Address line 2<br />
121
+ Address line 3<br />
122
+ Org number
123
+ </>
124
+ ),
125
+ addressProps: { "aria-label": "Telia Company address" },
126
+ },
127
+ };
128
+
129
+ const legalLinks = [
130
+ { text: "link 1", href: "https://telia.se" },
131
+ { text: "link 2", href: "https://telia.se" },
132
+ { text: "link 3", href: "https://telia.se" },
133
+ { text: "link 4", href: "https://telia.se" },
134
+ { text: "link 5", href: "https://telia.se" },
135
+ { text: "link 6", href: "https://telia.se" },
136
+ ];
137
+
138
+ const additionalContent = (
139
+ <Link variant="standalone" negative href="https://www.telia.lt/privatiems">
140
+ Additional content
141
+ </Link>
142
+ );
143
+
144
+ const paymentImages = [
145
+ {
146
+ src: "https://www.mastercard.com/content/dam/public/brandcenter/assets/images/logos/mclogo-for-footer.svg",
147
+ alt: "MasterCard",
148
+ },
149
+ {
150
+ src: "https://upload.wikimedia.org/wikipedia/commons/5/5e/Visa_Inc._logo.svg",
151
+ alt: "Visa",
152
+ },
153
+ ];
154
+
155
+ export const Showcase: Story = {
156
+ args: {
157
+ navigation: {
158
+ customLink: CustomTestLink,
159
+ links: navigationLinks,
160
+ sectionProps: {
161
+ "aria-label": "Navigation links",
162
+ },
163
+ },
164
+ social: {
165
+ links: socialLinks,
166
+ sectionProps: {
167
+ "aria-label": "Social media links",
168
+ },
169
+ },
170
+ contacts: {
171
+ ...contacts,
172
+ sectionProps: {
173
+ "aria-label": "Contact information",
174
+ },
175
+ },
176
+ legal: { links: legalLinks, sectionProps: { "aria-label": "Legal links" } },
177
+ additional: {
178
+ content: additionalContent,
179
+ sectionProps: { "aria-label": "Additional content" },
180
+ },
181
+ payments: {
182
+ images: paymentImages,
183
+ sectionProps: {
184
+ "aria-label": "Payment methods",
185
+ },
186
+ },
187
+ },
188
+ };
189
+
190
+ export const Empty: Story = {
191
+ args: {},
192
+ };
@@ -0,0 +1,192 @@
1
+ import React from "react";
2
+ import { facebook, linkedin } from "@purpurds/icon";
3
+ import * as matchers from "@testing-library/jest-dom/matchers";
4
+ import { cleanup, render, screen } from "@testing-library/react";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+
7
+ import { Footer } from "./footer";
8
+
9
+ expect.extend(matchers);
10
+
11
+ describe("Footer", () => {
12
+ afterEach(() => {
13
+ cleanup();
14
+ });
15
+
16
+ it("should render empty footer", () => {
17
+ render(<Footer />);
18
+
19
+ const sections = screen.queryAllByRole("region");
20
+ expect(sections).toHaveLength(0);
21
+
22
+ const teliaLogo = screen.getByRole("presentation");
23
+ expect(teliaLogo).toBeInTheDocument();
24
+ expect(teliaLogo).toHaveAttribute("alt", "Telia logo");
25
+ });
26
+
27
+ it("should render footer with social links", () => {
28
+ render(
29
+ <Footer
30
+ social={{
31
+ links: [
32
+ { icon: facebook, href: "https://facebook.com", "aria-label": "link to facebook page" },
33
+ { icon: linkedin, href: "https://linkedin.com", "aria-label": "link to linkedin page" },
34
+ ],
35
+ sectionProps: { "aria-label": "Social links" },
36
+ }}
37
+ />
38
+ );
39
+ // Due to the fact that the social links are rendered for both mobile and desktop the length is doubled
40
+ const sections = screen.queryAllByRole("region");
41
+ expect(sections).toHaveLength(2);
42
+ expect(sections[0]).toHaveAttribute("aria-label", "Social links");
43
+ expect(sections[1]).toHaveAttribute("aria-label", "Social links");
44
+
45
+ const socialLinks = screen.queryAllByRole("link");
46
+ expect(socialLinks).toHaveLength(4);
47
+
48
+ const facebookSocialLinks = screen.queryAllByLabelText("link to facebook page");
49
+ expect(facebookSocialLinks).toHaveLength(2);
50
+
51
+ const linkedinSocialLinks = screen.queryAllByLabelText("link to linkedin page");
52
+ expect(linkedinSocialLinks).toHaveLength(2);
53
+ });
54
+
55
+ it("should render footer with navigation links", () => {
56
+ render(
57
+ <Footer
58
+ navigation={{
59
+ links: [
60
+ {
61
+ heading: "Section 1",
62
+ links: [
63
+ { text: "link 1", href: "https://telia.se" },
64
+ { text: "link 2", href: "https://telia.se" },
65
+ ],
66
+ },
67
+ {
68
+ heading: "Section 2",
69
+ links: [
70
+ { text: "link 3", href: "https://telia.se" },
71
+ { text: "link 4", href: "https://telia.se" },
72
+ ],
73
+ },
74
+ ],
75
+ sectionProps: { "aria-label": "Navigation links" },
76
+ }}
77
+ />
78
+ );
79
+
80
+ // Due to the fact that the navigation is rendered for both mobile and desktop the length is doubled
81
+ const sections = screen.queryAllByRole("region");
82
+ expect(sections).toHaveLength(2);
83
+ expect(sections[0]).toHaveAttribute("aria-label", "Navigation links");
84
+ expect(sections[1]).toHaveAttribute("aria-label", "Navigation links");
85
+
86
+ expect(screen.queryAllByRole("heading")).toHaveLength(4);
87
+
88
+ // Links in accordion are not rendered in the test
89
+ const links = screen.queryAllByRole("link");
90
+ expect(links).toHaveLength(4);
91
+ });
92
+
93
+ it("should render footer with payments", () => {
94
+ render(
95
+ <Footer
96
+ payments={{
97
+ images: [
98
+ {
99
+ src: "https://www.mastercard.com/content/dam/public/brandcenter/assets/images/logos/mclogo-for-footer.svg",
100
+ alt: "MasterCard",
101
+ },
102
+ {
103
+ src: "https://upload.wikimedia.org/wikipedia/commons/5/5e/Visa_Inc._logo.svg",
104
+ alt: "Visa",
105
+ },
106
+ ],
107
+ sectionProps: { "aria-label": "Payment methods available in Telia" },
108
+ }}
109
+ />
110
+ );
111
+
112
+ const sections = screen.getByRole("region");
113
+ expect(sections).toBeInTheDocument();
114
+ expect(sections).toHaveAttribute("aria-label", "Payment methods available in Telia");
115
+
116
+ const paymentImages = screen.queryAllByRole("img");
117
+ expect(paymentImages).toHaveLength(2);
118
+
119
+ expect(screen.getByAltText("MasterCard")).toBeInTheDocument();
120
+ expect(screen.getByAltText("Visa")).toBeInTheDocument();
121
+ });
122
+
123
+ it("should render footer with contacts", () => {
124
+ render(
125
+ <Footer
126
+ contacts={{
127
+ copyright: "© Telia Company 2024",
128
+ address: {
129
+ content: (
130
+ <>
131
+ Address line 1 <br />
132
+ Address line 2 <br />
133
+ Address line 3 <br />
134
+ Org number
135
+ </>
136
+ ),
137
+ addressProps: { "aria-label": "Telia Company address" },
138
+ },
139
+ sectionProps: { "aria-label": "Contact information" },
140
+ }}
141
+ />
142
+ );
143
+
144
+ const sections = screen.getByRole("region");
145
+ expect(sections).toBeInTheDocument();
146
+ expect(sections).toHaveAttribute("aria-label", "Contact information");
147
+
148
+ expect(screen.getByText("© Telia Company 2024")).toBeInTheDocument();
149
+
150
+ const address = document.querySelector("address");
151
+ expect(address).toHaveTextContent("Address line 1 Address line 2 Address line 3 Org number");
152
+ });
153
+
154
+ it("should render footer with legal links", () => {
155
+ render(
156
+ <Footer
157
+ legal={{
158
+ links: [{ text: "link 1", href: "https://telia.se", target: "_blank" }],
159
+ sectionProps: {
160
+ "aria-label": "Legal links",
161
+ },
162
+ }}
163
+ />
164
+ );
165
+
166
+ const sections = screen.getByRole("region");
167
+ expect(sections).toBeInTheDocument();
168
+
169
+ const links = screen.getByRole("link");
170
+ expect(links).toBeInTheDocument();
171
+ expect(links).toHaveTextContent("link 1");
172
+ expect(links).toHaveAttribute("href", "https://telia.se");
173
+ expect(links).toHaveAttribute("target", "_blank");
174
+ });
175
+
176
+ it("should render footer with additional content", () => {
177
+ render(
178
+ <Footer
179
+ additional={{
180
+ content: <div data-testid="additional-content">Additional content</div>,
181
+ sectionProps: { "aria-label": "Additional content section" },
182
+ }}
183
+ />
184
+ );
185
+
186
+ const sections = screen.getByRole("region");
187
+ expect(sections).toBeInTheDocument();
188
+
189
+ expect(screen.getByTestId("additional-content")).toBeInTheDocument();
190
+ expect(screen.getByTestId("additional-content")).toHaveTextContent("Additional content");
191
+ });
192
+ });
package/src/footer.tsx ADDED
@@ -0,0 +1,202 @@
1
+ import React, { HTMLAttributes, ReactElement } from "react";
2
+ import { Grid } from "@purpurds/grid";
3
+ import { Link, LinkProps } from "@purpurds/link";
4
+ import c from "classnames/bind";
5
+
6
+ import { rootClassName } from "./constants";
7
+ import styles from "./footer.module.scss";
8
+ import {
9
+ CustomLinkType,
10
+ FooterNavigation,
11
+ FooterNavigationItem,
12
+ FooterNavigationItemLink,
13
+ } from "./footer-navigation.tsx";
14
+ import { FooterSocialLink, FooterSocialLinkProps } from "./footer-social-link.tsx";
15
+ import { DataAttributes } from "./types";
16
+ const cx = c.bind(styles);
17
+
18
+ type LegalLink = FooterNavigationItemLink &
19
+ Omit<LinkProps, "href" | "variant" | "negative" | "children">;
20
+
21
+ export type FooterProps = DataAttributes &
22
+ HTMLAttributes<HTMLElement> & {
23
+ className?: string;
24
+ /**
25
+ * Navigation section of the footer.
26
+ */
27
+ navigation?: {
28
+ customLink?: CustomLinkType;
29
+ links: FooterNavigationItem[];
30
+ sectionProps?: DataAttributes & HTMLAttributes<HTMLElement>;
31
+ };
32
+ /**
33
+ * Social links section of the footer.
34
+ */
35
+ social?: {
36
+ links: FooterSocialLinkProps[];
37
+ sectionProps?: DataAttributes & HTMLAttributes<HTMLElement>;
38
+ };
39
+ /**
40
+ * Contacts section of the footer.
41
+ */
42
+ contacts?: {
43
+ copyright?: string;
44
+ address?: {
45
+ content: ReactElement;
46
+ addressProps?: DataAttributes & HTMLAttributes<HTMLElement>;
47
+ };
48
+ sectionProps?: DataAttributes & HTMLAttributes<HTMLElement>;
49
+ };
50
+ /**
51
+ * Legal links section of the footer.
52
+ */
53
+ legal?: {
54
+ links: LegalLink[];
55
+ sectionProps?: DataAttributes & HTMLAttributes<HTMLElement>;
56
+ };
57
+ /**
58
+ * Additional content section of the footer.
59
+ */
60
+ additional?: {
61
+ content: ReactElement;
62
+ sectionProps?: DataAttributes & HTMLAttributes<HTMLElement>;
63
+ };
64
+ /**
65
+ * Payment images section of the footer.
66
+ */
67
+ payments?: {
68
+ images: { src: string; alt: string }[];
69
+ sectionProps?: DataAttributes & HTMLAttributes<HTMLElement>;
70
+ };
71
+ };
72
+
73
+ export const Footer = ({
74
+ className,
75
+ navigation,
76
+ social,
77
+ contacts,
78
+ legal,
79
+ additional,
80
+ payments,
81
+ ...props
82
+ }: FooterProps) => {
83
+ const classes = cx([className, rootClassName]);
84
+
85
+ return (
86
+ <footer {...props} className={classes}>
87
+ <Grid>
88
+ <div className={cx(`${rootClassName}__container`)}>
89
+ <div className={cx(`${rootClassName}__logo`)}>
90
+ <img
91
+ src="https://cdn.voca.teliacompany.com/logo/Telia-secondary-default-v2.svg"
92
+ alt="Telia logo"
93
+ role="presentation"
94
+ />
95
+ </div>
96
+ {social && (
97
+ <section
98
+ {...social.sectionProps}
99
+ className={cx(
100
+ `${rootClassName}__social`,
101
+ `${rootClassName}__social--md`,
102
+ social.sectionProps?.className
103
+ )}
104
+ >
105
+ {social.links.map(({ icon, href, ...props }, i) => (
106
+ <FooterSocialLink {...props} icon={icon} href={href} key={i} />
107
+ ))}
108
+ </section>
109
+ )}
110
+ {navigation && (
111
+ <FooterNavigation
112
+ customLink={navigation.customLink}
113
+ navigationLinks={navigation.links}
114
+ sectionProps={navigation.sectionProps}
115
+ />
116
+ )}
117
+ {payments && (
118
+ <section
119
+ {...payments.sectionProps}
120
+ className={cx(`${rootClassName}__payments`, payments.sectionProps?.className)}
121
+ >
122
+ {payments.images.map((payment, i) => (
123
+ <img
124
+ className={cx(`${rootClassName}__payments-image`)}
125
+ src={payment.src}
126
+ alt={payment.alt}
127
+ key={i}
128
+ />
129
+ ))}
130
+ </section>
131
+ )}
132
+ {contacts && (
133
+ <section
134
+ {...contacts.sectionProps}
135
+ className={cx(`${rootClassName}__contacts`, contacts.sectionProps?.className)}
136
+ >
137
+ {contacts.copyright && (
138
+ <div className={cx(`${rootClassName}__contacts-copyright`)}>
139
+ {contacts.copyright}
140
+ </div>
141
+ )}
142
+ {contacts.address && (
143
+ <address
144
+ {...contacts.address.addressProps}
145
+ className={cx(
146
+ `${rootClassName}__contacts-address`,
147
+ contacts.address.addressProps?.className
148
+ )}
149
+ >
150
+ {contacts.address.content}
151
+ </address>
152
+ )}
153
+ </section>
154
+ )}
155
+ {(additional || legal) && (
156
+ <div className={cx(`${rootClassName}__legal`)}>
157
+ {additional && (
158
+ <section
159
+ {...additional.sectionProps}
160
+ className={cx(
161
+ `${rootClassName}__additional-content`,
162
+ additional.sectionProps?.className
163
+ )}
164
+ >
165
+ {additional.content}
166
+ </section>
167
+ )}
168
+ {legal && (
169
+ <section
170
+ {...legal.sectionProps}
171
+ className={cx(`${rootClassName}__legal-links`, legal.sectionProps?.className)}
172
+ >
173
+ {legal?.links?.map(({ href, text, ...props }, i) => (
174
+ <Link {...props} variant="standalone" negative href={href} key={i}>
175
+ {text}
176
+ </Link>
177
+ ))}
178
+ </section>
179
+ )}
180
+ </div>
181
+ )}
182
+ {social && (
183
+ <section
184
+ {...social.sectionProps}
185
+ className={cx(
186
+ `${rootClassName}__social`,
187
+ `${rootClassName}__social--sm`,
188
+ social.sectionProps?.className
189
+ )}
190
+ >
191
+ {social.links.map(({ icon, href, ...props }, i) => (
192
+ <FooterSocialLink {...props} icon={icon} href={href} key={i} />
193
+ ))}
194
+ </section>
195
+ )}
196
+ </div>
197
+ </Grid>
198
+ </footer>
199
+ );
200
+ };
201
+
202
+ Footer.displayName = "Footer";
@@ -0,0 +1,4 @@
1
+ declare module "*.scss" {
2
+ const styles: { [className: string]: string };
3
+ export default styles;
4
+ }
package/src/types.ts ADDED
@@ -0,0 +1,5 @@
1
+ // TODO: move to reusable package, once decided where it should be placed
2
+
3
+ export type DataAttributes = {
4
+ [key: `data-${string}`]: string;
5
+ };