mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Fix the top bar styling (#7544)
Every top bar styled itself, so none of them matched the new UI. Also, colors on the premium banner (and possibly others) clashed since the theme changes. ## Before Example Issue <img width="1934" height="348" alt="Screenshot 2026-08-17 at 11 47 20 PM" src="https://github.com/user-attachments/assets/b6f13207-2f47-4084-bd3b-2392f572c1a1" /> ## After (all) <img width="2880" height="800" alt="danger__dark" src="https://github.com/user-attachments/assets/6b311dec-23e7-4059-a6bb-75527cbd2e34" /> <img width="2880" height="800" alt="danger__light" src="https://github.com/user-attachments/assets/3b04b109-5146-4874-88b3-d99770ea51f8" /> <img width="2880" height="800" alt="default-app__dark" src="https://github.com/user-attachments/assets/b0101b98-fce8-467a-99a6-dd40b8864da1" /> <img width="2880" height="800" alt="default-app__light" src="https://github.com/user-attachments/assets/8aa21f01-6549-4d78-b217-c5547d60ab5b" /> <img width="2880" height="800" alt="free-tier-limit__dark" src="https://github.com/user-attachments/assets/07fd7498-f44f-408f-8c79-9b5ea55e13df" /> <img width="2880" height="800" alt="free-tier-limit__light" src="https://github.com/user-attachments/assets/f38c527b-9f64-4db0-b84c-56ca48e464cc" /> <img width="2880" height="800" alt="server-attention__dark" src="https://github.com/user-attachments/assets/447a36fa-056d-4ca9-8b30-04aa0ccd6ed1" /> <img width="2880" height="800" alt="server-attention__light" src="https://github.com/user-attachments/assets/f0311d45-a218-4846-b0ac-47996e2637c5" /> <img width="2880" height="800" alt="team-invitation__dark" src="https://github.com/user-attachments/assets/cc368473-3b9f-4ab0-878a-67da993874c2" /> <img width="2880" height="800" alt="team-invitation__light" src="https://github.com/user-attachments/assets/19d52a3e-4028-46f0-8562-9bd9cef9397a" /> <img width="2880" height="800" alt="upgrade-prompt__dark" src="https://github.com/user-attachments/assets/e9d20daa-f41f-46d9-b827-a84f276e8af1" /> <img width="2880" height="800" alt="upgrade-prompt__light" src="https://github.com/user-attachments/assets/9b6250c1-6a61-40d6-a7ab-e37398d67322" /> ## What changed - `InfoBanner` exposed 8 colour-override props (`background`, `borderColor`, `textColor`, `iconColor`, `buttonColor`, `buttonTextColor`, `closeIconColor`, `buttonVariant`), so every caller invented its own look. Replaced with a closed tone set: `info` · `promo` · `warning` · `danger`. - Tone drives the whole bar — fill, border, icon and the button — so a CTA can't drift from the bar it sits on. Text is neutral in every tone; only the icon carries the tone colour. - All colour comes from `--c-*` tokens mixed over `--c-surface`, so the bars follow light and dark instead of ignoring them. The old bars were hardcoded: in dark mode the two licence warnings stayed cream-on-white. - `promo` keeps the gradient it was always meant to have, built from the existing `--c-hue-indigo`/`--c-hue-purple` stops (documented in `colors.css` as gradient hues, deliberately not accent-following), with the existing `premium` button accent on it. - Deleted the hardcoded colours from all four callers: the purple gradient (`#667eea`→`#764ba2`), the orange soup (`#FFF4E6` / `#9A3412` / `#EA580C`) duplicated across the urgent banner and the admin plan section, and the fixed dark bar (`--mantine-color-dark-7`) on the team invitation. - `UpgradeBanner|AdminPlanSection` sat on the theme linter's exemption list, which is how those colours survived the theme migration. Exemption removed, so `code-colors` now guards them. - The banner's class was colliding with `core/ui/Banner.css`'s `.sui-banner` (16 live rules), which restyled it in the app but not in Storybook — that's why the two disagreed on radius, border and tone. Renamed to `.app-banner`; the two surfaces now render identically. - Bar is square and full-bleed with a single hairline rule underneath; button labels are optically centred. - Added `--c-warning-subtle`, matching the existing `--c-danger-subtle` / `--c-success-subtle`. - New `Shared → Top bars` story renders all six bars at once, so a change to the shared component is visible against the whole set. - Unrelated one-liner: `frontend/.prettierignore` now ignores the gitignored `editor/screenshots/` capture artifacts, which were failing `format:check` locally. Happy to drop it if you'd rather keep this PR to the bars. ## Testing - `task frontend:check` — typecheck, lint (oxlint + 4 theme-lint passes + stylelint), format, 244 files / 2119 tests. - `frontend:storybook:a11y:changed` — clean in light and dark. - The a11y gate caught a real defect mid-change: giving each banner `role="region"` with the same label produced duplicate landmarks, which the app hits for real whenever two banners show at once. Landmark removed. - All six bars captured in the running editor, light and dark, and diffed against `origin/main`'s component rendered with each caller's original props.
This commit is contained in:
@@ -1879,6 +1879,9 @@ width = "Width"
|
||||
[app]
|
||||
description = "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
|
||||
[appBanner]
|
||||
dismiss = "Dismiss"
|
||||
|
||||
[attachments]
|
||||
convertToPdfA3b = "Convert to PDF/A-3b"
|
||||
convertToPdfA3bDescription = "Creates an archival PDF with embedded attachments"
|
||||
@@ -4827,9 +4830,6 @@ title = "Image to PDF"
|
||||
[imageToPdf]
|
||||
tags = "conversion,img,jpg,picture,photo"
|
||||
|
||||
[infoBanner]
|
||||
dismiss = "Dismiss"
|
||||
|
||||
[invite]
|
||||
acceptError = "Failed to create account"
|
||||
accountFor = "Creating account for"
|
||||
|
||||
@@ -642,7 +642,6 @@ const CODE_EXEMPT_PATH = [
|
||||
/mantineTheme|\/theme\.ts$|toolsTaxonomy|LayoutPreview|PageNumberPreview|CloudStorageIcons|BrandMarks/,
|
||||
/\/onboarding\//,
|
||||
/addStamp|addWatermark|\/tooltips\//,
|
||||
/UpgradeBanner|AdminPlanSection/,
|
||||
// Stories are checked like app code; colour-as-data lines opt out with
|
||||
// `theme-allow-color`.
|
||||
/\.test\.[jt]sx?$|\/types\//,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Group, Text } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
import { AppBanner } from "@app/components/shared/AppBanner";
|
||||
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
|
||||
|
||||
/**
|
||||
@@ -105,7 +105,7 @@ export function TeamInvitationBanner() {
|
||||
);
|
||||
|
||||
return (
|
||||
<InfoBanner
|
||||
<AppBanner
|
||||
icon="mail"
|
||||
message={
|
||||
<Group
|
||||
@@ -120,10 +120,6 @@ export function TeamInvitationBanner() {
|
||||
}
|
||||
show={shouldShow}
|
||||
dismissible={false}
|
||||
background="var(--mantine-color-dark-7)"
|
||||
borderColor="var(--mantine-color-dark-5)"
|
||||
textColor="rgba(255, 255, 255, 0.95)"
|
||||
iconColor="rgba(255, 255, 255, 0.95)"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AppLayout } from "@app/components/AppLayout";
|
||||
import { BannerProvider, useBanner } from "@app/contexts/BannerContext";
|
||||
import { NavigationProvider } from "@app/contexts/NavigationContext";
|
||||
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
import { AppBanner } from "@app/components/shared/AppBanner";
|
||||
|
||||
const meta = {
|
||||
title: "Components/AppLayout",
|
||||
@@ -49,7 +49,7 @@ function BannerSetter() {
|
||||
const { setBanner } = useBanner();
|
||||
useEffect(() => {
|
||||
setBanner(
|
||||
<InfoBanner
|
||||
<AppBanner
|
||||
icon="info-rounded"
|
||||
title="Heads up"
|
||||
message="This workspace is running in offline mode."
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/* App-wide top bar. One shape, four tones — callers pick a tone, never a colour.
|
||||
Named `app-banner`, not `sui-banner`: that belongs to the SUI Banner primitive. */
|
||||
.app-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 3.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
/* Full-bleed across the top of the app: square corners, one rule underneath. */
|
||||
border-bottom: 1px solid var(--app-banner-border);
|
||||
background: var(--app-banner-bg);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.app-banner--compact {
|
||||
min-height: 2.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.app-banner--info {
|
||||
--app-banner-bg: var(--c-primary-subtle);
|
||||
--app-banner-border: var(--c-primary-border);
|
||||
--app-banner-icon: var(--c-accent-fg, var(--c-primary));
|
||||
}
|
||||
|
||||
/* The one bar meant to pop, so it takes the feature gradient rather than a tint.
|
||||
Fixed hues by design — it doesn't follow the chosen accent. */
|
||||
.app-banner--promo {
|
||||
--app-banner-bg: linear-gradient(
|
||||
135deg,
|
||||
var(--c-hue-indigo) 0%,
|
||||
var(--c-hue-purple) 100%
|
||||
);
|
||||
--app-banner-border: transparent;
|
||||
--app-banner-icon: var(--color-text-on-accent);
|
||||
color: var(--color-text-on-accent);
|
||||
}
|
||||
|
||||
.app-banner--warning {
|
||||
--app-banner-bg: var(--c-warning-subtle);
|
||||
--app-banner-border: color-mix(in srgb, var(--c-warning) 32%, transparent);
|
||||
--app-banner-icon: var(--c-warning);
|
||||
}
|
||||
|
||||
.app-banner--danger {
|
||||
--app-banner-bg: var(--c-danger-subtle);
|
||||
--app-banner-border: color-mix(in srgb, var(--c-danger) 32%, transparent);
|
||||
--app-banner-icon: var(--c-danger);
|
||||
}
|
||||
|
||||
/* Only the icon carries the tone; text stays neutral in every tone. */
|
||||
.app-banner__icon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
color: var(--app-banner-icon);
|
||||
}
|
||||
|
||||
.app-banner__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.app-banner__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-banner__message {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.app-banner__body:not(:has(.app-banner__title)) .app-banner__message {
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.app-banner--compact .app-banner__title,
|
||||
.app-banner--compact .app-banner__message {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* On the gradient everything is white; muted grey would disappear. */
|
||||
.app-banner--promo .app-banner__message,
|
||||
.app-banner--promo .app-banner__actions .sui-btn--tertiary,
|
||||
.app-banner--promo .app-banner__actions .sui-ai {
|
||||
color: var(--color-text-on-accent);
|
||||
}
|
||||
|
||||
/* Lifts the premium CTA off the gradient it sits on. */
|
||||
.app-banner--promo .app-banner__actions .sui-btn--primary {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.app-banner__actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Mantine trims the leading padding when a button has a left section, which reads
|
||||
as off-centre next to the label. Even it back up. */
|
||||
.app-banner__actions .sui-btn .mantine-Button-inner {
|
||||
padding-inline: 0;
|
||||
}
|
||||
.app-banner__actions .sui-btn {
|
||||
padding-inline: 0.875rem;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AppBanner } from "@app/components/shared/AppBanner";
|
||||
|
||||
const meta = {
|
||||
title: "Shared/AppBanner",
|
||||
component: AppBanner,
|
||||
parameters: { layout: "fullscreen" },
|
||||
} satisfies Meta<typeof AppBanner>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Info: Story = {
|
||||
args: {
|
||||
icon: "info-rounded",
|
||||
title: "Heads up",
|
||||
message: "This document contains form fields that will be flattened.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Promo: Story = {
|
||||
args: {
|
||||
tone: "promo",
|
||||
icon: "stars-rounded",
|
||||
title: "Upgrade to Server Plan",
|
||||
message:
|
||||
"Get the most out of Stirling PDF with unlimited users and advanced features.",
|
||||
buttonText: "Upgrade Now",
|
||||
buttonIcon: "upgrade-rounded",
|
||||
onButtonClick: () => {},
|
||||
compact: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
tone: "warning",
|
||||
icon: "warning-rounded",
|
||||
title: "Action required",
|
||||
message: "Some pages could not be processed and were skipped.",
|
||||
buttonText: "Review",
|
||||
onButtonClick: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Danger: Story = {
|
||||
args: {
|
||||
tone: "danger",
|
||||
icon: "warning-rounded",
|
||||
title: "This server needs admin attention",
|
||||
message: "Review the license requirements to keep this server compliant.",
|
||||
buttonText: "See info",
|
||||
buttonIcon: "info-rounded",
|
||||
onButtonClick: () => {},
|
||||
dismissible: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Compact: Story = {
|
||||
args: {
|
||||
compact: true,
|
||||
icon: "info-rounded",
|
||||
message: "Autosave is enabled for this file.",
|
||||
dismissible: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** Message-only, no title: the message takes the title's weight so the bar still reads. */
|
||||
export const MessageOnly: Story = {
|
||||
args: {
|
||||
icon: "picture-as-pdf-rounded",
|
||||
message:
|
||||
"Make Stirling PDF your default application for opening PDF files.",
|
||||
buttonText: "Set Default",
|
||||
onButtonClick: () => {},
|
||||
secondaryButtonText: "Don't remind me again",
|
||||
onSecondaryButtonClick: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
function Row({ caption, children }: { caption: string; children: ReactNode }) {
|
||||
return (
|
||||
<section
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.6875rem",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--c-text-subtle)",
|
||||
padding: "0 1rem",
|
||||
}}
|
||||
>
|
||||
{caption}
|
||||
</span>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every top bar the app can show, in one place: each entry mirrors a real caller,
|
||||
* so a change to the component is visible against the whole set at once. Renders a
|
||||
* composition rather than the component, so it takes no args of its own.
|
||||
*/
|
||||
export const AllTopBars: StoryObj = {
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1.5rem",
|
||||
padding: "1.5rem 0",
|
||||
background: "var(--c-bg)",
|
||||
}}
|
||||
>
|
||||
<Row caption="Upgrade prompt · UpgradeBanner (friendly)">
|
||||
<AppBanner
|
||||
tone="promo"
|
||||
compact
|
||||
icon="stars-rounded"
|
||||
title="Upgrade to Server Plan"
|
||||
message="Get the most out of Stirling PDF with unlimited users and advanced features."
|
||||
buttonText="Upgrade Now"
|
||||
buttonIcon="upgrade-rounded"
|
||||
onButtonClick={() => {}}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row caption="Server needs attention · UpgradeBanner (urgent)">
|
||||
<AppBanner
|
||||
tone="warning"
|
||||
icon="warning-rounded"
|
||||
title="This server needs admin attention"
|
||||
message="Review the license requirements to keep this server compliant."
|
||||
buttonText="See info"
|
||||
buttonIcon="info-rounded"
|
||||
onButtonClick={() => {}}
|
||||
dismissible={false}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row caption="Free tier limit reached · AdminPlanSection">
|
||||
<AppBanner
|
||||
tone="warning"
|
||||
icon="warning-rounded"
|
||||
title="Free self-hosted limit reached"
|
||||
message="You have 12 users on a plan that covers 10."
|
||||
buttonText="See plans"
|
||||
buttonIcon="upgrade-rounded"
|
||||
onButtonClick={() => {}}
|
||||
dismissible={false}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row caption="Team invitation · TeamInvitationBanner">
|
||||
<AppBanner
|
||||
icon="mail"
|
||||
message="You have been invited to join the Acme Legal team."
|
||||
buttonText="Accept"
|
||||
onButtonClick={() => {}}
|
||||
secondaryButtonText="Decline"
|
||||
onSecondaryButtonClick={() => {}}
|
||||
dismissible={false}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row caption="Set as default app · DefaultAppBanner (desktop)">
|
||||
<AppBanner
|
||||
icon="picture-as-pdf-rounded"
|
||||
message="Make Stirling PDF your default application for opening PDF files."
|
||||
buttonText="Set Default"
|
||||
onButtonClick={() => {}}
|
||||
secondaryButtonText="Don't remind me again"
|
||||
onSecondaryButtonClick={() => {}}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row caption="Danger tone (available, no caller yet)">
|
||||
<AppBanner
|
||||
tone="danger"
|
||||
icon="warning-rounded"
|
||||
title="Storage is full"
|
||||
message="New uploads will fail until space is freed."
|
||||
buttonText="Manage storage"
|
||||
onButtonClick={() => {}}
|
||||
dismissible={false}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import "@app/components/shared/AppBanner.css";
|
||||
|
||||
/** Picks the whole look. Callers choose meaning, never colours. */
|
||||
export type AppBannerTone = "info" | "promo" | "warning" | "danger";
|
||||
|
||||
/** Tone decides the button too, so the CTA can't drift from the bar it sits on. */
|
||||
const TONE_BUTTON = {
|
||||
info: { variant: "secondary", accent: "default" },
|
||||
promo: { variant: "primary", accent: "premium" },
|
||||
warning: { variant: "primary", accent: "warning" },
|
||||
danger: { variant: "primary", accent: "danger" },
|
||||
} as const;
|
||||
|
||||
interface AppBannerProps {
|
||||
/** A LocalIcon name, or a pre-rendered node (e.g. a logo) dropped in as-is. */
|
||||
icon?: string | ReactNode;
|
||||
title?: ReactNode;
|
||||
message: ReactNode;
|
||||
buttonText?: string;
|
||||
buttonIcon?: string;
|
||||
onButtonClick?: () => void;
|
||||
/** Muted secondary action, e.g. "Don't remind me again". */
|
||||
secondaryButtonText?: string;
|
||||
onSecondaryButtonClick?: () => void;
|
||||
onDismiss?: () => void;
|
||||
dismissible?: boolean;
|
||||
loading?: boolean;
|
||||
show?: boolean;
|
||||
tone?: AppBannerTone;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/** The app's top bar: dismissible messaging above the workspace. */
|
||||
export const AppBanner: React.FC<AppBannerProps> = ({
|
||||
icon,
|
||||
title,
|
||||
message,
|
||||
buttonText,
|
||||
buttonIcon = "check-circle-rounded",
|
||||
onButtonClick,
|
||||
secondaryButtonText,
|
||||
onSecondaryButtonClick,
|
||||
onDismiss,
|
||||
dismissible = true,
|
||||
loading = false,
|
||||
show = true,
|
||||
tone = "info",
|
||||
compact = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
if (!show) return null;
|
||||
|
||||
const iconSize = compact ? "1rem" : "1.25rem";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"app-banner",
|
||||
`app-banner--${tone}`,
|
||||
compact ? "app-banner--compact" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{icon != null && (
|
||||
<span className="app-banner__icon" aria-hidden>
|
||||
{typeof icon === "string" ? (
|
||||
<LocalIcon icon={icon} width={iconSize} height={iconSize} />
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="app-banner__body">
|
||||
{title && <span className="app-banner__title">{title}</span>}
|
||||
<span className="app-banner__message">{message}</span>
|
||||
</div>
|
||||
|
||||
<div className="app-banner__actions">
|
||||
{buttonText && onButtonClick && (
|
||||
<Button
|
||||
variant={TONE_BUTTON[tone].variant}
|
||||
accent={TONE_BUTTON[tone].accent}
|
||||
size="sm"
|
||||
loading={loading}
|
||||
onClick={onButtonClick}
|
||||
leftSection={
|
||||
<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />
|
||||
}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
{secondaryButtonText && onSecondaryButtonClick && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
onClick={onSecondaryButtonClick}
|
||||
>
|
||||
{secondaryButtonText}
|
||||
</Button>
|
||||
)}
|
||||
{dismissible && (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
onClick={() => onDismiss?.()}
|
||||
aria-label={t("appBanner.dismiss", "Dismiss")}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
|
||||
const meta = {
|
||||
title: "Shared/InfoBanner",
|
||||
component: InfoBanner,
|
||||
parameters: { layout: "padded" },
|
||||
} satisfies Meta<typeof InfoBanner>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
icon: "info-rounded",
|
||||
title: "Heads up",
|
||||
message: "This document contains form fields that will be flattened.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
tone: "warning",
|
||||
icon: "warning-rounded",
|
||||
title: "Action required",
|
||||
message: "Some pages could not be processed and were skipped.",
|
||||
buttonText: "Review",
|
||||
onButtonClick: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Compact: Story = {
|
||||
args: {
|
||||
compact: true,
|
||||
icon: "info-rounded",
|
||||
message: "Autosave is enabled for this file.",
|
||||
dismissible: false,
|
||||
},
|
||||
};
|
||||
@@ -1,263 +0,0 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import { Paper, Group, Text, Stack } from "@mantine/core";
|
||||
import { Button, type ButtonVariant, type ButtonAccent } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
|
||||
type InfoBannerTone = "info" | "warning";
|
||||
|
||||
const toneStyles: Record<
|
||||
InfoBannerTone,
|
||||
{
|
||||
background: string;
|
||||
border: string;
|
||||
text: string;
|
||||
icon: string;
|
||||
buttonColor: string;
|
||||
}
|
||||
> = {
|
||||
info: {
|
||||
background: "var(--mantine-color-blue-0)",
|
||||
border: "var(--mantine-color-blue-2)",
|
||||
text: "var(--mantine-color-blue-9)",
|
||||
icon: "var(--mantine-color-blue-6)",
|
||||
buttonColor: "blue",
|
||||
},
|
||||
warning: {
|
||||
background: "var(--mantine-color-orange-0)",
|
||||
border: "var(--mantine-color-orange-3)",
|
||||
text: "var(--color-amber-dark)",
|
||||
icon: "var(--mantine-color-orange-7)",
|
||||
buttonColor: "orange",
|
||||
},
|
||||
};
|
||||
|
||||
function toSharedButtonVariant(
|
||||
variant: "light" | "filled" | "white" | "outline" | "subtle",
|
||||
): ButtonVariant {
|
||||
switch (variant) {
|
||||
case "filled":
|
||||
return "primary";
|
||||
case "outline":
|
||||
return "secondary";
|
||||
case "subtle":
|
||||
return "tertiary";
|
||||
case "light":
|
||||
case "white":
|
||||
default:
|
||||
return "secondary";
|
||||
}
|
||||
}
|
||||
|
||||
function toSharedButtonAccent(color: string | undefined): ButtonAccent {
|
||||
// Mantine colours may carry a shade suffix (e.g. "orange.7"); use the hue.
|
||||
const hue = (color ?? "").split(".")[0];
|
||||
switch (hue) {
|
||||
case "red":
|
||||
return "danger";
|
||||
case "green":
|
||||
return "success";
|
||||
case "yellow":
|
||||
case "orange":
|
||||
return "warning";
|
||||
case "blue":
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
interface InfoBannerProps {
|
||||
/**
|
||||
* Either a LocalIcon name (string) for the standard sized icon slot, or a
|
||||
* pre-rendered ReactNode (e.g. a logo image) which is dropped in as-is.
|
||||
*/
|
||||
icon?: string | ReactNode;
|
||||
title?: ReactNode;
|
||||
message: ReactNode;
|
||||
buttonText?: string;
|
||||
buttonIcon?: string;
|
||||
onButtonClick?: () => void;
|
||||
/** Optional muted secondary action (e.g. "Don't remind me again"). */
|
||||
secondaryButtonText?: string;
|
||||
onSecondaryButtonClick?: () => void;
|
||||
onDismiss?: () => void;
|
||||
dismissible?: boolean;
|
||||
loading?: boolean;
|
||||
show?: boolean;
|
||||
tone?: InfoBannerTone;
|
||||
background?: string;
|
||||
borderColor?: string;
|
||||
textColor?: string;
|
||||
iconColor?: string;
|
||||
buttonColor?: string;
|
||||
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
|
||||
/** Override the button label colour (for dark/custom theme variants). */
|
||||
buttonTextColor?: string;
|
||||
minHeight?: number | string;
|
||||
closeIconColor?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic info banner component for displaying dismissible messages at the top of the app
|
||||
*/
|
||||
export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
icon,
|
||||
title,
|
||||
message,
|
||||
buttonText,
|
||||
buttonIcon = "check-circle-rounded",
|
||||
onButtonClick,
|
||||
secondaryButtonText,
|
||||
onSecondaryButtonClick,
|
||||
onDismiss,
|
||||
dismissible = true,
|
||||
loading = false,
|
||||
show = true,
|
||||
tone = "info",
|
||||
background,
|
||||
borderColor,
|
||||
textColor,
|
||||
iconColor,
|
||||
buttonColor,
|
||||
buttonVariant = "light",
|
||||
buttonTextColor,
|
||||
minHeight = 56,
|
||||
closeIconColor,
|
||||
compact = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toneStyle = toneStyles[tone] ?? toneStyles.info;
|
||||
const resolvedTextColor = textColor ?? toneStyle.text;
|
||||
const handleDismiss = () => {
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
const iconSize = compact ? "1rem" : "1.2rem";
|
||||
const textSize = compact ? "xs" : "sm";
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p={compact ? "xs" : "sm"}
|
||||
radius={0}
|
||||
style={{
|
||||
background: background ?? toneStyle.background,
|
||||
border: "none",
|
||||
borderBottom:
|
||||
borderColor === "transparent"
|
||||
? "none"
|
||||
: `1px solid ${borderColor ?? toneStyle.border}`,
|
||||
minHeight,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
gap="sm"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
justify="space-between"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Group
|
||||
gap={compact ? "xs" : "sm"}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{icon != null &&
|
||||
(typeof icon === "string" ? (
|
||||
<LocalIcon
|
||||
icon={icon}
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{ flexShrink: 0, display: "flex", alignItems: "center" }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
))}
|
||||
<Stack gap={compact ? 1 : 2} style={{ flex: 1, minWidth: 0 }}>
|
||||
{title && (
|
||||
<Text
|
||||
fw={600}
|
||||
size={textSize}
|
||||
style={{ color: resolvedTextColor }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
fw={title ? 400 : 500}
|
||||
size={textSize}
|
||||
style={{ color: resolvedTextColor }}
|
||||
lineClamp={compact ? 1 : 2}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
{buttonText && onButtonClick && (
|
||||
<Button
|
||||
variant={toSharedButtonVariant(buttonVariant)}
|
||||
accent={toSharedButtonAccent(
|
||||
buttonColor ?? toneStyle.buttonColor,
|
||||
)}
|
||||
size="sm"
|
||||
loading={loading}
|
||||
onClick={onButtonClick}
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon={buttonIcon}
|
||||
width={compact ? "0.75rem" : "0.9rem"}
|
||||
height={compact ? "0.75rem" : "0.9rem"}
|
||||
/>
|
||||
}
|
||||
style={buttonTextColor ? { color: buttonTextColor } : undefined}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
{secondaryButtonText && onSecondaryButtonClick && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
onClick={onSecondaryButtonClick}
|
||||
style={{ color: "var(--c-text-muted)" }}
|
||||
>
|
||||
{secondaryButtonText}
|
||||
</Button>
|
||||
)}
|
||||
{dismissible && (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="sm"
|
||||
onClick={handleDismiss}
|
||||
aria-label={t("infoBanner.dismiss", "Dismiss")}
|
||||
style={{
|
||||
color: closeIconColor ?? "var(--c-text-muted)",
|
||||
}}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="close-rounded"
|
||||
width={compact ? "0.85rem" : "1rem"}
|
||||
height={compact ? "0.85rem" : "1rem"}
|
||||
/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -71,6 +71,11 @@ html[data-app-theme="light"] {
|
||||
var(--c-success) 10%,
|
||||
var(--c-surface)
|
||||
);
|
||||
--c-warning-subtle: color-mix(
|
||||
in srgb,
|
||||
var(--c-warning) 10%,
|
||||
var(--c-surface)
|
||||
);
|
||||
|
||||
/* ── Decorative / brand / categorical palette ──────────────────────────
|
||||
Fixed hues that intentionally do NOT follow the chosen accent: brand
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
import { AppBanner } from "@app/components/shared/AppBanner";
|
||||
import { useDefaultApp } from "@app/hooks/useDefaultApp";
|
||||
|
||||
export const DefaultAppBanner: React.FC = () => {
|
||||
@@ -15,7 +15,7 @@ export const DefaultAppBanner: React.FC = () => {
|
||||
const [sessionDismissed, setSessionDismissed] = useState(false);
|
||||
|
||||
return (
|
||||
<InfoBanner
|
||||
<AppBanner
|
||||
icon="picture-as-pdf-rounded"
|
||||
message={t(
|
||||
"defaultApp.prompt.message",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { isAuthRoute } from "@core/constants/routes";
|
||||
import { useCheckout } from "@app/contexts/CheckoutContext";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
import { AppBanner } from "@app/components/shared/AppBanner";
|
||||
import {
|
||||
SERVER_LICENSE_REQUEST_EVENT,
|
||||
type ServerLicenseRequestPayload,
|
||||
@@ -300,7 +300,7 @@ const UpgradeBanner: React.FC = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<InfoBanner
|
||||
<AppBanner
|
||||
icon="warning-rounded"
|
||||
tone="warning"
|
||||
title={t(
|
||||
@@ -312,13 +312,6 @@ const UpgradeBanner: React.FC = () => {
|
||||
buttonIcon="info-rounded"
|
||||
onButtonClick={buttonText ? handleSeeInfo : undefined}
|
||||
dismissible={false}
|
||||
minHeight={60}
|
||||
background="#FFF4E6"
|
||||
borderColor="var(--mantine-color-orange-7)"
|
||||
textColor="#9A3412"
|
||||
iconColor="#EA580C"
|
||||
buttonVariant="filled"
|
||||
buttonColor="orange.7"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -341,7 +334,7 @@ const UpgradeBanner: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
{friendlyVisible && (
|
||||
<InfoBanner
|
||||
<AppBanner
|
||||
icon="stars-rounded"
|
||||
title={t("upgradeBanner.title", "Upgrade to Server Plan")}
|
||||
message={t(
|
||||
@@ -353,14 +346,7 @@ const UpgradeBanner: React.FC = () => {
|
||||
onButtonClick={handleUpgrade}
|
||||
onDismiss={handleFriendlyDismiss}
|
||||
show={friendlyVisible}
|
||||
background="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
borderColor="transparent"
|
||||
textColor="#fff"
|
||||
iconColor="#fff"
|
||||
closeIconColor="#fff"
|
||||
buttonVariant="filled"
|
||||
buttonColor="blue"
|
||||
minHeight={48}
|
||||
tone="promo"
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
|
||||
+2
-9
@@ -12,7 +12,7 @@ import AvailablePlansSection from "@app/components/shared/config/configSections/
|
||||
import StaticPlanSection from "@app/components/shared/config/configSections/plan/StaticPlanSection";
|
||||
import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
import { AppBanner } from "@app/components/shared/AppBanner";
|
||||
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
|
||||
import {
|
||||
getPreferredCurrency,
|
||||
@@ -200,7 +200,7 @@ const AdminPlanSection: React.FC = () => {
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
|
||||
{shouldShowLicenseWarning && (
|
||||
<InfoBanner
|
||||
<AppBanner
|
||||
icon="warning-rounded"
|
||||
tone="warning"
|
||||
title={t(
|
||||
@@ -215,13 +215,6 @@ const AdminPlanSection: React.FC = () => {
|
||||
buttonIcon="upgrade-rounded"
|
||||
onButtonClick={scrollToPlans}
|
||||
dismissible={false}
|
||||
minHeight={68}
|
||||
background="#FFF4E6"
|
||||
borderColor="var(--mantine-color-orange-7)"
|
||||
textColor="#9A3412"
|
||||
iconColor="#EA580C"
|
||||
buttonVariant="filled"
|
||||
buttonColor="orange.7"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user