diff --git a/src/typography/helper.ts b/src/typography/helper.ts new file mode 100644 index 000000000..0a8f6fe56 --- /dev/null +++ b/src/typography/helper.ts @@ -0,0 +1,98 @@ +import { css } from "styled-components"; +import { TypographyProps, TypographySizeType, TypographyWeight } from "./types"; +import { TypographyStyle } from "./typography-style"; +import { Colour, Font } from "../theme"; +import { StyledComponentProps } from "../theme/helpers"; + +const getResolvedTypographyWeight = ( + weight: TypographyWeight, + props: StyledComponentProps +): string => { + const weightMap: Record = { + 300: "light", + 400: "regular", + 600: "semibold", + 700: "bold", + }; + + // If resolvedWeight is a string that is a number for eg "400", convert it to a number + const numericWeight = + typeof weight === "string" && !isNaN(Number(weight)) + ? Number(weight) + : weight; + + // Map it to its string equivalent + const mappedWeight = weightMap[numericWeight as number] || numericWeight; + const finalWeight = Font[`weight-${mappedWeight}`]; + + // If final weight is a function, resolve it with props + return typeof finalWeight === "function" ? finalWeight(props) : finalWeight; +}; + +export const getTypographyStyle = ( + type: TypographySizeType, + weight: TypographyWeight, + paragraph = false +) => { + return (props: any) => { + const attrs = TypographyStyle[type]; + + const resolvedWeight = getResolvedTypographyWeight(weight, props); + + // Check if function if so resolve with props + const fontSize = + typeof attrs.fontSize === "function" + ? attrs.fontSize(props) + : attrs.fontSize; + + // Make it a int for calc + const fontSizeValue = parseFloat(fontSize); + const fontSizeUnit = fontSize.replace(fontSizeValue.toString(), ""); + + // Add extra margin for paragraphs + const getMarginBottomStyle = () => { + const marginBottomScale = paragraph ? 1.05 : 0; + return css` + margin-bottom: ${fontSizeValue * marginBottomScale}${fontSizeUnit}; + `; + }; + + return css` + font-size: ${fontSize}; + line-height: ${attrs.lineHeight}; + letter-spacing: ${attrs.letterSpacing || 0}; + font-weight: ${resolvedWeight}; + ${getMarginBottomStyle()} + `; + }; +}; + +export const getDisplayStyle = (inline = false, paragraph = false) => { + if (paragraph) { + return css` + display: block; + `; + } else if (inline) { + return css` + display: inline; + `; + } else { + return css` + display: block; + `; + } +}; + +// Helper func to refactor code +export const createTypographyStyles = ( + textStyle: TypographySizeType, + props: TypographyProps +) => css` + ${getTypographyStyle( + textStyle, + props.weight || "regular", + props.paragraph + )(props)} + color: ${Colour.Primitive["neutral-20"]}; + ${getDisplayStyle(props.inline, props.paragraph)} +`; diff --git a/src/typography/types.ts b/src/typography/types.ts new file mode 100644 index 000000000..9046f5b90 --- /dev/null +++ b/src/typography/types.ts @@ -0,0 +1,51 @@ +export type TypographySizeType = + | "HeaderXXL" + | "HeaderXL" + | "HeaderLG" + | "HeaderMD" + | "HeaderSM" + | "HeaderXS" + | "BodyBL" + | "BodyLG" + | "BodyMD" + | "BodySM" + | "LinkBL" + | "LinkMD" + | "LinkLG" + | "LinkSM"; + +export interface TypographyStyleSpec { + fontSize?: number | undefined; + fontWeight?: number | undefined; + lineHeight?: number | undefined; + letterSpacing?: number | undefined; +} + +export type TypographyWeight = + | "regular" + | "semibold" + | "bold" + | "light" + | 400 + | 600 + | 700 + | 300; + +export type TextStyleSetType = { + [key in TypographySizeType]: TypographyStyleSpec; +}; + +export interface TypographyProps extends React.HTMLAttributes { + // Can be any weight such as regular or 400 + weight?: TypographyWeight; + // For consumer to choose if they want the text to be inline for example + inline?: boolean; + // For consumer to choose for block level style + paragraph?: boolean; +} + +export interface LinkProps extends TypographyProps { + // If the link is external + external?: boolean; + textStyle?: TypographySizeType; +} diff --git a/src/typography/typography-style.ts b/src/typography/typography-style.ts new file mode 100644 index 000000000..14959b256 --- /dev/null +++ b/src/typography/typography-style.ts @@ -0,0 +1,75 @@ +import { Font } from "../theme"; + +export const TypographyStyle = { + HeaderXXL: { + fontSize: Font["header-size-xxl"], + lineHeight: Font["header-lh-xxl"], + letterSpacing: Font["header-ls-xxl"], + }, + HeaderXL: { + fontSize: Font["header-size-xl"], + lineHeight: Font["header-lh-xl"], + letterSpacing: Font["header-ls-xl"], + }, + HeaderLG: { + fontSize: Font["header-size-lg"], + lineHeight: Font["header-lh-lg"], + letterSpacing: Font["header-ls-lg"], + }, + HeaderMD: { + fontSize: Font["header-size-md"], + lineHeight: Font["header-lh-md"], + letterSpacing: Font["header-ls-md"], + }, + HeaderSM: { + fontSize: Font["header-size-sm"], + lineHeight: Font["header-lh-sm"], + letterSpacing: Font["header-ls-sm"], + }, + HeaderXS: { + fontSize: Font["header-size-xs"], + lineHeight: Font["header-lh-xs"], + letterSpacing: Font["header-ls-xs"], + }, + + BodyBL: { + fontSize: Font["body-size-baseline"], + lineHeight: Font["body-lh-baseline"], + letterSpacing: Font["body-ls-baseline"], + }, + BodyLG: { + fontSize: Font["body-size-lg"], + lineHeight: Font["body-lh-lg"], + letterSpacing: Font["body-ls-lg"], + }, + BodyMD: { + fontSize: Font["body-size-md"], + lineHeight: Font["body-lh-md"], + letterSpacing: Font["body-ls-md"], + }, + BodySM: { + fontSize: Font["body-size-sm"], + lineHeight: Font["body-lh-sm"], + letterSpacing: Font["body-ls-sm"], + }, + LinkBL: { + fontSize: Font["body-size-baseline"], + lineHeight: Font["body-lh-baseline"], + letterSpacing: Font["body-ls-baseline"], + }, + LinkLG: { + fontSize: Font["body-size-lg"], + lineHeight: Font["body-lh-lg"], + letterSpacing: Font["body-ls-lg"], + }, + LinkMD: { + fontSize: Font["body-size-md"], + lineHeight: Font["body-lh-md"], + letterSpacing: Font["body-ls-md"], + }, + LinkSM: { + fontSize: Font["body-size-sm"], + lineHeight: Font["body-lh-sm"], + letterSpacing: Font["body-ls-sm"], + }, +}; diff --git a/src/typography/typography.tsx b/src/typography/typography.tsx new file mode 100644 index 000000000..be7982f8b --- /dev/null +++ b/src/typography/typography.tsx @@ -0,0 +1,106 @@ +import styled, { css } from "styled-components"; +import { + createTypographyStyles, + getDisplayStyle, + getTypographyStyle, +} from "./helper"; +import { Colour } from "../theme"; +import { LinkProps, TypographyProps, TypographySizeType } from "./types"; +import { ExternalIcon } from "@lifesg/react-icons/external"; + +export namespace Typography { + const createHeader = ( + tag: keyof JSX.IntrinsicElements, + textStyle: TypographySizeType + ) => { + const Header = styled(tag)` + ${(props: TypographyProps) => + createTypographyStyles(textStyle, props)} + `; + Header.displayName = `Header-${textStyle}`; + return Header; + }; + + export const HeaderXXL = createHeader("h1", "HeaderXXL"); + export const HeaderXL = createHeader("h2", "HeaderXL"); + export const HeaderLG = createHeader("h3", "HeaderLG"); + export const HeaderMD = createHeader("h4", "HeaderMD"); + export const HeaderSM = createHeader("h5", "HeaderSM"); + export const HeaderXS = createHeader("h6", "HeaderXS"); + + const createBody = (textStyle: TypographySizeType) => { + const Body = styled.p` + ${(props: TypographyProps) => + createTypographyStyles(textStyle, props)} + `; + Body.displayName = `Body-${textStyle}`; + return Body; + }; + + export const BodyBL = createBody("BodyBL"); + export const BodyLG = createBody("BodyLG"); + export const BodyMD = createBody("BodyMD"); + export const BodySM = createBody("BodySM"); + + const createLinkComponent = (textStyle: TypographySizeType) => { + const Component = (props: LinkProps) => ( + + ); + Component.displayName = `Link-${textStyle}`; + return Component; + }; + + export const LinkBL = createLinkComponent("LinkBL"); + export const LinkMD = createLinkComponent("LinkMD"); + export const LinkLG = createLinkComponent("LinkLG"); + export const LinkSM = createLinkComponent("LinkSM"); +} + +console.log("Hello"); + +// FOR LINK : +export const StyledExternalIcon = styled(ExternalIcon)` + height: 1em; + width: 1em; + margin-left: 0.4em; + vertical-align: middle; +`; + +const HyperlinkBase = styled.a` + ${(props) => { + return css` + ${getTypographyStyle( + props.textStyle, + props.weight || "regular" + )(props)} + color: ${Colour.hyperlink}; + text-decoration: none; + + :hover, + :active, + :focus { + color: ${Colour["text-hover"]}; + + svg { + color: ${Colour["text-hover"]}; + } + } + + ${getDisplayStyle(props.inline, props.paragraph)} + `; + }} +`; + +const HyperlinkComponent = ({ + external = false, + children, + ...rest +}: LinkProps) => { + return ( + + {children} + {external && } + + ); +}; +HyperlinkComponent.displayName = "HyperlinkComponent"; diff --git a/stories/typography-test/Typography-Body.stories.tsx b/stories/typography-test/Typography-Body.stories.tsx new file mode 100644 index 000000000..a67257da1 --- /dev/null +++ b/stories/typography-test/Typography-Body.stories.tsx @@ -0,0 +1,38 @@ +import { ThemeProvider } from "styled-components"; +import { Typography } from "../../src/typography/typography"; +import { mockOverrideTheme, mockTheme } from "./mock-theme"; + +export default { + title: "Typography-Test/Body", + component: Typography, +}; + +export const Body_Inline = () => ( + + + Testing for BodyBL and Bold with inline + +
+ + Testing for BodyLG and SemiBold with inline + +
+ + Testing for BodyMD and light with inline + +
+); + +export const Body_Paragraph = () => ( + + + Testing for BodyBL and Bold with paragraph + + + Testing for BodyLG and regular with paragraph + + + Testing for BodyMD and Light with paragraph + + +); diff --git a/stories/typography-test/Typography-Header.stories.tsx b/stories/typography-test/Typography-Header.stories.tsx new file mode 100644 index 000000000..3616c4a84 --- /dev/null +++ b/stories/typography-test/Typography-Header.stories.tsx @@ -0,0 +1,37 @@ +import { ThemeProvider } from "styled-components"; +import { Typography } from "../../src/typography/typography"; +import { mockTheme } from "./mock-theme"; + +export default { + title: "Typography-Test/Header", + component: Typography, +}; + +// HeaderXXL with bold weight and inline display +export const Header_Inline = () => ( + + + Testing for HeaderXXL and Bold with inline + + + Testing for HeaderXL and regualar with inline + + + Testing for HeaderSM and Bold with inline + + +); + +export const Header_Paragraph = () => ( + + + Testing for HeaderXXL and Bold with paragraph + + + Testing for HeaderXL and Bold with paragraph + + + Testing for HeaderSM and Light with paragraph + + +); diff --git a/stories/typography-test/Typography-Link.stories.tsx b/stories/typography-test/Typography-Link.stories.tsx new file mode 100644 index 000000000..2d076a67b --- /dev/null +++ b/stories/typography-test/Typography-Link.stories.tsx @@ -0,0 +1,30 @@ +import { ThemeProvider } from "styled-components"; +import { Typography } from "../../src/typography/typography"; +import { mockTheme } from "./mock-theme"; + +export default { + title: "Typography-Test/Link", + component: Typography, +}; + +export const LinkBL_Regular = () => ( + + + This is a baseline link + + +); + +export const LinkLG_Bold_Inline = () => ( + + + This is a bold link with inline display + + +); + +export const LinkSM_External = () => ( + + External Small Link + +); diff --git a/stories/typography-test/mock-theme.ts b/stories/typography-test/mock-theme.ts new file mode 100644 index 000000000..f9160551a --- /dev/null +++ b/stories/typography-test/mock-theme.ts @@ -0,0 +1,26 @@ +import { ThemeSpec } from "../../src/theme/types"; + +export const mockTheme: ThemeSpec = { + colourScheme: "lifesg", + fontScheme: "lifesg", + animationScheme: "lifesg", + borderScheme: "lifesg", + spacingScheme: "lifesg", + radiusScheme: "lifesg", + breakpointScheme: "lifesg", +}; + +export const mockOverrideTheme: ThemeSpec = { + colourScheme: "lifesg", + fontScheme: "lifesg", + animationScheme: "lifesg", + borderScheme: "lifesg", + spacingScheme: "lifesg", + radiusScheme: "lifesg", + breakpointScheme: "lifesg", + overrides: { + primitiveColour: { + "neutral-20": "#9D00FF", + }, + }, +}; diff --git a/tests/typography/typography-component-body.spec.tsx b/tests/typography/typography-component-body.spec.tsx new file mode 100644 index 000000000..fcaeb9e7d --- /dev/null +++ b/tests/typography/typography-component-body.spec.tsx @@ -0,0 +1,78 @@ +import { render } from "@testing-library/react"; +import "jest-styled-components"; +import { Typography } from "../../src/typography/typography"; +import { ThemeSpec } from "../../src/theme/types"; +import { ThemeProvider } from "styled-components"; + +describe("Typography Components", () => { + it("renders BodyBL with correct styles", () => { + const mockTheme: ThemeSpec = { + colourScheme: "lifesg", + fontScheme: "lifesg", + animationScheme: "lifesg", + borderScheme: "lifesg", + spacingScheme: "lifesg", + radiusScheme: "lifesg", + breakpointScheme: "lifesg", + }; + + const { container } = render( + + + This is body text + + + ); + + const p = container.firstChild; + expect(p).toHaveStyleRule("font-size", "1.125rem"); + expect(p).toHaveStyleRule("line-height", "1.625rem"); + expect(p).toHaveStyleRule("letter-spacing", "0rem"); + expect(p).toHaveStyleRule("font-weight", "400"); + expect(p).toHaveStyleRule("color", "#282828"); + }); + + it("renders BodyLG with correct styles", () => { + const mockTheme: ThemeSpec = { + colourScheme: "lifesg", + fontScheme: "lifesg", + animationScheme: "lifesg", + borderScheme: "lifesg", + spacingScheme: "lifesg", + radiusScheme: "lifesg", + breakpointScheme: "lifesg", + }; + + const { container } = render( + + + This is large body text + + + ); + + const p = container.firstChild; + expect(p).toHaveStyleRule("font-size", "1rem"); + expect(p).toHaveStyleRule("line-height", "1.5rem"); + expect(p).toHaveStyleRule("letter-spacing", "0.014rem"); + expect(p).toHaveStyleRule("font-weight", "600"); + expect(p).toHaveStyleRule("display", "inline"); + expect(p).toHaveStyleRule("color", "#282828"); + }); + + it("renders BodyMD with paragraph styling", () => { + const { container } = render( + + This is medium body text + + ); + + const p = container.firstChild; + expect(p).toHaveStyleRule("font-size", "0.875rem"); + expect(p).toHaveStyleRule("line-height", "1.6rem"); + expect(p).toHaveStyleRule("letter-spacing", "0.012rem"); + expect(p).toHaveStyleRule("margin-bottom", "0.9187500000000001rem"); + expect(p).toHaveStyleRule("display", "block"); + expect(p).toHaveStyleRule("color", "#282828"); + }); +}); diff --git a/tests/typography/typography-component-link.spec.tsx b/tests/typography/typography-component-link.spec.tsx new file mode 100644 index 000000000..765909818 --- /dev/null +++ b/tests/typography/typography-component-link.spec.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import "jest-styled-components"; +import { Typography } from "../../src/typography/typography"; +import { ThemeSpec } from "../../src/theme/types"; +import { ThemeProvider } from "styled-components"; + +describe("Typography Link Components", () => { + it("renders LinkBL with correct styles", () => { + const mockTheme: ThemeSpec = { + colourScheme: "lifesg", + fontScheme: "lifesg", + animationScheme: "lifesg", + borderScheme: "lifesg", + spacingScheme: "lifesg", + radiusScheme: "lifesg", + breakpointScheme: "lifesg", + }; + + const { container } = render( + + + This is a baseline link + + + ); + + const link = container.firstChild; + expect(link).toHaveStyleRule("font-size", "1.125rem"); + expect(link).toHaveStyleRule("line-height", "1.625rem"); + expect(link).toHaveStyleRule("letter-spacing", "0rem"); + expect(link).toHaveStyleRule("font-weight", "400"); + expect(link).toHaveStyleRule("color", "#1768BE"); + }); + + it("renders LinkLG with correct styles", () => { + const mockTheme: ThemeSpec = { + colourScheme: "lifesg", + fontScheme: "lifesg", + animationScheme: "lifesg", + borderScheme: "lifesg", + spacingScheme: "lifesg", + radiusScheme: "lifesg", + breakpointScheme: "lifesg", + }; + + const { container } = render( + + + This is a large link + + + ); + + const link = container.firstChild; + expect(link).toHaveStyleRule("font-size", "1rem"); + expect(link).toHaveStyleRule("line-height", "1.5rem"); + expect(link).toHaveStyleRule("letter-spacing", "0.014rem"); + expect(link).toHaveStyleRule("font-weight", "600"); + expect(link).toHaveStyleRule("display", "inline"); + expect(link).toHaveStyleRule("color", "#1768BE"); + }); + + it("renders external LinkSM with external icon", () => { + const mockTheme: ThemeSpec = { + colourScheme: "lifesg", + fontScheme: "lifesg", + animationScheme: "lifesg", + borderScheme: "lifesg", + spacingScheme: "lifesg", + radiusScheme: "lifesg", + breakpointScheme: "lifesg", + }; + + const { container } = render( + + + External Small Link + + + ); + + const link = container.firstChild; + expect(link).toHaveStyleRule("font-size", "0.75rem"); + expect(link).toHaveStyleRule("line-height", "1.2rem"); + expect(link).toHaveStyleRule("letter-spacing", "0.012rem"); + expect(link).toHaveStyleRule("color", "#1768BE"); + + const icon = container.querySelector("svg"); + expect(icon).not.toBeNull(); + }); +}); diff --git a/tests/typography/typography-component.spec.tsx b/tests/typography/typography-component.spec.tsx new file mode 100644 index 000000000..7878a6455 --- /dev/null +++ b/tests/typography/typography-component.spec.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import "jest-styled-components"; +import { Typography } from "../../src/typography/typography"; +import { ThemeSpec } from "../../src/theme/types"; +import { ThemeProvider } from "styled-components"; + +describe("Typography Components", () => { + it("renders H1 with correct styles", () => { + const mockTheme: ThemeSpec = { + colourScheme: "lifesg", + fontScheme: "lifesg", + animationScheme: "lifesg", + borderScheme: "lifesg", + spacingScheme: "lifesg", + radiusScheme: "lifesg", + breakpointScheme: "lifesg", + }; + + const { container } = render( + + + Hello World + + + ); + + const h1 = container.firstChild; + expect(h1).toHaveStyleRule("font-size", "3rem"); + expect(h1).toHaveStyleRule("display", "inline"); + expect(h1).toHaveStyleRule("color", "#282828"); + expect(h1).toHaveStyleRule("font-weight", "700"); + }); + + it("renders H2 as block with paragraph styling", () => { + const { container } = render( + Hello World + ); + + const h2 = container.firstChild; + expect(h2).toHaveStyleRule("font-size", "2.5rem"); + expect(h2).toHaveStyleRule("display", "block"); + expect(h2).toHaveStyleRule("margin-bottom", "2.625rem"); + expect(h2).toHaveStyleRule("color", "#282828"); + expect(h2).toHaveStyleRule("font-weight", "400"); + }); +});