(react) add Alert

Here is the Alert component based on recent delivered sketches.
There is a main component that based on props renders sub alert components.
This commit is contained in:
Nathan Vasse
2023-12-20 11:02:59 +01:00
committed by NathanVss
parent 3800cd8142
commit 33d0c9fdca
13 changed files with 640 additions and 0 deletions

View File

@@ -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 (
<AlertWrapper {...props}>
<div className="c__alert__content">
<div className="c__alert__content__left">
{props.children}
<AlertIcon {...props} />
</div>
<AlertClose {...props} />
</div>
<div className="c__alert__additional">{props.additional}</div>
<div className="c__alert-additional__buttons">
<AlertButtons {...props} />
</div>
</AlertWrapper>
);
};

View File

@@ -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 = (
<Button
color="tertiary"
size="nano"
aria-label={
expanded
? t("components.alert.shrink_aria_label")
: t("components.alert.expand_aria_label")
}
icon={
<span className="material-icons">{expanded ? "remove" : "add"}</span>
}
onClick={() => {
onExpand(!expanded);
}}
/>
);
const customProps = {
...props,
icon: iconButton,
className: "c__alert--expandable",
};
if (expanded) {
return <AlertAdditional {...customProps} />;
}
return <AlertOneLine {...customProps} />;
};

View File

@@ -0,0 +1,32 @@
import React from "react";
import { AlertProps } from ":/components/Alert/index";
import {
AlertButtons,
AlertClose,
AlertIcon,
AlertWrapper,
} from ":/components/Alert/Utils";
export const AlertOneLine = (props: AlertProps) => {
const hasActions =
props.canClose ||
props.primaryLabel ||
props.tertiaryLabel ||
props.buttons;
return (
<AlertWrapper {...props}>
<div className="c__alert__content">
<div className="c__alert__content__left">
{props.children}
<AlertIcon {...props} />
</div>
{hasActions && (
<div className="c__alert__actions">
<AlertButtons {...props} />
<AlertClose {...props} />
</div>
)}
</div>
</AlertWrapper>
);
};

View File

@@ -0,0 +1,94 @@
import React, { useMemo } from "react";
import classNames from "classnames";
import { Button } from ":/components/Button";
import { AlertProps, AlertType } from ":/components/Alert/index";
import { useCunningham } from ":/components/Provider";
export const AlertWrapper = (props: AlertProps) => {
return (
<div
className={classNames(
"c__alert",
"c__alert--" + props.type,
props.className,
{
"c__alert--hide": props.hide,
},
)}
>
{props.children}
</div>
);
};
export const AlertIcon = ({ type, ...props }: AlertProps) => {
const icon = useMemo(() => {
switch (type) {
case AlertType.INFO:
return "info";
case AlertType.SUCCESS:
return "task_alt";
case AlertType.WARNING:
return "warning";
case AlertType.ERROR:
return "cancel";
default:
return "";
}
}, [type]);
if (props.icon) {
return props.icon;
}
if (!icon) {
return null;
}
return (
<div className="c__alert__icon">
<span className="material-icons">{icon}</span>
</div>
);
};
export const AlertClose = (props: AlertProps) => {
const { t } = useCunningham();
return (
props.canClose && (
<Button
className="ml-st"
color="tertiary"
size="small"
icon={<span className="material-icons">close</span>}
aria-label={t("components.alert.close_aria_label")}
onClick={() => {
props.onClose?.(true);
}}
/>
)
);
};
export const AlertButtons = (props: AlertProps) => {
return (
<>
{props.tertiaryLabel && (
<Button
color="tertiary-text"
onClick={props.tertiaryOnClick}
{...props.tertiaryProps}
>
{props.tertiaryLabel}
</Button>
)}
{props.primaryLabel && (
<Button
color="primary-text"
onClick={props.primaryOnClick}
{...props.primaryProps}
>
{props.primaryLabel}
</Button>
)}
{props.buttons}
</>
);
};

View File

@@ -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);
}
}
}

View File

@@ -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("<Alert/>", () => {
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(
<CunninghamProvider>
<Alert type={type}>Alert component</Alert>
</CunninghamProvider>,
);
const $icon = document.querySelector(".c__alert__icon");
if (icon) {
expect($icon).toHaveTextContent(icon!);
} else {
expect($icon).toBeNull();
}
});
it("renders additional information", () => {
render(
<CunninghamProvider>
<Alert type={AlertType.INFO} additional="Additional information">
Alert component
</Alert>
</CunninghamProvider>,
);
expect(screen.getByText("Additional information")).toBeInTheDocument();
});
it("renders primary button when primaryLabel is provided", () => {
render(
<CunninghamProvider>
<Alert type={AlertType.INFO} primaryLabel="Primary">
Alert component
</Alert>
</CunninghamProvider>,
);
screen.getByRole("button", { name: "Primary" });
});
it("renders tertiary button when tertiaryLabel is provided", () => {
render(
<CunninghamProvider>
<Alert type={AlertType.INFO} tertiaryLabel="Tertiary">
Alert component
</Alert>
</CunninghamProvider>,
);
screen.getByRole("button", { name: "Tertiary" });
});
it("renders both buttons labels are provided", () => {
render(
<CunninghamProvider>
<Alert
type={AlertType.INFO}
primaryLabel="Primary"
tertiaryLabel="Tertiary"
>
Alert component
</Alert>
</CunninghamProvider>,
);
screen.getByRole("button", { name: "Primary" });
screen.getByRole("button", { name: "Tertiary" });
});
it("renders custom buttons via buttons props", () => {
render(
<CunninghamProvider>
<Alert
type={AlertType.INFO}
buttons={
<>
<Button color="primary">Primary Custom</Button>
<Button color="secondary">Secondary Custom</Button>
</>
}
>
Alert component
</Alert>
</CunninghamProvider>,
);
screen.getByRole("button", { name: "Primary Custom" });
screen.getByRole("button", { name: "Secondary Custom" });
});
it("can close the alert non controlled", async () => {
render(
<CunninghamProvider>
<Alert type={AlertType.INFO} canClose={true}>
Alert component
</Alert>
</CunninghamProvider>,
);
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 (
<CunninghamProvider>
<Alert
type={AlertType.INFO}
canClose={true}
closed={closed}
onClose={(flag) => setClosed(flag)}
>
Alert component
</Alert>
<div>Closed: {closed ? "true" : "false"}</div>
<Button onClick={() => setClosed(true)}>Close</Button>
<Button onClick={() => setClosed(false)}>Open</Button>
</CunninghamProvider>
);
};
render(<Wrapper />);
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(
<CunninghamProvider>
<Alert
type={AlertType.INFO}
additional="Additional information"
expandable={true}
>
Alert component
</Alert>
</CunninghamProvider>,
);
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 (
<CunninghamProvider>
<Alert
type={AlertType.INFO}
additional="Additional information"
expandable={true}
expanded={expanded}
onExpand={(flag) => setExpanded(flag)}
>
Alert component
</Alert>
<div>Expanded: {expanded ? "true" : "false"}</div>
<Button onClick={() => setExpanded(true)}>Expand</Button>
<Button onClick={() => setExpanded(false)}>Shrink</Button>
</CunninghamProvider>
);
};
render(<Wrapper />);
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();
});
});

View File

@@ -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 <AlertAdditionalExpandable {...propsWithDefault} />;
}
return <AlertAdditional {...propsWithDefault} />;
}
return <AlertOneLine {...propsWithDefault} />;
};

View File

@@ -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"],
};
};

View File

@@ -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";

View File

@@ -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";

View File

@@ -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",

View File

@@ -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",