Custom add and create flow polish

This commit is contained in:
adilallo
2026-05-08 20:32:24 -06:00
parent 26bcd61ea3
commit 026a1e6d71
68 changed files with 6208 additions and 527 deletions
+3
View File
@@ -6,6 +6,7 @@ import ArrowBackIcon from "./arrow_back.svg";
import ChevronRightIcon from "./chevron_right.svg";
import ContentCopyIcon from "./content_copy.svg";
import CsvIcon from "./csv.svg";
import CustomIcon from "./custom.svg";
import EditIcon from "./edit.svg";
import ExclamationIcon from "./exclamation.svg";
import ImageGlyphIcon from "./image.svg";
@@ -23,6 +24,7 @@ export const ICON_NAME_OPTIONS = [
"chevron_right",
"content_copy",
"csv",
"custom",
"edit",
"exclamation",
"image",
@@ -48,6 +50,7 @@ const iconMap: Record<IconName, SvgComponent> = {
chevron_right: ChevronRightIcon,
content_copy: ContentCopyIcon,
csv: CsvIcon,
custom: CustomIcon,
edit: EditIcon,
exclamation: ExclamationIcon,
image: ImageGlyphIcon,
+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.3636 8.27273L16.2273 5.77273L13.7273 4.63636L16.2273 3.5L17.3636 1L18.5 3.5L21 4.63636L18.5 5.77273L17.3636 8.27273ZM17.3636 21L16.2273 18.5L13.7273 17.3636L16.2273 16.2273L17.3636 13.7273L18.5 16.2273L21 17.3636L18.5 18.5L17.3636 21ZM8.27273 18.2727L6 13.2727L1 11L6 8.72727L8.27273 3.72727L10.5455 8.72727L15.5455 11L10.5455 13.2727L8.27273 18.2727Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 484 B

@@ -7,4 +7,5 @@ export type ListItemProps = {
/** Bottom divider between rows — false on the final row per Figma. */
showDivider: boolean;
className?: string;
variant?: "default" | "destructive";
};
@@ -10,10 +10,15 @@ export const ListItemView = memo(function ListItemView({
onClick,
showDivider,
className = "",
variant = "default",
}: ListItemProps) {
const dividerClass = showDivider
? "border-b border-solid border-[var(--color-border-default-tertiary)]"
: "";
const contentTone =
variant === "destructive"
? "text-[var(--color-content-default-negative-primary)]"
: "text-[var(--color-content-default-primary)]";
return (
<button
@@ -22,10 +27,14 @@ export const ListItemView = memo(function ListItemView({
onClick={onClick}
className={`relative flex w-full shrink-0 cursor-pointer items-center gap-[6px] px-[4px] py-[16px] text-left hover:bg-[var(--color-surface-default-tertiary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)] ${dividerClass} ${className}`}
>
<span className="flex size-6 shrink-0 items-center justify-center overflow-visible text-[var(--color-content-default-primary)]">
<span
className={`flex size-6 shrink-0 items-center justify-center overflow-visible ${contentTone}`}
>
<Icon name={leadingIcon} size={24} />
</span>
<span className="min-w-0 flex-1 text-left font-inter text-[12px] font-normal leading-4 whitespace-normal text-[var(--color-content-default-primary)]">
<span
className={`min-w-0 flex-1 text-left font-inter text-[12px] font-normal leading-4 whitespace-normal ${contentTone}`}
>
{label}
</span>
</button>
@@ -28,6 +28,9 @@ const CreateContainer = memo<CreateProps>(
ariaLabelledBy,
backdropVariant = "default",
stepper,
kebabTriggerAriaLabel,
kebabMenuAriaLabel,
kebabMenuItems,
}) => {
const createRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
@@ -60,6 +63,9 @@ const CreateContainer = memo<CreateProps>(
overlayRef={overlayRef}
backdropVariant={backdropVariant}
stepper={stepper}
kebabTriggerAriaLabel={kebabTriggerAriaLabel}
kebabMenuAriaLabel={kebabMenuAriaLabel}
kebabMenuItems={kebabMenuItems}
/>
);
},
@@ -1,5 +1,6 @@
import type { RefObject } from "react";
import type { CreateModalBackdropVariant } from "./CreateModalFrame.view";
import type { ModalHeaderMenuItem } from "../ModalHeader/ModalHeader.types";
export interface CreateProps {
isOpen: boolean;
@@ -37,6 +38,9 @@ export interface CreateProps {
backdropVariant?: CreateModalBackdropVariant;
/** Passed through to ModalFooter; set explicitly when step visibility must not infer from steps alone. */
stepper?: boolean;
kebabTriggerAriaLabel?: string;
kebabMenuAriaLabel?: string;
kebabMenuItems?: ModalHeaderMenuItem[];
}
export interface CreateViewProps {
@@ -63,4 +67,7 @@ export interface CreateViewProps {
overlayRef: RefObject<HTMLDivElement | null>;
backdropVariant: CreateModalBackdropVariant;
stepper?: boolean;
kebabTriggerAriaLabel?: string;
kebabMenuAriaLabel?: string;
kebabMenuItems?: ModalHeaderMenuItem[];
}
+10 -1
View File
@@ -30,6 +30,9 @@ export function CreateView({
overlayRef,
backdropVariant,
stepper,
kebabTriggerAriaLabel,
kebabMenuAriaLabel,
kebabMenuItems,
}: CreateViewProps) {
return (
<CreateModalFrameView
@@ -42,7 +45,13 @@ export function CreateView({
overlayRef={overlayRef}
dialogRef={createRef}
>
<ModalHeader onClose={onClose} onMoreOptions={onClose} />
<ModalHeader
onClose={onClose}
moreOptionsAriaLabel={kebabTriggerAriaLabel}
menuAriaLabel={kebabMenuAriaLabel}
menuItems={kebabMenuItems}
showMoreOptionsButton={(kebabMenuItems?.length ?? 0) > 0}
/>
{headerContent !== undefined ? (
<div className="shrink-0">{headerContent}</div>
@@ -1,6 +1,6 @@
"use client";
import { memo } from "react";
import { memo, useEffect, useId, useRef, useState } from "react";
import { ModalHeaderView } from "./ModalHeader.view";
import type { ModalHeaderProps } from "./ModalHeader.types";
@@ -10,7 +10,55 @@ import type { ModalHeaderProps } from "./ModalHeader.types";
* (right) icon buttons.
*/
const ModalHeaderContainer = memo<ModalHeaderProps>((props) => {
return <ModalHeaderView {...props} />;
const { menuItems = [] } = props;
const hasMenu = menuItems.length > 0;
const [menuOpen, setMenuOpen] = useState(false);
const menuId = useId();
const menuWrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menuOpen || !hasMenu) return;
const onDoc = (event: MouseEvent) => {
if (
menuWrapRef.current &&
!menuWrapRef.current.contains(event.target as Node)
) {
setMenuOpen(false);
}
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [hasMenu, menuOpen]);
useEffect(() => {
if (!menuOpen || !hasMenu) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setMenuOpen(false);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [hasMenu, menuOpen]);
return (
<div ref={menuWrapRef}>
<ModalHeaderView
{...props}
menuId={menuId}
menuOpen={menuOpen}
onToggleMenu={hasMenu ? () => setMenuOpen((open) => !open) : undefined}
onMenuItemClick={
hasMenu
? (item) => {
item.onClick?.();
setMenuOpen(false);
}
: undefined
}
/>
</div>
);
});
ModalHeaderContainer.displayName = "ModalHeader";
@@ -1,3 +1,14 @@
import type { IconName } from "../../asset/icon";
export interface ModalHeaderMenuItem {
id: string;
label: string;
leadingIcon: IconName;
onClick?: () => void;
/** Kebab rows only; omit for default lockup styling. */
variant?: "default" | "destructive";
}
export interface ModalHeaderProps {
onClose?: () => void;
onMoreOptions?: () => void;
@@ -7,5 +18,11 @@ export interface ModalHeaderProps {
closeButtonAriaLabel?: string;
/** When set, used for the more-options controls accessible name (e.g. localized). */
moreOptionsAriaLabel?: string;
menuAriaLabel?: string;
menuItems?: ModalHeaderMenuItem[];
menuId?: string;
menuOpen?: boolean;
onToggleMenu?: () => void;
onMenuItemClick?: (_item: ModalHeaderMenuItem) => void;
className?: string;
}
@@ -1,3 +1,5 @@
import ListItem from "../../layout/ListItem";
import Popover from "../Popover";
import { getAssetPath } from "../../../../lib/assetUtils";
import type { ModalHeaderProps } from "./ModalHeader.types";
@@ -11,8 +13,16 @@ export function ModalHeaderView({
showMoreOptionsButton = true,
closeButtonAriaLabel = "Close dialog",
moreOptionsAriaLabel = "More options",
menuAriaLabel = "More options menu",
menuItems = [],
menuId,
menuOpen = false,
onToggleMenu,
onMenuItemClick,
className = "",
}: ModalHeaderProps) {
const hasMenu = menuItems.length > 0;
return (
<div
className={`border-b border-[var(--color-border-default-secondary)] h-[48px] shrink-0 sticky top-0 bg-[var(--color-surface-default-primary)] z-[2] ${className}`}
@@ -41,9 +51,12 @@ export function ModalHeaderView({
{showMoreOptionsButton && (
<button
type="button"
onClick={onMoreOptions}
onClick={hasMenu ? onToggleMenu : onMoreOptions}
className={`${iconButtonClass} right-[24px] top-[12px]`}
aria-label={moreOptionsAriaLabel}
aria-haspopup={hasMenu ? "menu" : undefined}
aria-expanded={hasMenu ? menuOpen : undefined}
aria-controls={hasMenu ? menuId : undefined}
>
<svg
width="16"
@@ -58,6 +71,22 @@ export function ModalHeaderView({
</svg>
</button>
)}
{showMoreOptionsButton && hasMenu && menuOpen ? (
<div className="absolute right-[24px] top-[44px] z-[300]">
<Popover id={menuId} menuAriaLabel={menuAriaLabel}>
{menuItems.map((item, index) => (
<ListItem
key={item.id}
showDivider={index < menuItems.length - 1}
leadingIcon={item.leadingIcon}
label={item.label}
variant={item.variant}
onClick={() => onMenuItemClick?.(item)}
/>
))}
</Popover>
</div>
) : null}
</div>
);
}