From 33d0c9fdcaf31cc86d96b074ca7417398682f513 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Wed, 20 Dec 2023 11:02:59 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(react)=20add=20Alert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Here is the Alert component based on recent delivered sketches. There is a main component that based on props renders sub alert components. --- .changeset/spotty-hotels-study.md | 5 + .../src/components/Alert/AlertAdditional.tsx | 26 ++ .../Alert/AlertAdditionalExpandable.tsx | 45 ++++ .../src/components/Alert/AlertOneLine.tsx | 32 +++ packages/react/src/components/Alert/Utils.tsx | 94 +++++++ .../react/src/components/Alert/_index.scss | 102 +++++++ .../react/src/components/Alert/index.spec.tsx | 251 ++++++++++++++++++ packages/react/src/components/Alert/index.tsx | 60 +++++ packages/react/src/components/Alert/tokens.ts | 13 + packages/react/src/index.scss | 1 + packages/react/src/index.ts | 1 + packages/react/src/locales/en-US.json | 5 + packages/react/src/locales/fr-FR.json | 5 + 13 files changed, 640 insertions(+) create mode 100644 .changeset/spotty-hotels-study.md create mode 100644 packages/react/src/components/Alert/AlertAdditional.tsx create mode 100644 packages/react/src/components/Alert/AlertAdditionalExpandable.tsx create mode 100644 packages/react/src/components/Alert/AlertOneLine.tsx create mode 100644 packages/react/src/components/Alert/Utils.tsx create mode 100644 packages/react/src/components/Alert/_index.scss create mode 100644 packages/react/src/components/Alert/index.spec.tsx create mode 100644 packages/react/src/components/Alert/index.tsx create mode 100644 packages/react/src/components/Alert/tokens.ts diff --git a/.changeset/spotty-hotels-study.md b/.changeset/spotty-hotels-study.md new file mode 100644 index 0000000..f105231 --- /dev/null +++ b/.changeset/spotty-hotels-study.md @@ -0,0 +1,5 @@ +--- +"@openfun/cunningham-react": minor +--- + +add Alert diff --git a/packages/react/src/components/Alert/AlertAdditional.tsx b/packages/react/src/components/Alert/AlertAdditional.tsx new file mode 100644 index 0000000..24e6b6f --- /dev/null +++ b/packages/react/src/components/Alert/AlertAdditional.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { AlertProps } from ":/components/Alert/index"; +import { + AlertButtons, + AlertClose, + AlertIcon, + AlertWrapper, +} from ":/components/Alert/Utils"; + +export const AlertAdditional = (props: AlertProps) => { + return ( + +
+
+ {props.children} + +
+ +
+
{props.additional}
+
+ +
+
+ ); +}; diff --git a/packages/react/src/components/Alert/AlertAdditionalExpandable.tsx b/packages/react/src/components/Alert/AlertAdditionalExpandable.tsx new file mode 100644 index 0000000..9de51cd --- /dev/null +++ b/packages/react/src/components/Alert/AlertAdditionalExpandable.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { useControllableState } from ":/hooks/useControllableState"; +import { Button } from ":/components/Button"; +import { AlertProps } from ":/components/Alert/index"; +import { AlertAdditional } from ":/components/Alert/AlertAdditional"; +import { AlertOneLine } from ":/components/Alert/AlertOneLine"; +import { useCunningham } from ":/components/Provider"; + +export const AlertAdditionalExpandable = (props: AlertProps) => { + const { t } = useCunningham(); + const [expanded, onExpand] = useControllableState( + false, + props.expanded, + props.onExpand, + ); + + const iconButton = ( + + )} + {props.primaryLabel && ( + + )} + {props.buttons} + + ); +}; diff --git a/packages/react/src/components/Alert/_index.scss b/packages/react/src/components/Alert/_index.scss new file mode 100644 index 0000000..35ba9b5 --- /dev/null +++ b/packages/react/src/components/Alert/_index.scss @@ -0,0 +1,102 @@ +.c__alert { + padding: 0.75rem 1rem; + background-color: var(--c--components--alert--background-color); + border-radius: var(--c--components--alert--border-radius); + border-style: solid; + border-width: 1px; + border-left-width: 3px; + font-weight: var(--c--components--alert--font-weight); + color: var(--c--components--alert--color); + box-sizing: border-box; + border-color: var(--c--theme--colors--greyscale-300); + border-left-color: var(--c--theme--colors--greyscale-600); + + &__icon { + display: flex; + align-items: center; + width: 1.5rem; + height: 1.5rem; + justify-content: center; + + span { + font-size: var(--c--components--alert--icon-size); + } + } + + &__content { + display: flex; + align-items: center; + justify-content: space-between; + // We want to maintain the same height as when a button is present. + min-height: 2.25rem; + + &__left { + display: flex; + align-items: center; + gap: 0.5rem; + // For accessibility reasons, we want to make sure that the text is the first thing that is read. + flex-direction: row-reverse; + } + } + + &-additional { + + &__buttons { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + } + } + + .c__alert__additional { + font-weight: var(--c--components--alert--additional-font-weight); + color: var(--c--components--alert--additional-color); + } + + &:not(.c__alert--neutral), &.c__alert--expandable { + .c__alert__additional { + padding-left: 2rem; + } + } + + &__actions { + display: flex; + align-items: center; + } + + &--info { + border-color: var(--c--theme--colors--primary-300); + border-left-color: var(--c--theme--colors--primary-600); + + .c__alert__icon { + color: var(--c--theme--colors--primary-600); + } + } + + &--success { + border-color: var(--c--theme--colors--success-300); + border-left-color: var(--c--theme--colors--success-600); + + .c__alert__icon { + color: var(--c--theme--colors--success-600); + } + } + + &--warning { + border-color: var(--c--theme--colors--warning-300); + border-left-color: var(--c--theme--colors--warning-600); + + .c__alert__icon { + color: var(--c--theme--colors--warning-600); + } + } + + &--error { + border-color: var(--c--theme--colors--danger-300); + border-left-color: var(--c--theme--colors--danger-600); + + .c__alert__icon { + color: var(--c--theme--colors--danger-600); + } + } +} diff --git a/packages/react/src/components/Alert/index.spec.tsx b/packages/react/src/components/Alert/index.spec.tsx new file mode 100644 index 0000000..b4508e9 --- /dev/null +++ b/packages/react/src/components/Alert/index.spec.tsx @@ -0,0 +1,251 @@ +import { render, screen } from "@testing-library/react"; +import React from "react"; +import userEvent from "@testing-library/user-event"; +import { Alert, AlertType } from ":/components/Alert/index"; +import { Button } from ":/components/Button"; +import { CunninghamProvider } from ":/components/Provider"; + +describe("", () => { + it.each([ + [AlertType.INFO, "info"], + [AlertType.SUCCESS, "task_alt"], + [AlertType.WARNING, "warning"], + [AlertType.ERROR, "cancel"], + [AlertType.NEUTRAL, undefined], + ])("renders % alert with according icon", (type, icon) => { + render( + + Alert component + , + ); + const $icon = document.querySelector(".c__alert__icon"); + if (icon) { + expect($icon).toHaveTextContent(icon!); + } else { + expect($icon).toBeNull(); + } + }); + it("renders additional information", () => { + render( + + + Alert component + + , + ); + expect(screen.getByText("Additional information")).toBeInTheDocument(); + }); + it("renders primary button when primaryLabel is provided", () => { + render( + + + Alert component + + , + ); + screen.getByRole("button", { name: "Primary" }); + }); + it("renders tertiary button when tertiaryLabel is provided", () => { + render( + + + Alert component + + , + ); + screen.getByRole("button", { name: "Tertiary" }); + }); + it("renders both buttons labels are provided", () => { + render( + + + Alert component + + , + ); + screen.getByRole("button", { name: "Primary" }); + screen.getByRole("button", { name: "Tertiary" }); + }); + it("renders custom buttons via buttons props", () => { + render( + + + + + + } + > + Alert component + + , + ); + screen.getByRole("button", { name: "Primary Custom" }); + screen.getByRole("button", { name: "Secondary Custom" }); + }); + it("can close the alert non controlled", async () => { + render( + + + Alert component + + , + ); + + screen.getByText("Alert component"); + expect(document.querySelector(".c__alert")).toBeInTheDocument(); + + const $close = screen.getByRole("button", { name: "Delete alert" }); + await userEvent.click($close); + + expect(screen.queryByText("Alert component")).not.toBeInTheDocument(); + expect(document.querySelector(".c__alert")).not.toBeInTheDocument(); + }); + it("can close the alert controlled", async () => { + const Wrapper = () => { + const [closed, setClosed] = React.useState(false); + return ( + + setClosed(flag)} + > + Alert component + +
Closed: {closed ? "true" : "false"}
+ + +
+ ); + }; + render(); + + const user = userEvent.setup(); + + screen.getByText("Closed: false"); + + expect(document.querySelector(".c__alert")).toBeInTheDocument(); + + // Close from button. + const $closeButton = screen.getByRole("button", { name: "Close" }); + await user.click($closeButton); + + expect(document.querySelector(".c__alert")).not.toBeInTheDocument(); + screen.getByText("Closed: true"); + + // Open from button. + const $openButton = screen.getByRole("button", { name: "Open" }); + await user.click($openButton); + + expect(document.querySelector(".c__alert")).toBeInTheDocument(); + screen.getByText("Closed: false"); + + // Close from alert. + const $close = screen.getByRole("button", { name: "Delete alert" }); + await userEvent.click($close); + + screen.getByText("Closed: true"); + }); + it("can expand the alert non controlled", async () => { + render( + + + Alert component + + , + ); + + const user = userEvent.setup(); + + screen.getByText("Alert component"); + expect( + screen.queryByText("Additional information"), + ).not.toBeInTheDocument(); + + const $expandButton = screen.getByRole("button", { name: "Expand alert" }); + await user.click($expandButton); + + expect(screen.queryByText("Additional information")).toBeInTheDocument(); + + const $shrinkButton = screen.getByRole("button", { name: "Shrink alert" }); + await user.click($shrinkButton); + + expect( + screen.queryByText("Additional information"), + ).not.toBeInTheDocument(); + }); + it("can expand the alert controlled", async () => { + const Wrapper = () => { + const [expanded, setExpanded] = React.useState(false); + return ( + + setExpanded(flag)} + > + Alert component + +
Expanded: {expanded ? "true" : "false"}
+ + +
+ ); + }; + render(); + + const user = userEvent.setup(); + + screen.getByText("Expanded: false"); + + // Expand from button. + const $expandButton = screen.getByRole("button", { name: "Expand" }); + await user.click($expandButton); + + screen.getByText("Expanded: true"); + expect(screen.queryByText("Additional information")).toBeInTheDocument(); + + // Shrink from button. + const $shrinkButton = screen.getByRole("button", { name: "Shrink" }); + await user.click($shrinkButton); + + screen.getByText("Expanded: false"); + expect( + screen.queryByText("Additional information"), + ).not.toBeInTheDocument(); + + // Expand from alert. + const $expandAlertButton = screen.getByRole("button", { + name: "Expand alert", + }); + await user.click($expandAlertButton); + + screen.getByText("Expanded: true"); + expect(screen.queryByText("Additional information")).toBeInTheDocument(); + + // Expand from alert. + const $shrinkAlertButton = screen.getByRole("button", { + name: "Shrink alert", + }); + await user.click($shrinkAlertButton); + + screen.getByText("Expanded: false"); + expect( + screen.queryByText("Additional information"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/components/Alert/index.tsx b/packages/react/src/components/Alert/index.tsx new file mode 100644 index 0000000..44c9468 --- /dev/null +++ b/packages/react/src/components/Alert/index.tsx @@ -0,0 +1,60 @@ +import React, { PropsWithChildren, ReactNode } from "react"; +import { ButtonProps } from ":/components/Button"; +import { useControllableState } from ":/hooks/useControllableState"; +import { AlertAdditionalExpandable } from ":/components/Alert/AlertAdditionalExpandable"; +import { AlertAdditional } from ":/components/Alert/AlertAdditional"; +import { AlertOneLine } from ":/components/Alert/AlertOneLine"; + +export enum AlertType { + INFO = "info", + SUCCESS = "success", + WARNING = "warning", + ERROR = "error", + NEUTRAL = "neutral", +} + +export interface AlertProps extends PropsWithChildren { + additional?: React.ReactNode; + buttons?: React.ReactNode; + canClose?: boolean; + className?: string; + closed?: boolean; + expandable?: boolean; + expanded?: boolean; + hide?: boolean; + icon?: ReactNode; + onClose?: (value: boolean) => void; + onExpand?: (value: boolean) => void; + primaryLabel?: string; + primaryOnClick?: ButtonProps["onClick"]; + primaryProps?: ButtonProps; + tertiaryLabel?: string; + tertiaryOnClick?: ButtonProps["onClick"]; + tertiaryProps?: ButtonProps; + type?: AlertType; +} + +export const Alert = (props: AlertProps) => { + const [closed, onClose] = useControllableState( + false, + props.closed, + props.onClose, + ); + + const propsWithDefault = { + type: AlertType.INFO, + ...props, + onClose, + }; + + if (closed) { + return null; + } + if (props.additional) { + if (props.expandable) { + return ; + } + return ; + } + return ; +}; diff --git a/packages/react/src/components/Alert/tokens.ts b/packages/react/src/components/Alert/tokens.ts new file mode 100644 index 0000000..699f63c --- /dev/null +++ b/packages/react/src/components/Alert/tokens.ts @@ -0,0 +1,13 @@ +import { DefaultTokens } from "@openfun/cunningham-tokens"; + +export const tokens = (defaults: DefaultTokens) => { + return { + "background-color": defaults.theme.colors["greyscale-100"], + "border-radius": "4px", + "font-weight": defaults.theme.font.weights.medium, + color: defaults.theme.colors["greyscale-900"], + "icon-size": defaults.theme.font.sizes.l, + "additional-font-weight": defaults.theme.font.weights.regular, + "additional-color": defaults.theme.colors["greyscale-700"], + }; +}; diff --git a/packages/react/src/index.scss b/packages/react/src/index.scss index 956780e..e50efe6 100644 --- a/packages/react/src/index.scss +++ b/packages/react/src/index.scss @@ -5,6 +5,7 @@ @use "@openfun/cunningham-tokens/default-tokens"; @use "utils/accessibility"; +@use "./components/Alert"; @use "./components/Button"; @use "./components/DataGrid"; @use "./components/Forms/Checkbox"; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 00ca073..0b6bbbd 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -2,6 +2,7 @@ import "./index.scss"; import { PartialExtendableNested, PartialNested } from ":/types"; import { tokens } from "./cunningham-tokens"; +export * from "./components/Alert"; export * from "./components/Button"; export * from "./components/DataGrid"; export * from "./components/DataGrid/DataList"; diff --git a/packages/react/src/locales/en-US.json b/packages/react/src/locales/en-US.json index 0d474e9..79598ed 100644 --- a/packages/react/src/locales/en-US.json +++ b/packages/react/src/locales/en-US.json @@ -1,5 +1,10 @@ { "components": { + "alert": { + "close_aria_label": "Delete alert", + "expand_aria_label": "Expand alert", + "shrink_aria_label": "Shrink alert" + }, "pagination": { "goto_label": "Go to page", "goto_label_aria": "Go to any page", diff --git a/packages/react/src/locales/fr-FR.json b/packages/react/src/locales/fr-FR.json index f6aeda9..5c4f0b4 100644 --- a/packages/react/src/locales/fr-FR.json +++ b/packages/react/src/locales/fr-FR.json @@ -1,5 +1,10 @@ { "components": { + "alert": { + "close_aria_label": "Supprimer l'alerte", + "expand_aria_label": "Ouvrir l'alerte", + "shrink_aria_label": "Fermer l'alerte" + }, "pagination": { "goto_label": "Aller à", "goto_label_aria": "Aller à la page",