Update and refine alert modals
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
description: Unified Alert (toast/banner) for app notifications — Figma + drift prevention
|
||||||
|
globs: app/**/*.tsx, stories/modals/Alert.stories.js, tests/components/Alert.test.tsx
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Alerts and notifications
|
||||||
|
|
||||||
|
## Source of truth
|
||||||
|
|
||||||
|
- **Figma:** [Community Rule System — Modal / Alert](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=6351-14646) (node **6351-14646**).
|
||||||
|
- **Code:** `app/components/modals/Alert` — default export `Alert` from `Alert.container.tsx` (Figma docstring on the container).
|
||||||
|
|
||||||
|
## When to use `Alert`
|
||||||
|
|
||||||
|
Use **`Alert`** for **app-level, section-level, and shell-level** success, warning, error, and neutral status messages that should read as a designed system surface (not body copy alone).
|
||||||
|
|
||||||
|
Do **not** recreate the same job with ad-hoc UI: bordered `<p>`, free-standing `role="alert"` blocks, or raw `text-[var(--color-border-default-utility-negative)]` paragraphs for product messaging.
|
||||||
|
|
||||||
|
## Props (lowercase in code; match Figma intent)
|
||||||
|
|
||||||
|
| Concern | Prop | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Layout | `type` | `toast` — bottom accent bar, top rounded corners; `banner` — full rounded block, inline or stacked. |
|
||||||
|
| Intent | `status` | `default` \| `positive` \| `warning` \| `danger`. |
|
||||||
|
| Density | `size` | `s` \| `m` (Figma S/M). Typography and padding are implemented inside `Alert.container.tsx` — do not fork spacing per call site. |
|
||||||
|
| Copy | `title`, `description?` | Required title; optional description when `hasBodyText` is true. |
|
||||||
|
| Icon | `hasLeadingIcon?` | Default `true`. |
|
||||||
|
| Body | `hasBodyText?` | Default `true`; set `false` for title-only. |
|
||||||
|
| Dismiss | `onClose?`, `hasTrailingIcon?` | Close control shows only when `onClose` is provided **and** `hasTrailingIcon` is not `false`. Omit `onClose` for non-dismissible messages. |
|
||||||
|
|
||||||
|
Valid enum slices for Storybook / guards: `ALERT_*_OPTIONS` in `lib/propNormalization.ts`.
|
||||||
|
|
||||||
|
## Choosing toast vs banner
|
||||||
|
|
||||||
|
- **`toast`** — transient edge / bottom emphasis (e.g. completed flow), strong bottom border accent.
|
||||||
|
- **`banner`** — rounded block; for **page / shell / modal** messaging, mount inside a **`fixed`** (or equivalent) overlay wrapper with `pointer-events-none` on the outer layer and `pointer-events-auto` on the alert so layout chrome does not reflow when the message appears (see `CreateFlowLayoutClient` `topBanners`, profile overlays, `LoginForm`, `PostLoginDraftTransfer`).
|
||||||
|
|
||||||
|
## Exemptions (do not force `Alert`)
|
||||||
|
|
||||||
|
1. **Single-field validation** under a control — keep `TextInput` / `TextArea` `error` and helper text (e.g. invalid email on the login form) unless design explicitly moves that line into `Alert`.
|
||||||
|
2. **Marketing layout** — `HeroBanner`, `ContentBanner` are not system alerts.
|
||||||
|
3. **Landmarks** — `role="banner"` on headers/nav is not the `Alert` “banner” type.
|
||||||
|
4. **A11y-only live regions** — e.g. tooltip / incrementer `aria-live` for widget state, not product notifications.
|
||||||
|
|
||||||
|
## Copy
|
||||||
|
|
||||||
|
All user-visible strings go through **`messages/`** and `useTranslation` / message modules per `localization.mdc`.
|
||||||
@@ -15,6 +15,7 @@ Single-locale (English) today; designed for i18n via `messages/`.
|
|||||||
| If you're touching… | Load this rule |
|
| If you're touching… | Load this rule |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `app/components/**` | `component-structure.mdc`, `component-props.mdc`, `tailwind-styling.mdc` |
|
| `app/components/**` | `component-structure.mdc`, `component-props.mdc`, `tailwind-styling.mdc` |
|
||||||
|
| `Alert`, or user-visible notifications / shell errors / success banners | `alerts.mdc` (and `localization.mdc` for copy) |
|
||||||
| `app/(app)/create/**` | `create-flow.mdc` (+ component rules) |
|
| `app/(app)/create/**` | `create-flow.mdc` (+ component rules) |
|
||||||
| `app/api/**` | `api-routes.mdc` |
|
| `app/api/**` | `api-routes.mdc` |
|
||||||
| `app/hooks/**` | `hooks.mdc` |
|
| `app/hooks/**` | `hooks.mdc` |
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useCreateFlow } from "./context/CreateFlowContext";
|
|||||||
import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps";
|
import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps";
|
||||||
import { saveDraftToServer } from "../../../lib/create/api";
|
import { saveDraftToServer } from "../../../lib/create/api";
|
||||||
import messages from "../../../messages/en/index";
|
import messages from "../../../messages/en/index";
|
||||||
|
import Alert from "../../components/modals/Alert";
|
||||||
|
|
||||||
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||||
|
|
||||||
@@ -139,12 +140,27 @@ export function PostLoginDraftTransfer({
|
|||||||
|
|
||||||
if (!transferError) return null;
|
if (!transferError) return null;
|
||||||
|
|
||||||
|
const [titleLine, ...rest] = transferError.split(/\n\n+/);
|
||||||
|
const title = (titleLine ?? transferError).trim();
|
||||||
|
const description = rest.join("\n\n").trim() || undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-[150] flex justify-center px-5 md:bottom-6">
|
||||||
role="alert"
|
<div className="pointer-events-auto w-full max-w-[640px]">
|
||||||
className="mx-auto max-w-[640px] px-5 py-3 text-center font-inter text-sm text-[var(--color-border-default-utility-negative)]"
|
<Alert
|
||||||
>
|
type="banner"
|
||||||
{transferError}
|
status="danger"
|
||||||
|
size="s"
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
hasBodyText={Boolean(description)}
|
||||||
|
hasLeadingIcon
|
||||||
|
onClose={() => {
|
||||||
|
setTransferError(null);
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { useCreateFlow } from "./context/CreateFlowContext";
|
import { useCreateFlow } from "./context/CreateFlowContext";
|
||||||
import { fetchDraftFromServer } from "../../../lib/create/api";
|
import { fetchDraftFromServer } from "../../../lib/create/api";
|
||||||
import messages from "../../../messages/en/index";
|
import messages from "../../../messages/en/index";
|
||||||
|
import Alert from "../../components/modals/Alert";
|
||||||
import {
|
import {
|
||||||
isValidStep,
|
isValidStep,
|
||||||
parseCreateFlowScreenFromPathname,
|
parseCreateFlowScreenFromPathname,
|
||||||
@@ -119,12 +120,19 @@ export function SignedInDraftHydration({
|
|||||||
if (!loadingHydration) return null;
|
if (!loadingHydration) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="pointer-events-none fixed left-0 right-0 top-14 z-[170] flex justify-center px-[var(--spacing-measures-spacing-500,20px)] pt-2 md:top-16 md:px-[var(--measures-spacing-1800,64px)]">
|
||||||
role="status"
|
<div className="pointer-events-auto w-full max-w-[960px]">
|
||||||
aria-live="polite"
|
<Alert
|
||||||
className="w-full shrink-0 px-[var(--spacing-measures-spacing-500,20px)] py-[var(--spacing-measures-spacing-200,8px)] md:px-[var(--measures-spacing-1800,64px)] text-center font-inter text-sm text-[var(--color-text-default-secondary,#a3a3a3)]"
|
type="banner"
|
||||||
>
|
status="default"
|
||||||
{messages.create.draftHydration.loadingSavedProgress}
|
size="s"
|
||||||
|
title={messages.create.draftHydration.loadingSavedProgress}
|
||||||
|
hasBodyText={false}
|
||||||
|
hasLeadingIcon={false}
|
||||||
|
hasTrailingIcon={false}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,19 +86,28 @@ export default function ReviewTemplatePage({ params }: PageProps) {
|
|||||||
|
|
||||||
if (error || !template) {
|
if (error || !template) {
|
||||||
return (
|
return (
|
||||||
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
|
<>
|
||||||
<div
|
<div
|
||||||
className={`flex shrink-0 flex-col gap-4 pb-8 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
className="pointer-events-none fixed left-0 right-0 top-14 z-[120] flex justify-center px-5 pt-3 md:top-20 md:px-12"
|
||||||
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<Alert
|
<div className="pointer-events-auto w-full max-w-[960px]">
|
||||||
type="banner"
|
<Alert
|
||||||
status="danger"
|
type="banner"
|
||||||
title={t("errors.loadFailed")}
|
status="danger"
|
||||||
description={error ?? t("errors.notFound")}
|
title={t("errors.loadFailed")}
|
||||||
className="w-full"
|
description={error ?? t("errors.notFound")}
|
||||||
/>
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CreateFlowStepShell>
|
<CreateFlowStepShell variant="wideGrid" contentTopBelowMd="space-800">
|
||||||
|
<div
|
||||||
|
className={`min-h-[40vh] shrink-0 ${CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</CreateFlowStepShell>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export default function ProfilePageClient() {
|
|||||||
const [emailChangeModalError, setEmailChangeModalError] = useState<
|
const [emailChangeModalError, setEmailChangeModalError] = useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const [emailChangeRequestSent, setEmailChangeRequestSent] = useState(false);
|
||||||
const [profileSuccessMessage, setProfileSuccessMessage] = useState<
|
const [profileSuccessMessage, setProfileSuccessMessage] = useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -131,6 +132,7 @@ export default function ProfilePageClient() {
|
|||||||
setActionError(null);
|
setActionError(null);
|
||||||
setProfileSuccessMessage(null);
|
setProfileSuccessMessage(null);
|
||||||
setEmailChangeModalError(null);
|
setEmailChangeModalError(null);
|
||||||
|
setEmailChangeRequestSent(false);
|
||||||
setEmailChangeInput(user.email);
|
setEmailChangeInput(user.email);
|
||||||
setEmailChangeOpen(true);
|
setEmailChangeOpen(true);
|
||||||
}, [user]);
|
}, [user]);
|
||||||
@@ -138,8 +140,25 @@ export default function ProfilePageClient() {
|
|||||||
const handleCloseEmailChange = useCallback(() => {
|
const handleCloseEmailChange = useCallback(() => {
|
||||||
if (emailChangeBusy) return;
|
if (emailChangeBusy) return;
|
||||||
setEmailChangeOpen(false);
|
setEmailChangeOpen(false);
|
||||||
|
setEmailChangeRequestSent(false);
|
||||||
}, [emailChangeBusy]);
|
}, [emailChangeBusy]);
|
||||||
|
|
||||||
|
const handleDismissProfileSuccess = useCallback(() => {
|
||||||
|
setProfileSuccessMessage(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDismissActionError = useCallback(() => {
|
||||||
|
setActionError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDismissRulesError = useCallback(() => {
|
||||||
|
setRulesError(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDismissEmailChangeModalError = useCallback(() => {
|
||||||
|
setEmailChangeModalError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubmitEmailChange = useCallback(async () => {
|
const handleSubmitEmailChange = useCallback(async () => {
|
||||||
const trimmed = emailChangeInput.trim();
|
const trimmed = emailChangeInput.trim();
|
||||||
if (!trimmed || emailChangeBusy) return;
|
if (!trimmed || emailChangeBusy) return;
|
||||||
@@ -157,8 +176,7 @@ export default function ProfilePageClient() {
|
|||||||
setEmailChangeModalError(res.error);
|
setEmailChangeModalError(res.error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setEmailChangeOpen(false);
|
setEmailChangeRequestSent(true);
|
||||||
setProfileSuccessMessage(t("emailChangeRequestSent"));
|
|
||||||
}
|
}
|
||||||
}, [emailChangeBusy, emailChangeInput, t]);
|
}, [emailChangeBusy, emailChangeInput, t]);
|
||||||
|
|
||||||
@@ -318,7 +336,12 @@ export default function ProfilePageClient() {
|
|||||||
emailChangeValue={emailChangeInput}
|
emailChangeValue={emailChangeInput}
|
||||||
onEmailChangeValueChange={(value) => setEmailChangeInput(value)}
|
onEmailChangeValueChange={(value) => setEmailChangeInput(value)}
|
||||||
emailChangeBusy={emailChangeBusy}
|
emailChangeBusy={emailChangeBusy}
|
||||||
|
emailChangeRequestSent={emailChangeRequestSent}
|
||||||
emailChangeModalError={emailChangeModalError}
|
emailChangeModalError={emailChangeModalError}
|
||||||
|
onDismissProfileSuccess={handleDismissProfileSuccess}
|
||||||
|
onDismissActionError={handleDismissActionError}
|
||||||
|
onDismissRulesError={handleDismissRulesError}
|
||||||
|
onDismissEmailChangeModalError={handleDismissEmailChangeModalError}
|
||||||
onOpenEmailChange={handleOpenEmailChange}
|
onOpenEmailChange={handleOpenEmailChange}
|
||||||
onCloseEmailChange={handleCloseEmailChange}
|
onCloseEmailChange={handleCloseEmailChange}
|
||||||
onSubmitEmailChange={handleSubmitEmailChange}
|
onSubmitEmailChange={handleSubmitEmailChange}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import RuleCard from "../../../components/cards/RuleCard";
|
|||||||
import TextInput from "../../../components/controls/TextInput";
|
import TextInput from "../../../components/controls/TextInput";
|
||||||
import List from "../../../components/layout/List";
|
import List from "../../../components/layout/List";
|
||||||
import type { ListItem, ListSize } from "../../../components/layout/List";
|
import type { ListItem, ListSize } from "../../../components/layout/List";
|
||||||
|
import Icon from "../../../components/asset/Icon";
|
||||||
import Dialog from "../../../components/modals/Dialog";
|
import Dialog from "../../../components/modals/Dialog";
|
||||||
|
import Alert from "../../../components/modals/Alert";
|
||||||
import HeaderLockup from "../../../components/type/HeaderLockup";
|
import HeaderLockup from "../../../components/type/HeaderLockup";
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||||
import type { CreateFlowState } from "../../create/types";
|
import type { CreateFlowState } from "../../create/types";
|
||||||
@@ -49,7 +51,9 @@ export type ProfilePageViewProps = {
|
|||||||
emailChangeValue: string;
|
emailChangeValue: string;
|
||||||
onEmailChangeValueChange: (value: string) => void;
|
onEmailChangeValueChange: (value: string) => void;
|
||||||
emailChangeBusy: boolean;
|
emailChangeBusy: boolean;
|
||||||
|
emailChangeRequestSent: boolean;
|
||||||
emailChangeModalError: string | null;
|
emailChangeModalError: string | null;
|
||||||
|
onDismissEmailChangeModalError: () => void;
|
||||||
onOpenEmailChange: () => void;
|
onOpenEmailChange: () => void;
|
||||||
onCloseEmailChange: () => void;
|
onCloseEmailChange: () => void;
|
||||||
onSubmitEmailChange: () => void;
|
onSubmitEmailChange: () => void;
|
||||||
@@ -65,6 +69,9 @@ export type ProfilePageViewProps = {
|
|||||||
onOpenDeleteAccount: () => void;
|
onOpenDeleteAccount: () => void;
|
||||||
onCloseDeleteAccount: () => void;
|
onCloseDeleteAccount: () => void;
|
||||||
onConfirmDeleteAccount: () => void;
|
onConfirmDeleteAccount: () => void;
|
||||||
|
onDismissProfileSuccess: () => void;
|
||||||
|
onDismissActionError: () => void;
|
||||||
|
onDismissRulesError: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -171,7 +178,9 @@ export function ProfilePageView({
|
|||||||
emailChangeValue,
|
emailChangeValue,
|
||||||
onEmailChangeValueChange,
|
onEmailChangeValueChange,
|
||||||
emailChangeBusy,
|
emailChangeBusy,
|
||||||
|
emailChangeRequestSent,
|
||||||
emailChangeModalError,
|
emailChangeModalError,
|
||||||
|
onDismissEmailChangeModalError,
|
||||||
onOpenEmailChange,
|
onOpenEmailChange,
|
||||||
onCloseEmailChange,
|
onCloseEmailChange,
|
||||||
onSubmitEmailChange,
|
onSubmitEmailChange,
|
||||||
@@ -187,8 +196,12 @@ export function ProfilePageView({
|
|||||||
onOpenDeleteAccount,
|
onOpenDeleteAccount,
|
||||||
onCloseDeleteAccount,
|
onCloseDeleteAccount,
|
||||||
onConfirmDeleteAccount,
|
onConfirmDeleteAccount,
|
||||||
|
onDismissProfileSuccess,
|
||||||
|
onDismissActionError,
|
||||||
|
onDismissRulesError,
|
||||||
}: ProfilePageViewProps) {
|
}: ProfilePageViewProps) {
|
||||||
const t = useTranslation("pages.profile");
|
const t = useTranslation("pages.profile");
|
||||||
|
const tLogin = useTranslation("pages.login");
|
||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
const welcomeTitle = t("welcomeTitle").replace(/\{\{name\}\}/g, userEmail);
|
const welcomeTitle = t("welcomeTitle").replace(/\{\{name\}\}/g, userEmail);
|
||||||
const welcomeBody =
|
const welcomeBody =
|
||||||
@@ -277,30 +290,6 @@ export function ProfilePageView({
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{profileSuccessMessage ? (
|
|
||||||
<p
|
|
||||||
className="rounded-lg border border-[var(--color-border-default-secondary)] bg-[var(--color-surface-default-secondary)] px-4 py-3 font-inter text-sm text-[var(--color-content-default-primary)]"
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
{profileSuccessMessage}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{actionError ? (
|
|
||||||
<p
|
|
||||||
className="rounded-lg border border-[var(--color-border-default-secondary)] bg-[var(--color-surface-default-tertiary)] px-4 py-3 font-inter text-sm text-[var(--color-content-default-primary)]"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{actionError}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{rulesError ? (
|
|
||||||
<p className="font-inter text-sm text-[var(--color-content-default-tertiary)]">
|
|
||||||
{t("actionError")}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-8 lg:flex-row lg:flex-nowrap lg:items-start lg:gap-8">
|
<div className="flex flex-col gap-8 lg:flex-row lg:flex-nowrap lg:items-start lg:gap-8">
|
||||||
<section
|
<section
|
||||||
className="flex min-w-0 w-full flex-col gap-3 lg:min-w-0 lg:flex-1 lg:gap-6"
|
className="flex min-w-0 w-full flex-col gap-3 lg:min-w-0 lg:flex-1 lg:gap-6"
|
||||||
@@ -519,53 +508,136 @@ export function ProfilePageView({
|
|||||||
if (!emailChangeBusy) onCloseEmailChange();
|
if (!emailChangeBusy) onCloseEmailChange();
|
||||||
}}
|
}}
|
||||||
backdropVariant="blurredYellow"
|
backdropVariant="blurredYellow"
|
||||||
title={t("emailChangeModalTitle")}
|
title={
|
||||||
description={t("emailChangeModalDescription")}
|
emailChangeRequestSent
|
||||||
|
? tLogin("successTitle")
|
||||||
|
: t("emailChangeModalTitle")
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
emailChangeRequestSent
|
||||||
|
? tLogin("successBody")
|
||||||
|
: t("emailChangeModalDescription")
|
||||||
|
}
|
||||||
footer={
|
footer={
|
||||||
<>
|
emailChangeRequestSent ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="medium"
|
size="medium"
|
||||||
buttonType="outline"
|
buttonType="outline"
|
||||||
palette="default"
|
palette="default"
|
||||||
onClick={onCloseEmailChange}
|
onClick={onCloseEmailChange}
|
||||||
disabled={emailChangeBusy}
|
|
||||||
>
|
>
|
||||||
{t("emailChangeCancel")}
|
{t("emailChangeConfirmationClose")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
) : (
|
||||||
type="button"
|
<>
|
||||||
size="medium"
|
<Button
|
||||||
buttonType="filled"
|
type="button"
|
||||||
palette="default"
|
size="medium"
|
||||||
onClick={onSubmitEmailChange}
|
buttonType="outline"
|
||||||
disabled={emailChangeBusy}
|
palette="default"
|
||||||
>
|
onClick={onCloseEmailChange}
|
||||||
{t("emailChangeSubmit")}
|
disabled={emailChangeBusy}
|
||||||
</Button>
|
>
|
||||||
</>
|
{t("emailChangeCancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="medium"
|
||||||
|
buttonType="filled"
|
||||||
|
palette="default"
|
||||||
|
onClick={onSubmitEmailChange}
|
||||||
|
disabled={emailChangeBusy}
|
||||||
|
>
|
||||||
|
{t("emailChangeSubmit")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{emailChangeModalError ? (
|
{emailChangeRequestSent ? (
|
||||||
<p
|
<div className="flex flex-col gap-3 pt-2">
|
||||||
className="font-inter text-sm text-[var(--color-content-default-primary)]"
|
<div className="relative flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[var(--color-surface-inverse-brand-primary)]">
|
||||||
role="alert"
|
<Icon name="mail" size={22} aria-hidden />
|
||||||
>
|
</div>
|
||||||
{emailChangeModalError}
|
</div>
|
||||||
</p>
|
) : (
|
||||||
) : null}
|
<TextInput
|
||||||
<TextInput
|
type="email"
|
||||||
type="email"
|
inputSize="medium"
|
||||||
inputSize="medium"
|
label={t("emailChangeNewEmailLabel")}
|
||||||
label={t("emailChangeNewEmailLabel")}
|
placeholder={t("emailChangeNewEmailPlaceholder")}
|
||||||
placeholder={t("emailChangeNewEmailPlaceholder")}
|
value={emailChangeValue}
|
||||||
value={emailChangeValue}
|
onChange={(e) => onEmailChangeValueChange(e.target.value)}
|
||||||
onChange={(e) => onEmailChangeValueChange(e.target.value)}
|
disabled={emailChangeBusy}
|
||||||
disabled={emailChangeBusy}
|
error={Boolean(emailChangeModalError)}
|
||||||
error={Boolean(emailChangeModalError)}
|
autoComplete="email"
|
||||||
autoComplete="email"
|
/>
|
||||||
/>
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{(profileSuccessMessage || actionError || rulesError) && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none fixed left-0 right-0 top-[41px] z-[60] flex justify-center px-4 pt-3 md:px-8 lg:top-[85px] lg:px-16 xl:top-[89px]"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="pointer-events-auto flex w-full max-w-[640px] flex-col gap-2">
|
||||||
|
{profileSuccessMessage ? (
|
||||||
|
<Alert
|
||||||
|
type="banner"
|
||||||
|
status="positive"
|
||||||
|
size="s"
|
||||||
|
title={profileSuccessMessage}
|
||||||
|
hasBodyText={false}
|
||||||
|
hasLeadingIcon
|
||||||
|
onClose={onDismissProfileSuccess}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{actionError ? (
|
||||||
|
<Alert
|
||||||
|
type="banner"
|
||||||
|
status="danger"
|
||||||
|
size="s"
|
||||||
|
title={actionError}
|
||||||
|
hasBodyText={false}
|
||||||
|
hasLeadingIcon
|
||||||
|
onClose={onDismissActionError}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{rulesError ? (
|
||||||
|
<Alert
|
||||||
|
type="banner"
|
||||||
|
status="warning"
|
||||||
|
size="s"
|
||||||
|
title={t("rulesLoadBannerTitle")}
|
||||||
|
description={t("rulesLoadBannerDescription")}
|
||||||
|
hasLeadingIcon
|
||||||
|
onClose={onDismissRulesError}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{emailChangeOpen && emailChangeModalError ? (
|
||||||
|
<div className="pointer-events-none fixed inset-x-0 top-6 z-[10001] flex justify-center px-4 md:top-10">
|
||||||
|
<div className="pointer-events-auto w-full max-w-[min(480px,calc(100%-2rem))]">
|
||||||
|
<Alert
|
||||||
|
type="banner"
|
||||||
|
status="danger"
|
||||||
|
size="s"
|
||||||
|
title={emailChangeModalError}
|
||||||
|
hasBodyText={false}
|
||||||
|
hasLeadingIcon
|
||||||
|
onClose={onDismissEmailChangeModalError}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,117 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figma: "Modal / Alert" (6351-14646)
|
||||||
|
* https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=6351-14646
|
||||||
|
*/
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { AlertView } from "./Alert.view";
|
import { AlertView } from "./Alert.view";
|
||||||
import type { AlertProps } from "./Alert.types";
|
import type { AlertProps } from "./Alert.types";
|
||||||
|
|
||||||
|
function layoutFor(
|
||||||
|
type: NonNullable<AlertProps["type"]>,
|
||||||
|
size: NonNullable<AlertProps["size"]>,
|
||||||
|
): {
|
||||||
|
containerClasses: string;
|
||||||
|
titleClasses: string;
|
||||||
|
descriptionClasses: string;
|
||||||
|
} {
|
||||||
|
if (type === "toast") {
|
||||||
|
const padH =
|
||||||
|
size === "s"
|
||||||
|
? "px-[var(--space-500)]"
|
||||||
|
: "px-[var(--space-1200)]";
|
||||||
|
const containerClasses = `flex gap-[var(--space-300)] items-center ${padH} pb-[var(--space-500)] pt-[var(--space-400)] rounded-tl-[var(--radius-200,8px)] rounded-tr-[var(--radius-200,8px)] border-solid`;
|
||||||
|
if (size === "s") {
|
||||||
|
return {
|
||||||
|
containerClasses,
|
||||||
|
titleClasses:
|
||||||
|
"font-inter text-[14px] leading-[18px] font-medium tracking-[0%]",
|
||||||
|
descriptionClasses:
|
||||||
|
"font-inter text-[14px] leading-[20px] font-normal tracking-[0%] mt-[var(--spacing-scale-004)]",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
containerClasses,
|
||||||
|
titleClasses:
|
||||||
|
"font-inter text-[18px] leading-[24px] font-medium tracking-[0%]",
|
||||||
|
descriptionClasses:
|
||||||
|
"font-inter text-[18px] leading-[1.3] font-normal tracking-[0%] mt-[var(--spacing-scale-004)]",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size === "s") {
|
||||||
|
return {
|
||||||
|
containerClasses:
|
||||||
|
"flex gap-[var(--space-300)] items-center p-[var(--space-300)] rounded-[var(--radius-200,8px)] border-solid",
|
||||||
|
titleClasses:
|
||||||
|
"font-inter text-[14px] leading-[18px] font-medium tracking-[0%]",
|
||||||
|
descriptionClasses:
|
||||||
|
"font-inter text-[14px] leading-[20px] font-normal tracking-[0%] mt-[var(--spacing-scale-004)]",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
containerClasses:
|
||||||
|
"flex gap-[var(--space-300)] items-center px-[var(--space-600)] py-[var(--space-400)] rounded-[var(--radius-200,8px)] border-solid",
|
||||||
|
titleClasses:
|
||||||
|
"font-inter text-[16px] leading-[20px] font-medium tracking-[0%]",
|
||||||
|
descriptionClasses:
|
||||||
|
"font-inter text-[16px] leading-[24px] font-normal tracking-[0%] mt-[var(--spacing-scale-004)]",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const AlertContainer = memo<AlertProps>(
|
const AlertContainer = memo<AlertProps>(
|
||||||
({
|
({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
status: statusProp = "default",
|
status: statusProp = "default",
|
||||||
type: typeProp = "toast",
|
type: typeProp = "toast",
|
||||||
|
size: sizeProp = "m",
|
||||||
hasLeadingIcon = true,
|
hasLeadingIcon = true,
|
||||||
hasBodyText = true,
|
hasBodyText = true,
|
||||||
|
hasTrailingIcon: hasTrailingIconProp,
|
||||||
onClose,
|
onClose,
|
||||||
className = "",
|
className = "",
|
||||||
}) => {
|
}) => {
|
||||||
const status = statusProp;
|
const status = statusProp;
|
||||||
const type = typeProp;
|
const type = typeProp;
|
||||||
// Determine background and border colors based on status and type
|
const size = sizeProp;
|
||||||
|
|
||||||
const getStatusStyles = () => {
|
const getStatusStyles = () => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "positive":
|
case "positive":
|
||||||
return {
|
return {
|
||||||
background: "bg-[var(--color-kiwi-kiwi0)]",
|
background:
|
||||||
|
"bg-[var(--color-surface-invert-positive-secondary,var(--color-kiwi-kiwi0))]",
|
||||||
borderColor:
|
borderColor:
|
||||||
type === "toast"
|
type === "toast"
|
||||||
? "var(--color-border-invert-positive-primary)"
|
? "var(--color-border-invert-positive-primary)"
|
||||||
: undefined,
|
: undefined,
|
||||||
titleColor: "text-[var(--color-content-invert-primary)]",
|
titleColor: "text-[var(--color-content-invert-primary)]",
|
||||||
descriptionColor: "text-[var(--color-content-invert-secondary)]",
|
descriptionColor:
|
||||||
|
"text-[var(--color-content-invert-secondary)]",
|
||||||
iconColor: "var(--color-kiwi-kiwi500)",
|
iconColor: "var(--color-kiwi-kiwi500)",
|
||||||
closeButtonIconColor: "var(--color-content-invert-primary)",
|
closeButtonIconColor: "var(--color-content-invert-primary)",
|
||||||
};
|
};
|
||||||
case "warning":
|
case "warning":
|
||||||
return {
|
return {
|
||||||
background: "bg-[var(--color-yellow-yellow0)]",
|
background:
|
||||||
|
"bg-[var(--color-surface-invert-warning-secondary,var(--color-yellow-yellow0))]",
|
||||||
borderColor:
|
borderColor:
|
||||||
type === "toast"
|
type === "toast"
|
||||||
? "var(--color-border-invert-warning-primary)"
|
? "var(--color-border-invert-warning-primary)"
|
||||||
: undefined,
|
: undefined,
|
||||||
titleColor: "text-[var(--color-content-invert-primary)]",
|
titleColor: "text-[var(--color-content-invert-primary)]",
|
||||||
descriptionColor: "text-[var(--color-content-invert-secondary)]",
|
descriptionColor:
|
||||||
|
"text-[var(--color-content-invert-secondary)]",
|
||||||
iconColor: "var(--color-yellow-yellow500)",
|
iconColor: "var(--color-yellow-yellow500)",
|
||||||
closeButtonIconColor: "var(--color-content-invert-primary)",
|
closeButtonIconColor: "var(--color-content-invert-primary)",
|
||||||
};
|
};
|
||||||
case "danger":
|
case "danger":
|
||||||
return {
|
return {
|
||||||
background: "bg-[var(--color-red-red0)]",
|
background:
|
||||||
|
"bg-[var(--color-surface-invert-negative-secondary,var(--color-red-red0))]",
|
||||||
borderColor:
|
borderColor:
|
||||||
type === "toast"
|
type === "toast"
|
||||||
? "var(--color-border-invert-negative-primary)"
|
? "var(--color-border-invert-negative-primary)"
|
||||||
@@ -67,18 +132,14 @@ const AlertContainer = memo<AlertProps>(
|
|||||||
titleColor: "text-[var(--color-content-default-primary)]",
|
titleColor: "text-[var(--color-content-default-primary)]",
|
||||||
descriptionColor: "text-[var(--color-content-default-primary)]",
|
descriptionColor: "text-[var(--color-content-default-primary)]",
|
||||||
iconColor: "var(--color-content-default-brand-primary)",
|
iconColor: "var(--color-content-default-brand-primary)",
|
||||||
closeButtonIconColor: "var(--color-content-default-brand-primary)",
|
closeButtonIconColor:
|
||||||
|
"var(--color-content-default-brand-primary)",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusStyles = getStatusStyles();
|
const statusStyles = getStatusStyles();
|
||||||
|
const layout = layoutFor(type, size);
|
||||||
const containerClasses = `flex gap-[var(--space-300)] items-center ${
|
|
||||||
type === "toast"
|
|
||||||
? `pb-[var(--space-500)] pt-[var(--space-400)] px-[var(--space-1200)] rounded-tl-[var(--radius-200,8px)] rounded-tr-[var(--radius-200,8px)]`
|
|
||||||
: `px-[var(--spacing-scale-024)] py-[var(--spacing-scale-016)] rounded-[var(--radius-200,8px)]`
|
|
||||||
} ${statusStyles.background} border-solid`;
|
|
||||||
|
|
||||||
const containerStyle =
|
const containerStyle =
|
||||||
type === "toast" && statusStyles.borderColor
|
type === "toast" && statusStyles.borderColor
|
||||||
@@ -88,15 +149,14 @@ const AlertContainer = memo<AlertProps>(
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const titleClasses =
|
const containerClasses = `${layout.containerClasses} ${statusStyles.background}`;
|
||||||
type === "banner"
|
|
||||||
? `font-inter text-[16px] leading-[20px] font-medium tracking-[0%] ${statusStyles.titleColor} relative shrink-0 w-full`
|
|
||||||
: `font-inter text-[18px] leading-[24px] font-medium tracking-[0%] ${statusStyles.titleColor} relative shrink-0 w-full`;
|
|
||||||
|
|
||||||
const descriptionClasses =
|
const titleClasses = `${layout.titleClasses} ${statusStyles.titleColor} relative shrink-0 w-full`;
|
||||||
type === "banner"
|
const descriptionClasses = `${layout.descriptionClasses} ${statusStyles.descriptionColor} relative shrink-0 w-full`;
|
||||||
? `font-inter text-[16px] leading-[24px] font-normal tracking-[0%] ${statusStyles.descriptionColor} relative shrink-0 w-full mt-[var(--spacing-scale-004)]`
|
|
||||||
: `font-inter text-[18px] leading-[23.4px] font-normal tracking-[0%] ${statusStyles.descriptionColor} relative shrink-0 w-full mt-[var(--spacing-scale-004)]`;
|
const hasTrailingIcon =
|
||||||
|
hasTrailingIconProp ?? Boolean(onClose);
|
||||||
|
const showClose = hasTrailingIcon && Boolean(onClose);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertView
|
<AlertView
|
||||||
@@ -106,6 +166,7 @@ const AlertContainer = memo<AlertProps>(
|
|||||||
type={type}
|
type={type}
|
||||||
hasLeadingIcon={hasLeadingIcon}
|
hasLeadingIcon={hasLeadingIcon}
|
||||||
hasBodyText={hasBodyText}
|
hasBodyText={hasBodyText}
|
||||||
|
hasTrailingIcon={showClose}
|
||||||
className={className}
|
className={className}
|
||||||
containerClasses={containerClasses}
|
containerClasses={containerClasses}
|
||||||
containerStyle={containerStyle}
|
containerStyle={containerStyle}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import type { AlertSizeValue } from "../../../../lib/propNormalization";
|
||||||
|
|
||||||
export type AlertStatusValue = "default" | "positive" | "warning" | "danger";
|
export type AlertStatusValue = "default" | "positive" | "warning" | "danger";
|
||||||
|
|
||||||
export type AlertTypeValue = "toast" | "banner";
|
export type AlertTypeValue = "toast" | "banner";
|
||||||
|
|
||||||
|
export type { AlertSizeValue };
|
||||||
|
|
||||||
export interface AlertProps {
|
export interface AlertProps {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -13,6 +17,11 @@ export interface AlertProps {
|
|||||||
* Alert type.
|
* Alert type.
|
||||||
*/
|
*/
|
||||||
type?: AlertTypeValue;
|
type?: AlertTypeValue;
|
||||||
|
/**
|
||||||
|
* Density / typography scale (Figma Modal Alert S | M).
|
||||||
|
* @default "m"
|
||||||
|
*/
|
||||||
|
size?: AlertSizeValue;
|
||||||
/**
|
/**
|
||||||
* Whether to show the leading icon (Figma prop).
|
* Whether to show the leading icon (Figma prop).
|
||||||
* @default true
|
* @default true
|
||||||
@@ -23,6 +32,11 @@ export interface AlertProps {
|
|||||||
* @default true
|
* @default true
|
||||||
*/
|
*/
|
||||||
hasBodyText?: boolean;
|
hasBodyText?: boolean;
|
||||||
|
/**
|
||||||
|
* Trailing dismiss control (Figma `hasTrailingIcon`).
|
||||||
|
* When omitted, defaults to `true` when `onClose` is provided, else `false`.
|
||||||
|
*/
|
||||||
|
hasTrailingIcon?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -34,6 +48,7 @@ export interface AlertViewProps {
|
|||||||
type: "toast" | "banner";
|
type: "toast" | "banner";
|
||||||
hasLeadingIcon: boolean;
|
hasLeadingIcon: boolean;
|
||||||
hasBodyText: boolean;
|
hasBodyText: boolean;
|
||||||
|
hasTrailingIcon: boolean;
|
||||||
className: string;
|
className: string;
|
||||||
containerClasses: string;
|
containerClasses: string;
|
||||||
containerStyle?: React.CSSProperties;
|
containerStyle?: React.CSSProperties;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export function AlertView({
|
|||||||
type: _type,
|
type: _type,
|
||||||
hasLeadingIcon,
|
hasLeadingIcon,
|
||||||
hasBodyText,
|
hasBodyText,
|
||||||
|
hasTrailingIcon,
|
||||||
className,
|
className,
|
||||||
containerClasses,
|
containerClasses,
|
||||||
containerStyle,
|
containerStyle,
|
||||||
@@ -54,40 +55,42 @@ export function AlertView({
|
|||||||
<p className={descriptionClasses}>{description}</p>
|
<p className={descriptionClasses}>{description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{hasTrailingIcon && onClose ? (
|
||||||
buttonType="ghost"
|
<Button
|
||||||
palette="default"
|
buttonType="ghost"
|
||||||
size="large"
|
palette="default"
|
||||||
onClick={onClose}
|
size="large"
|
||||||
ariaLabel="Close alert"
|
onClick={onClose}
|
||||||
className="shrink-0 [&_svg_path]:transition-colors [&_svg_path]:duration-200 hover:[&_svg_path]:fill-[var(--color-content-default-primary)]"
|
ariaLabel="Close alert"
|
||||||
>
|
className="shrink-0 [&_svg_path]:transition-colors [&_svg_path]:duration-200 hover:[&_svg_path]:fill-[var(--color-content-default-primary)]"
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
>
|
||||||
<mask
|
<svg
|
||||||
id="mask0_21296_8285"
|
|
||||||
style={{ maskType: "alpha" }}
|
|
||||||
maskUnits="userSpaceOnUse"
|
|
||||||
x="0"
|
|
||||||
y="0"
|
|
||||||
width="20"
|
width="20"
|
||||||
height="20"
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<rect width="20" height="20" fill="#D9D9D9" />
|
<mask
|
||||||
</mask>
|
id="mask0_21296_8285"
|
||||||
<g mask="url(#mask0_21296_8285)">
|
style={{ maskType: "alpha" }}
|
||||||
<path
|
maskUnits="userSpaceOnUse"
|
||||||
d="M5.33327 15.5448L4.45508 14.6666L9.12174 9.99993L4.45508 5.33327L5.33327 4.45508L9.99993 9.12174L14.6666 4.45508L15.5448 5.33327L10.8781 9.99993L15.5448 14.6666L14.6666 15.5448L9.99993 10.8781L5.33327 15.5448Z"
|
x="0"
|
||||||
fill={closeButtonIconColor}
|
y="0"
|
||||||
/>
|
width="20"
|
||||||
</g>
|
height="20"
|
||||||
</svg>
|
>
|
||||||
</Button>
|
<rect width="20" height="20" fill="#D9D9D9" />
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_21296_8285)">
|
||||||
|
<path
|
||||||
|
d="M5.33327 15.5448L4.45508 14.6666L9.12174 9.99993L4.45508 5.33327L5.33327 4.45508L9.99993 9.12174L14.6666 4.45508L15.5448 5.33327L10.8781 9.99993L15.5448 14.6666L14.6666 15.5448L9.99993 10.8781L5.33327 15.5448Z"
|
||||||
|
fill={closeButtonIconColor}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useTranslation } from "../../../contexts/MessagesContext";
|
|||||||
import Button from "../../buttons/Button";
|
import Button from "../../buttons/Button";
|
||||||
import TextInput from "../../controls/TextInput";
|
import TextInput from "../../controls/TextInput";
|
||||||
import ContentLockup from "../../type/ContentLockup";
|
import ContentLockup from "../../type/ContentLockup";
|
||||||
|
import Alert from "../Alert";
|
||||||
import { requestMagicLink } from "../../../../lib/create/api";
|
import { requestMagicLink } from "../../../../lib/create/api";
|
||||||
import { safeInternalPath } from "../../../../lib/safeInternalPath";
|
import { safeInternalPath } from "../../../../lib/safeInternalPath";
|
||||||
import { setTransferPendingFlag } from "../../../(app)/create/utils/anonymousDraftStorage";
|
import { setTransferPendingFlag } from "../../../(app)/create/utils/anonymousDraftStorage";
|
||||||
@@ -55,7 +56,6 @@ export default function LoginForm({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const formAlertId = useId();
|
|
||||||
const emailErrorId = useId();
|
const emailErrorId = useId();
|
||||||
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
@@ -166,26 +166,40 @@ export default function LoginForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{urlErrorMessage ? (
|
{(urlErrorMessage || formError) && (
|
||||||
<p
|
<div className="pointer-events-none fixed inset-x-0 top-4 z-[10000] flex justify-center px-4 md:top-6">
|
||||||
role="alert"
|
<div className="pointer-events-auto flex w-full max-w-[560px] flex-col gap-2">
|
||||||
aria-live="polite"
|
{urlErrorMessage ? (
|
||||||
className="text-center font-inter text-[14px] leading-[20px] text-[var(--color-border-default-utility-negative)]"
|
<Alert
|
||||||
>
|
type="banner"
|
||||||
{urlErrorMessage}
|
status="danger"
|
||||||
</p>
|
size="s"
|
||||||
) : null}
|
title={urlErrorMessage}
|
||||||
|
hasBodyText={false}
|
||||||
{formError ? (
|
hasLeadingIcon
|
||||||
<p
|
onClose={() => {
|
||||||
id={formAlertId}
|
stripErrorQuery();
|
||||||
role="alert"
|
}}
|
||||||
aria-live="polite"
|
className="w-full"
|
||||||
className="font-inter text-[14px] leading-[20px] text-[var(--color-border-default-utility-negative)]"
|
/>
|
||||||
>
|
) : null}
|
||||||
{formError}
|
{formError ? (
|
||||||
</p>
|
<Alert
|
||||||
) : null}
|
type="banner"
|
||||||
|
status="danger"
|
||||||
|
size="s"
|
||||||
|
title={formError}
|
||||||
|
hasBodyText={false}
|
||||||
|
hasLeadingIcon
|
||||||
|
onClose={() => {
|
||||||
|
setFormError("");
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!sent ? (
|
{!sent ? (
|
||||||
<form
|
<form
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ export type AlertStatusValue = (typeof ALERT_STATUS_OPTIONS)[number];
|
|||||||
export const ALERT_TYPE_OPTIONS = ["toast", "banner"] as const;
|
export const ALERT_TYPE_OPTIONS = ["toast", "banner"] as const;
|
||||||
export type AlertTypeValue = (typeof ALERT_TYPE_OPTIONS)[number];
|
export type AlertTypeValue = (typeof ALERT_TYPE_OPTIONS)[number];
|
||||||
|
|
||||||
|
export const ALERT_SIZE_OPTIONS = ["s", "m"] as const;
|
||||||
|
export type AlertSizeValue = (typeof ALERT_SIZE_OPTIONS)[number];
|
||||||
|
|
||||||
export const TOOLTIP_POSITION_OPTIONS = ["top", "bottom"] as const;
|
export const TOOLTIP_POSITION_OPTIONS = ["top", "bottom"] as const;
|
||||||
export type TooltipPositionValue = (typeof TOOLTIP_POSITION_OPTIONS)[number];
|
export type TooltipPositionValue = (typeof TOOLTIP_POSITION_OPTIONS)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
"emailChangeNewEmailPlaceholder": "you@example.com",
|
"emailChangeNewEmailPlaceholder": "you@example.com",
|
||||||
"emailChangeCancel": "Cancel",
|
"emailChangeCancel": "Cancel",
|
||||||
"emailChangeSubmit": "Send confirmation link",
|
"emailChangeSubmit": "Send confirmation link",
|
||||||
"emailChangeRequestSent": "Check your inbox and confirm the new address using the link we sent.",
|
"emailChangeConfirmationClose": "Close",
|
||||||
"emailChangeSuccess": "Your account email was updated.",
|
"emailChangeSuccess": "Your account email was updated.",
|
||||||
"emailChangeVerifyExpired": "That confirmation link expired or was already used. Start a new email change from your profile.",
|
"emailChangeVerifyExpired": "That confirmation link expired or was already used. Start a new email change from your profile.",
|
||||||
"emailChangeVerifyInvalid": "That confirmation link is not valid.",
|
"emailChangeVerifyInvalid": "That confirmation link is not valid.",
|
||||||
@@ -57,6 +57,8 @@
|
|||||||
"deleteAccountModalTitle": "Delete your account?",
|
"deleteAccountModalTitle": "Delete your account?",
|
||||||
"deleteAccountModalBody": "This will remove your account and sign you out. Published rules you created stay public as anonymous community rules.",
|
"deleteAccountModalBody": "This will remove your account and sign you out. Published rules you created stay public as anonymous community rules.",
|
||||||
"actionError": "Something went wrong. Try again.",
|
"actionError": "Something went wrong. Try again.",
|
||||||
|
"rulesLoadBannerTitle": "Could not load your CommunityRules",
|
||||||
|
"rulesLoadBannerDescription": "Check your connection and refresh the page.",
|
||||||
"notFound": "Not found",
|
"notFound": "Not found",
|
||||||
"forbidden": "You do not have permission for this action."
|
"forbidden": "You do not have permission for this action."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import Alert from "../../app/components/modals/Alert";
|
import Alert from "../../app/components/modals/Alert";
|
||||||
|
import {
|
||||||
|
ALERT_SIZE_OPTIONS,
|
||||||
|
ALERT_STATUS_OPTIONS,
|
||||||
|
ALERT_TYPE_OPTIONS,
|
||||||
|
} from "../../lib/propNormalization";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "Components/Modals/Alert",
|
title: "Components/Modals/Alert",
|
||||||
@@ -7,12 +12,17 @@ export default {
|
|||||||
argTypes: {
|
argTypes: {
|
||||||
status: {
|
status: {
|
||||||
control: { type: "select" },
|
control: { type: "select" },
|
||||||
options: ["default", "positive", "warning", "danger"],
|
options: [...ALERT_STATUS_OPTIONS],
|
||||||
},
|
},
|
||||||
type: {
|
type: {
|
||||||
control: { type: "select" },
|
control: { type: "select" },
|
||||||
options: ["toast", "banner"],
|
options: [...ALERT_TYPE_OPTIONS],
|
||||||
},
|
},
|
||||||
|
size: {
|
||||||
|
control: { type: "select" },
|
||||||
|
options: [...ALERT_SIZE_OPTIONS],
|
||||||
|
},
|
||||||
|
hasTrailingIcon: { control: { type: "boolean" } },
|
||||||
title: {
|
title: {
|
||||||
control: { type: "text" },
|
control: { type: "text" },
|
||||||
},
|
},
|
||||||
@@ -111,6 +121,40 @@ TitleOnly.args = {
|
|||||||
type: "toast",
|
type: "toast",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ToastSmall = Template.bind({});
|
||||||
|
ToastSmall.args = {
|
||||||
|
title: "Short alert toast message goes here",
|
||||||
|
description:
|
||||||
|
"Nascetur ipsum a nisi tempor cras nam neque volutpat. Aliquam id est faucibus nunc quis. Eleifend suspendisse.",
|
||||||
|
status: "default",
|
||||||
|
type: "toast",
|
||||||
|
size: "s",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BannerSmall = Template.bind({});
|
||||||
|
BannerSmall.args = {
|
||||||
|
title: "Short alert banner message goes here",
|
||||||
|
description:
|
||||||
|
"Nascetur ipsum a nisi tempor cras nam neque volutpat. Aliquam id est faucibus nunc quis. Eleifend suspendisse.",
|
||||||
|
status: "positive",
|
||||||
|
type: "banner",
|
||||||
|
size: "s",
|
||||||
|
};
|
||||||
|
|
||||||
|
const NoDismissTemplate = (args) => (
|
||||||
|
<div className="p-8 max-w-[600px]">
|
||||||
|
<Alert {...args} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
export const BannerNoDismiss = NoDismissTemplate.bind({});
|
||||||
|
BannerNoDismiss.args = {
|
||||||
|
title: "Non-dismissible banner (no onClose)",
|
||||||
|
description: "Used when the message clears via navigation or parent state only.",
|
||||||
|
status: "danger",
|
||||||
|
type: "banner",
|
||||||
|
size: "s",
|
||||||
|
};
|
||||||
|
|
||||||
export const AllStatuses = () => {
|
export const AllStatuses = () => {
|
||||||
const [visible, setVisible] = useState({
|
const [visible, setVisible] = useState({
|
||||||
default: true,
|
default: true,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
import Alert from "../../app/components/modals/Alert";
|
import Alert from "../../app/components/modals/Alert";
|
||||||
import { componentTestSuite } from "../utils/componentTestSuite";
|
import { componentTestSuite } from "../utils/componentTestSuite";
|
||||||
|
import { renderWithProviders as render } from "../utils/test-utils";
|
||||||
|
|
||||||
type AlertProps = React.ComponentProps<typeof Alert>;
|
type AlertProps = React.ComponentProps<typeof Alert>;
|
||||||
|
|
||||||
@@ -16,6 +20,7 @@ componentTestSuite<AlertProps>({
|
|||||||
description: "Optional description",
|
description: "Optional description",
|
||||||
status: "positive",
|
status: "positive",
|
||||||
type: "banner",
|
type: "banner",
|
||||||
|
size: "m",
|
||||||
},
|
},
|
||||||
primaryRole: "alert",
|
primaryRole: "alert",
|
||||||
testCases: {
|
testCases: {
|
||||||
@@ -26,3 +31,30 @@ componentTestSuite<AlertProps>({
|
|||||||
errorState: false,
|
errorState: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Alert dismiss control", () => {
|
||||||
|
it("omits close button when onClose is absent", () => {
|
||||||
|
render(<Alert title="T" description="D" />);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Close alert" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders close when onClose is provided", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<Alert title="T" onClose={onClose} />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Close alert" }));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits close when hasTrailingIcon is false even if onClose exists", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<Alert title="T" onClose={onClose} hasTrailingIcon={false} />,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Close alert" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user