diff --git a/.eslintrc b/.eslintrc index ca8821681..a0a557248 100644 --- a/.eslintrc +++ b/.eslintrc @@ -92,6 +92,13 @@ { "additionalHooks": "useIsomorphicLayoutEffect" } + ], + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + } ] } } diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 8ec43f037..2cf527d86 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -39,6 +39,7 @@ const preview: Preview = { "Typography", "Animation", ], + "Core", "General", ["Animations", "Button", ["Base", "With Icon"], "*"], "Form", diff --git a/src/theme/typography/types.ts b/src/theme/typography/types.ts index 855f409a5..3a85f1d89 100644 --- a/src/theme/typography/types.ts +++ b/src/theme/typography/types.ts @@ -7,6 +7,18 @@ export type TypographyCollectionMap = { export type TypographySetOptions = Partial; +export type TypographySizeType = + | "header-xxl" + | "header-xl" + | "header-lg" + | "header-md" + | "header-sm" + | "header-xs" + | "body-baseline" + | "body-lg" + | "body-md" + | "body-sm"; + export type TypographySet = { "header-xxl-light": CSSProp | string; "header-xxl-regular": CSSProp | string; diff --git a/src/typography/helper.ts b/src/typography/helper.ts new file mode 100644 index 000000000..02c512b19 --- /dev/null +++ b/src/typography/helper.ts @@ -0,0 +1,42 @@ +import { css } from "styled-components"; +import { Colour, Typography } from "../theme"; +import { TypographySizeType } from "../theme/typography/types"; +import { TypographyProps, TypographyWeight } from "./types"; + +export const getTextStyle = ( + type: TypographySizeType, + weight: TypographyWeight, + paragraph = false +) => { + const token = `${type}-${weight.toLowerCase()}`; + + return css` + ${Typography[token]} + ${paragraph ? "margin-bottom: 1.05em;" : "margin-bottom: 0;"} + `; +}; + +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; + `; + } +}; + +export const createTypographyStyles = ( + textStyle: TypographySizeType, + props: TypographyProps +) => css` + ${getTextStyle(textStyle, props.weight || "regular", props.paragraph)} + ${getDisplayStyle(props.inline, props.paragraph)} + color: ${Colour.text}; +`; diff --git a/src/typography/index.ts b/src/typography/index.ts new file mode 100644 index 000000000..6e90b17bc --- /dev/null +++ b/src/typography/index.ts @@ -0,0 +1,2 @@ +export * from "./typography"; +export * from "./types"; diff --git a/src/typography/types.ts b/src/typography/types.ts new file mode 100644 index 000000000..ae39cbafd --- /dev/null +++ b/src/typography/types.ts @@ -0,0 +1,18 @@ +export type TypographyWeight = "regular" | "semibold" | "bold" | "light"; + +export interface TypographyProps extends React.HTMLAttributes { + /** The font weight */ + weight?: TypographyWeight | undefined; + /** Specifies if text is displayed inline */ + inline?: boolean | undefined; + /** Specifies if text has a bottom margin */ + paragraph?: boolean | undefined; +} + +export interface TypographyLinkProps + extends React.AnchorHTMLAttributes { + /** The font weight */ + weight?: TypographyWeight | undefined; + /** Displays indicator to signal that link leads to an external site */ + external?: boolean | undefined; +} diff --git a/src/typography/typography.tsx b/src/typography/typography.tsx new file mode 100644 index 000000000..4c27841b1 --- /dev/null +++ b/src/typography/typography.tsx @@ -0,0 +1,88 @@ +import { ExternalIcon } from "@lifesg/react-icons/external"; +import styled, { css } from "styled-components"; +import { Colour } from "../theme"; +import { TypographySizeType } from "../theme/typography/types"; +import { createTypographyStyles, getTextStyle } from "./helper"; +import { TypographyLinkProps, TypographyProps } from "./types"; + +export namespace Typography { + const createHeader = ( + tag: keyof JSX.IntrinsicElements, + textStyle: TypographySizeType, + displayName: string + ) => { + const Header = styled(tag).attrs(({ inline }) => ({ + as: inline ? "span" : undefined, + }))` + ${(props) => createTypographyStyles(textStyle, props)} + `; + Header.displayName = `Typography.${displayName}`; + return Header; + }; + + export const HeaderXXL = createHeader("h1", "header-xxl", "HeaderXXL"); + export const HeaderXL = createHeader("h2", "header-xl", "HeaderXL"); + export const HeaderLG = createHeader("h3", "header-lg", "HeaderLG"); + export const HeaderMD = createHeader("h4", "header-md", "HeaderMD"); + export const HeaderSM = createHeader("h5", "header-sm", "HeaderSM"); + export const HeaderXS = createHeader("h6", "header-xs", "HeaderXS"); + + const createBody = (textStyle: TypographySizeType, displayName: string) => { + const Body = styled.p.attrs(({ inline }) => ({ + as: inline ? "span" : undefined, + }))` + ${(props) => createTypographyStyles(textStyle, props)} + `; + Body.displayName = `Typography.${displayName}`; + return Body; + }; + + export const BodyBL = createBody("body-baseline", "BodyBL"); + export const BodyLG = createBody("body-lg", "BodyLG"); + export const BodyMD = createBody("body-md", "BodyMD"); + export const BodySM = createBody("body-sm", "BodySM"); + + const createLinkComponent = ( + textStyle: TypographySizeType, + displayName: string + ) => { + const HyperlinkBase = styled.a` + ${(props) => css` + ${getTextStyle(textStyle, props.weight || "regular")} + color: ${Colour.hyperlink}; + text-decoration: none; + + :hover, + :active, + :focus { + color: ${Colour["text-hover"]}; + } + `} + `; + + const Component = ({ + external = false, + children, + ...rest + }: TypographyLinkProps) => ( + + {children} + {external && } + + ); + Component.displayName = `Typography.${displayName}`; + return Component; + }; + + export const LinkBL = createLinkComponent("body-baseline", "LinkBL"); + export const LinkMD = createLinkComponent("body-md", "LinkMD"); + export const LinkLG = createLinkComponent("body-lg", "LinkLG"); + export const LinkSM = createLinkComponent("body-sm", "LinkSM"); +} + +const StyledExternalIcon = styled(ExternalIcon)` + height: 1lh; + width: 1em; + margin-left: 0.4em; + vertical-align: middle; +`; diff --git a/stories/storybook-common/api-table/index.ts b/stories/storybook-common/api-table/index.ts index a35ae5012..aaa4b4ef5 100644 --- a/stories/storybook-common/api-table/index.ts +++ b/stories/storybook-common/api-table/index.ts @@ -1,3 +1,4 @@ -export * from "./api-table-components"; export * from "./api-table"; +export * from "./api-table-components"; export * from "./markup-helpers"; +export * from "./types"; diff --git a/stories/typography/props-table.tsx b/stories/typography/props-table.tsx new file mode 100644 index 000000000..87c12a6ef --- /dev/null +++ b/stories/typography/props-table.tsx @@ -0,0 +1,100 @@ +import { + ApiTable, + ApiTableSectionProps, + TabAttribute, + Tabs, +} from "../storybook-common"; + +const TEXT_DATA: ApiTableSectionProps[] = [ + { + attributes: [ + { + name: "", + description: ( + <> + This component also inherits props from{" "} + + HTMLElement + + + ), + }, + { + name: "inline", + description: + "Sets the text to an inline display to allow a combination of text in a single line", + propTypes: ["boolean"], + }, + { + name: "maxLines", + description: + "Specifies the number of lines visible. Additional lines will be truncated", + propTypes: ["number"], + }, + { + name: "paragraph", + description: + "Adds an extra bottom margin to allow a better separation of text blocks", + propTypes: ["boolean"], + defaultValue: "false", + }, + { + name: "weight", + description: "The weight of the text component", + propTypes: [`"regular"`, `"semibold"`, `"bold"`, `"light"`], + defaultValue: `"regular"`, + }, + ], + }, +]; + +const LINK_DATA: ApiTableSectionProps[] = [ + { + attributes: [ + { + name: "", + description: ( + <> + This component also inherits props from{" "} + + HTMLAnchorElement + + + ), + }, + { + name: "weight", + description: "The weight of the hyperlink component", + propTypes: [`"regular"`, `"semibold"`, `"bold"`, `"light"`], + defaultValue: `"regular"`, + }, + { + name: "external", + description: + "Indicates if the link is external to the domain. Adds an indicator at the end of the link", + propTypes: ["boolean"], + }, + ], + }, +]; + +const PROPS_TABLE_DATA: TabAttribute[] = [ + { + title: "Header/Body", + component: , + }, + { + title: "Link", + component: , + }, +]; + +export const PropsTable = () => ; diff --git a/stories/typography/typography.mdx b/stories/typography/typography.mdx new file mode 100644 index 000000000..964f05e4d --- /dev/null +++ b/stories/typography/typography.mdx @@ -0,0 +1,80 @@ +import { Canvas, Meta } from "@storybook/blocks"; +import { DocInfo } from "stories/storybook-common"; +import { PropsTable } from "./props-table"; +import * as TypographyStories from "./typography.stories"; + + + +# Typography + +## Overview + +The component that is used for headings, body text, links and more. + +```tsx +import { Typography } from "@lifesg/react-design-system/typography"; +``` + + + + +**Which module to use?**

+ +**Scenario 1: Text content** + +If you are rendering text content, such as headings, paragraphs and links, use +this Typography component as it comes with additional capabilities to help you +lay out text.

+ +**Scenario 2: Parent container** + +If you are rendering a container that contains text children but does not need +to be a text element itself, apply the [typography design +tokens](/docs/foundations-typography-introduction--docs) directly.

+ +**Scenario 3: HTML children** + +If you are rendering a container that can contain arbitrary HTML markup, use the +[Markup component](/docs/general-markup--docs). + +
+ +## Combining styles + +### Inline text + +You can nest text within text using the `inline` prop. The nested text is +rendered as a `span` element. + + + +### Inline link + +You can include links within a set of text. Links are inline by default. + + + +### Font weight + +You can include different weights within a set of text using the `weight` and +`inline` props. + + + +## Paragraph specification + +You can include paragraph spacing between text blocks by specifying the +`paragraph` prop. + + + +## External links + +If a link leads to an external site, it is recommended to specify the `external` +prop, which displays an indicator. + + + +## Component API + + diff --git a/stories/typography/typography.stories.tsx b/stories/typography/typography.stories.tsx new file mode 100644 index 000000000..45fca4716 --- /dev/null +++ b/stories/typography/typography.stories.tsx @@ -0,0 +1,103 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Typography } from "src/typography"; + +const meta: Meta = { + title: "Core/Typography", +}; +export default meta; + +export const InlineText: StoryObj = { + render: (_args) => ( + + The quick brown fox{" "} + jumps over the lazy + dog + + ), +}; + +export const InlineLink: StoryObj = { + render: (_args) => ( + + The quick brown fox{" "} + + jumps over + {" "} + the lazy dog + + ), +}; + +export const MixedFontWeights: StoryObj = { + render: (_args) => ( + + The{" "} + + quick brown fox + {" "} + + jumps over + {" "} + the{" "} + + lazy dog + + + ), +}; + +export const Paragraphs: StoryObj = { + render: (_args) => ( + <> + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi + euismod quam eget ex tincidunt dapibus. Donec vitae leo + vehicula, fermentum urna vitae, gravida ex. + + + Aenean imperdiet faucibus velit, eu maximus libero facilisis ut. + Donec nulla nisi, fermentum eget lorem at, feugiat ultricies ex. + Aliquam volutpat nibh non suscipit rhoncus. + + + ), +}; + +export const ExternalLink: StoryObj = { + render: (_args) => ( + + The quick brown fox{" "} + + jumps over + {" "} + the lazy dog + + ), +}; + +export const TypographySet: StoryObj = { + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ["pattern"], + render: (_args) => ( + <> + HeaderXXL: Lorem ipsum + HeaderXL: Lorem ipsum + HeaderLG: Lorem ipsum + HeaderMD: Lorem ipsum + HeaderSM: Lorem ipsum + HeaderXS: Lorem ipsum + BodyBL: Lorem ipsum + BodyMD: Lorem ipsum + BodySM: Lorem ipsum + LinkBL: Lorem ipsum + LinkMD: Lorem ipsum + LinkSM: Lorem ipsum + + ), +}; diff --git a/tests/typography/typography.spec.tsx b/tests/typography/typography.spec.tsx new file mode 100644 index 000000000..d2f525faf --- /dev/null +++ b/tests/typography/typography.spec.tsx @@ -0,0 +1,63 @@ +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", () => { + const mockTheme: ThemeSpec = { + colourScheme: "lifesg", + fontScheme: "lifesg", + animationScheme: "lifesg", + borderScheme: "lifesg", + spacingScheme: "lifesg", + radiusScheme: "lifesg", + breakpointScheme: "lifesg", + }; + + describe("Body Text", () => { + it("renders BodyLG with correct text", () => { + const { getByText } = render( + + + This is large body text + + + ); + + expect(getByText("This is large body text")).toBeInTheDocument(); + }); + }); + + describe("Link Components", () => { + it("renders external LinkSM with external icon", () => { + const { getByText, container } = render( + + + External Small Link + + + ); + + expect(getByText("External Small Link")).toBeInTheDocument(); + + const icon = container.querySelector("svg"); + expect(icon).not.toBeNull(); + }); + }); + + describe("Header Components", () => { + it("renders HeaderXXL with correct text", () => { + const { getByText } = render( + + + Hello World + + + ); + + expect(getByText("Hello World")).toBeInTheDocument(); + }); + }); +});