Full cleanup pass

This commit is contained in:
adilallo
2026-05-21 23:25:56 -06:00
parent 28de8ef3bc
commit 99f535f821
149 changed files with 2623 additions and 1242 deletions
@@ -1,6 +1,7 @@
"use client";
import { memo } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import { CreateFlowFooterView } from "./CreateFlowFooter.view";
import type { CreateFlowFooterProps } from "./CreateFlowFooter.types";
@@ -16,7 +17,10 @@ const CreateFlowFooterContainer = memo<CreateFlowFooterProps>(
proportionBarVariant,
onBackClick,
className = "",
footerAriaLabel,
}) => {
const t = useTranslation("controlsChrome");
return (
<CreateFlowFooterView
secondButton={secondButton}
@@ -25,6 +29,7 @@ const CreateFlowFooterContainer = memo<CreateFlowFooterProps>(
proportionBarVariant={proportionBarVariant}
onBackClick={onBackClick}
className={className}
footerAriaLabel={footerAriaLabel ?? t("createFlowFooterAriaLabel")}
/>
);
},
@@ -36,4 +36,8 @@ export interface CreateFlowFooterProps {
* Additional CSS classes
*/
className?: string;
/**
* Accessible name for the footer landmark.
*/
footerAriaLabel?: string;
}
@@ -9,13 +9,14 @@ export function CreateFlowFooterView({
proportionBarVariant: proportionBarVariantProp,
onBackClick,
className = "",
footerAriaLabel,
}: CreateFlowFooterProps) {
const proportionBarVariant = proportionBarVariantProp ?? "default";
return (
<footer
className={`bg-black w-full ${className}`}
role="contentinfo"
aria-label="Create Flow Footer"
aria-label={footerAriaLabel}
>
{/* Progress Bar - Top */}
{progressBar && (
@@ -1,10 +1,14 @@
"use client";
import { memo } from "react";
import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useCreateFlowSm2Up } from "../../../(app)/create/hooks/useCreateFlowSm2Up";
import { useTranslation } from "../../../contexts/MessagesContext";
import { CreateFlowTopNavView } from "./CreateFlowTopNav.view";
import type { CreateFlowTopNavProps } from "./CreateFlowTopNav.types";
import type {
CreateFlowTopNavActionMenuItem,
CreateFlowTopNavProps,
} from "./CreateFlowTopNav.types";
/**
* Figma: Utility / CreateFlowTopNav — wizard header (create-flow chrome).
@@ -34,15 +38,168 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
const router = useRouter();
const t = useTranslation("create.topNav");
const tPopover = useTranslation("modals.popoverExport");
const sm2Up = useCreateFlowSm2Up();
const exitButtonText =
exitLabel ?? (saveDraftOnExit ? t("saveAndExit") : t("exit"));
const [exportMenuOpen, setExportMenuOpen] = useState(false);
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
const exportWrapRef = useRef<HTMLDivElement>(null);
const actionsWrapRef = useRef<HTMLDivElement>(null);
const exportMenuId = useId();
const actionsMenuId = useId();
const handleExit = (options?: { saveDraft?: boolean }) => {
if (onExit) {
onExit(options);
} else {
// Default behavior: navigate to home
router.push("/");
const handleExit = useCallback(
(options?: { saveDraft?: boolean }) => {
if (onExit) {
onExit(options);
} else {
// Default behavior: navigate to home
router.push("/");
}
},
[onExit, router],
);
const hasSecondaryActions =
hasShare ||
hasExport ||
hasEdit ||
hasDuplicate ||
hasManageStakeholders;
const useKebabMenu = hasSecondaryActions && !sm2Up;
const actionMenuItems = useMemo((): CreateFlowTopNavActionMenuItem[] => {
const items: CreateFlowTopNavActionMenuItem[] = [];
if (hasShare && onShare) {
items.push({
id: "share",
label: t("share"),
leadingIcon: "mail",
onClick: onShare,
});
}
};
if (hasExport && onSelectExportFormat) {
items.push(
{
id: "export-pdf",
label: tPopover("downloadPdf"),
leadingIcon: "picture_as_pdf",
onClick: () => onSelectExportFormat("pdf"),
},
{
id: "export-csv",
label: tPopover("downloadCsv"),
leadingIcon: "csv",
onClick: () => onSelectExportFormat("csv"),
},
{
id: "export-markdown",
label: tPopover("downloadMarkdown"),
leadingIcon: "markdown_copy",
onClick: () => onSelectExportFormat("markdown"),
},
);
}
if (hasDuplicate && onDuplicate) {
items.push({
id: "duplicate",
label: duplicateLabel ?? t("edit"),
leadingIcon: "content_copy",
onClick: onDuplicate,
});
} else if (hasEdit && onEdit) {
items.push({
id: "edit",
label: t("edit"),
leadingIcon: "edit",
onClick: onEdit,
});
}
if (hasManageStakeholders && onManageStakeholders) {
items.push({
id: "manage-stakeholders",
label: t("manageStakeholders"),
leadingIcon: "tags",
onClick: onManageStakeholders,
});
}
items.push({
id: "exit",
label: exitButtonText,
leadingIcon: "log_out",
onClick: () => void handleExit({ saveDraft: saveDraftOnExit }),
});
return items;
}, [
duplicateLabel,
exitButtonText,
handleExit,
hasDuplicate,
hasEdit,
hasExport,
hasManageStakeholders,
hasShare,
onDuplicate,
onEdit,
onManageStakeholders,
onSelectExportFormat,
onShare,
saveDraftOnExit,
t,
tPopover,
]);
useEffect(() => {
if (!exportMenuOpen) return;
const onDoc = (e: MouseEvent) => {
if (
exportWrapRef.current &&
!exportWrapRef.current.contains(e.target as Node)
) {
setExportMenuOpen(false);
}
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [exportMenuOpen]);
useEffect(() => {
if (!actionsMenuOpen) return;
const onDoc = (e: MouseEvent) => {
if (
actionsWrapRef.current &&
!actionsWrapRef.current.contains(e.target as Node)
) {
setActionsMenuOpen(false);
}
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [actionsMenuOpen]);
useEffect(() => {
if (!exportMenuOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setExportMenuOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [exportMenuOpen]);
useEffect(() => {
if (!actionsMenuOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setActionsMenuOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [actionsMenuOpen]);
return (
<CreateFlowTopNavView
@@ -63,6 +220,17 @@ const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
duplicateAriaLabel={duplicateAriaLabel}
buttonPalette={buttonPalette}
className={className}
exitButtonText={exitButtonText}
useKebabMenu={useKebabMenu}
exportMenuOpen={exportMenuOpen}
setExportMenuOpen={setExportMenuOpen}
actionsMenuOpen={actionsMenuOpen}
setActionsMenuOpen={setActionsMenuOpen}
exportWrapRef={exportWrapRef}
actionsWrapRef={actionsWrapRef}
exportMenuId={exportMenuId}
actionsMenuId={actionsMenuId}
actionMenuItems={actionMenuItems}
exportPopoverMenuAriaLabel={tPopover("menuAriaLabel")}
exportPopoverPdfLabel={tPopover("downloadPdf")}
exportPopoverCsvLabel={tPopover("downloadCsv")}
@@ -5,6 +5,16 @@
* Includes logo and action buttons (Share, Export, Edit, Exit).
*/
import type { Dispatch, RefObject, SetStateAction } from "react";
import type { IconName } from "../../asset/icon";
export type CreateFlowTopNavActionMenuItem = {
id: string;
label: string;
leadingIcon: IconName;
onClick: () => void;
};
export interface CreateFlowTopNavProps {
/**
* Whether to show the Share button
@@ -81,8 +91,19 @@ export interface CreateFlowTopNavProps {
className?: string;
}
/** Resolved copy for the export popover; supplied by the container. */
/** Resolved copy and menu state; supplied by the container. */
export type CreateFlowTopNavViewProps = CreateFlowTopNavProps & {
exitButtonText: string;
useKebabMenu: boolean;
exportMenuOpen: boolean;
setExportMenuOpen: Dispatch<SetStateAction<boolean>>;
actionsMenuOpen: boolean;
setActionsMenuOpen: Dispatch<SetStateAction<boolean>>;
exportWrapRef: RefObject<HTMLDivElement | null>;
actionsWrapRef: RefObject<HTMLDivElement | null>;
exportMenuId: string;
actionsMenuId: string;
actionMenuItems: CreateFlowTopNavActionMenuItem[];
exportPopoverMenuAriaLabel: string;
exportPopoverPdfLabel: string;
exportPopoverCsvLabel: string;
@@ -1,12 +1,9 @@
"use client";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { IconName } from "../../asset/icon";
import Logo from "../../asset/Logo";
import Button from "../../buttons/Button";
import ListItem from "../../layout/ListItem";
import Popover from "../../modals/Popover";
import { useCreateFlowSm2Up } from "../../../(app)/create/hooks/useCreateFlowSm2Up";
import { useTranslation } from "../../../contexts/MessagesContext";
import type { CreateFlowTopNavViewProps } from "./CreateFlowTopNav.types";
@@ -16,13 +13,6 @@ const outlineButtonClass =
const exitButtonFigmaClass =
"!rounded-[var(--radius-measures-radius-full,9999px)] !border-[1.25px] !px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!text-[12px] md:!leading-[14px]";
type ActionMenuItem = {
id: string;
label: string;
leadingIcon: IconName;
onClick: () => void;
};
function KebabIcon({ className = "" }: { className?: string }) {
return (
<svg
@@ -54,11 +44,21 @@ export function CreateFlowTopNavView({
onDuplicate,
onManageStakeholders,
onExit,
exitLabel,
duplicateLabel,
duplicateAriaLabel,
buttonPalette = "default",
className = "",
exitButtonText,
useKebabMenu,
exportMenuOpen,
setExportMenuOpen,
actionsMenuOpen,
setActionsMenuOpen,
exportWrapRef,
actionsWrapRef,
exportMenuId,
actionsMenuId,
actionMenuItems,
exportPopoverMenuAriaLabel,
exportPopoverPdfLabel,
exportPopoverCsvLabel,
@@ -67,15 +67,6 @@ export function CreateFlowTopNavView({
actionsMenuAriaLabel,
}: CreateFlowTopNavViewProps) {
const t = useTranslation("create.topNav");
const sm2Up = useCreateFlowSm2Up();
const exitButtonText =
exitLabel ?? (saveDraftOnExit ? t("saveAndExit") : t("exit"));
const [exportMenuOpen, setExportMenuOpen] = useState(false);
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
const exportWrapRef = useRef<HTMLDivElement>(null);
const actionsWrapRef = useRef<HTMLDivElement>(null);
const exportMenuId = useId();
const actionsMenuId = useId();
const hasSecondaryActions =
hasShare ||
@@ -83,142 +74,6 @@ export function CreateFlowTopNavView({
hasEdit ||
hasDuplicate ||
hasManageStakeholders;
const useKebabMenu = hasSecondaryActions && !sm2Up;
const actionMenuItems = useMemo((): ActionMenuItem[] => {
const items: ActionMenuItem[] = [];
if (hasShare && onShare) {
items.push({
id: "share",
label: t("share"),
leadingIcon: "mail",
onClick: onShare,
});
}
if (hasExport && onSelectExportFormat) {
items.push(
{
id: "export-pdf",
label: exportPopoverPdfLabel,
leadingIcon: "picture_as_pdf",
onClick: () => onSelectExportFormat("pdf"),
},
{
id: "export-csv",
label: exportPopoverCsvLabel,
leadingIcon: "csv",
onClick: () => onSelectExportFormat("csv"),
},
{
id: "export-markdown",
label: exportPopoverMarkdownLabel,
leadingIcon: "markdown_copy",
onClick: () => onSelectExportFormat("markdown"),
},
);
}
if (hasDuplicate && onDuplicate) {
items.push({
id: "duplicate",
label: duplicateLabel ?? t("edit"),
leadingIcon: "content_copy",
onClick: onDuplicate,
});
} else if (hasEdit && onEdit) {
items.push({
id: "edit",
label: t("edit"),
leadingIcon: "edit",
onClick: onEdit,
});
}
if (hasManageStakeholders && onManageStakeholders) {
items.push({
id: "manage-stakeholders",
label: t("manageStakeholders"),
leadingIcon: "tags",
onClick: onManageStakeholders,
});
}
items.push({
id: "exit",
label: exitButtonText,
leadingIcon: "log_out",
onClick: () => void onExit?.({ saveDraft: saveDraftOnExit }),
});
return items;
}, [
duplicateLabel,
exitButtonText,
exportPopoverCsvLabel,
exportPopoverMarkdownLabel,
exportPopoverPdfLabel,
hasDuplicate,
hasEdit,
hasExport,
hasManageStakeholders,
hasShare,
onDuplicate,
onEdit,
onExit,
onManageStakeholders,
onSelectExportFormat,
onShare,
saveDraftOnExit,
t,
]);
useEffect(() => {
if (!exportMenuOpen) return;
const onDoc = (e: MouseEvent) => {
if (
exportWrapRef.current &&
!exportWrapRef.current.contains(e.target as Node)
) {
setExportMenuOpen(false);
}
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [exportMenuOpen]);
useEffect(() => {
if (!actionsMenuOpen) return;
const onDoc = (e: MouseEvent) => {
if (
actionsWrapRef.current &&
!actionsWrapRef.current.contains(e.target as Node)
) {
setActionsMenuOpen(false);
}
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [actionsMenuOpen]);
useEffect(() => {
if (!exportMenuOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setExportMenuOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [exportMenuOpen]);
useEffect(() => {
if (!actionsMenuOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setActionsMenuOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [actionsMenuOpen]);
const inlineActions = (
<>
+2 -1
View File
@@ -14,6 +14,7 @@ import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
*/
const Footer = memo(() => {
const t = useTranslation("footer");
const tChrome = useTranslation("controlsChrome");
const linkFocusClass =
"hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity";
@@ -129,7 +130,7 @@ const Footer = memo(() => {
</div>
<nav
aria-label="Footer"
aria-label={tChrome("footerAriaLabel")}
className="order-1 flex w-full max-w-full flex-col
items-start
gap-[var(--spacing-scale-032)]
@@ -1,5 +1,9 @@
"use client";
/**
* Figma: "Utility / Menu Item" (see registry)
*/
import { memo } from "react";
import MenuItemView from "./MenuItem.view";
import type { MenuItemProps } from "./MenuItem.types";
@@ -1,5 +1,9 @@
"use client";
/**
* Figma: "Navigation / NavigationItem" (see registry)
*/
import { memo } from "react";
import NavigationItemView from "./NavigationItem.view";
import type { NavigationItemProps } from "./NavigationItem.types";
@@ -1,5 +1,9 @@
"use client";
/**
* Figma: "Navigation / Top" (22078-808559)
*/
import { memo, useCallback } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useAuthModal } from "../../../contexts/AuthModalContext";
@@ -24,11 +28,17 @@ const NAV_SIZE_TO_MENU_ITEM_SIZE: Record<NavSize, MenuClusterSize> = {
xlarge: "X Large",
};
export const avatarImages = [
{ src: getAssetPath(ASSETS.AVATAR_3), alt: "Avatar 3" },
{ src: getAssetPath(ASSETS.AVATAR_2), alt: "Avatar 2" },
{ src: getAssetPath(ASSETS.AVATAR_1), alt: "Avatar 1" },
];
export const avatarImageSources = [
getAssetPath(ASSETS.AVATAR_3),
getAssetPath(ASSETS.AVATAR_2),
getAssetPath(ASSETS.AVATAR_1),
] as const;
/** @deprecated Use `avatarImageSources` — alts are resolved in `TopContainer` via `topNav` messages. */
export const avatarImages = avatarImageSources.map((src, index) => ({
src,
alt: `Avatar ${3 - index}`,
}));
const TopContainer = memo<TopProps>(
({ folderTop = false, loggedIn = false, profile = false, logIn = true }) => {
@@ -36,6 +46,7 @@ const TopContainer = memo<TopProps>(
const router = useRouter();
const { openLogin } = useAuthModal();
const t = useTranslation("header");
const tTopNav = useTranslation("topNav");
/**
* `Top` is hidden on `/create` routes by ConditionalNavigationClient, so
@@ -58,7 +69,7 @@ const TopContainer = memo<TopProps>(
name: "CommunityRule",
url: "https://communityrule.com",
...(folderTop && {
description: "Build operating manuals for successful communities",
description: tTopNav("schemaDescription"),
}),
potentialAction: {
"@type": "SearchAction",
@@ -110,11 +121,11 @@ const TopContainer = memo<TopProps>(
) => {
return (
<AvatarContainer size={containerSize}>
{avatarImages.map((avatar, index) => (
{avatarImageSources.map((src, index) => (
<Avatar
key={index}
src={avatar.src}
alt={avatar.alt}
src={src}
alt={tTopNav(`avatarAlts.${3 - index}`)}
size={avatarSize}
/>
))}