Show seven cards on the four method decks and open each as a routing overlay of its parts, so groups write from empty fields with Generate draft, search the full deck, and see unselected scope chips with enough contrast.

This commit is contained in:
adilallo committed 2026-11-05 22:43:30 -07:00
1 parent dea7dd7fbd
commit 46656f52d3
28 files changed
+1349 -318

No files matched your search

@@ -1,6 +1,6 @@
"use client";
import { memo, useCallback, useState } from "react";
import { memo, useCallback, useEffect, useState } from "react";
import { useTranslation } from "../../../contexts/MessagesContext";
import { CardStackView } from "./CardStack.view";
import type { CardStackProps } from "./CardStack.types";
@@ -29,7 +29,7 @@ const CardStackContainer = memo<CardStackProps>(
title = "",
description = "",
layout = "default",
compactRecommendedLimit = 5,
compactRecommendedLimit = 7,
compactCardIds,
compactDesktopLayout: compactDesktopLayoutProp = "grid",
headerLockupSize,
@@ -39,16 +39,25 @@ const CardStackContainer = memo<CardStackProps>(
addCardLabel = "",
addCardAriaLabel = "",
onAddCard,
deckLine = "",
searchPlaceholder = "",
alternativePrefix = "",
onAlternativeSelect,
}) => {
const t = useTranslation("controlsChrome");
const [internalExpanded, setInternalExpanded] = useState(false);
const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>(
[],
);
const [searchQuery, setSearchQuery] = useState("");
const expanded =
controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
useEffect(() => {
if (!expanded) setSearchQuery("");
}, [expanded]);
const handleToggleExpand = useCallback(() => {
if (controlledOnToggleExpand) {
controlledOnToggleExpand();
@@ -102,6 +111,12 @@ const CardStackContainer = memo<CardStackProps>(
addCardLabel={addCardLabel}
addCardAriaLabel={addCardAriaLabel}
onAddCard={onAddCard}
deckLine={deckLine}
searchPlaceholder={searchPlaceholder}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
alternativePrefix={alternativePrefix}
onAlternativeSelect={onAlternativeSelect}
/>
);
},
@@ -6,6 +6,10 @@ export interface CardStackItem {
label: string;
supportText?: string;
recommended?: boolean;
/** Compact-seven why line; See all keeps `supportText`. */
compactSupportText?: string;
/** One alternative per job, shown under the compact card. */
alternative?: { id: string; label: string };
}
export interface CardStackProps {
@@ -23,9 +27,16 @@ export interface CardStackProps {
/** "default" = compact grid/column + expanded 2-col 142px tiles; "singleStack" = always one column, expand shows more in same stack */
layout?: "default" | "singleStack";
/**
* Max recommended cards in compact (non-expanded) mode. Default 5; Figma compact stack uses 3.
* Max recommended cards in compact (non-expanded) mode. Default 7.
*/
compactRecommendedLimit?: number;
/** Line above the compact seven, in place of a tag on every card. */
deckLine?: string;
/** See-all search field placeholder. */
searchPlaceholder?: string;
/** Prefix for the compact alternative row. */
alternativePrefix?: string;
onAlternativeSelect?: (id: string) => void;
/**
* Optional explicit list of card ids to render in the compact slot, in
* order. When provided, this overrides the default
@@ -78,4 +89,10 @@ export interface CardStackViewProps {
addCardLabel: string;
addCardAriaLabel: string;
onAddCard?: () => void;
deckLine: string;
searchPlaceholder: string;
searchQuery: string;
onSearchQueryChange: (value: string) => void;
alternativePrefix: string;
onAlternativeSelect?: (id: string) => void;
}
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
import HeaderLockup from "../../type/HeaderLockup";
import type { HeaderLockupSizeValue } from "../../type/HeaderLockup/HeaderLockup.types";
import Selection from "../Selection";
import type { CardStackViewProps } from "./CardStack.types";
import type { CardStackItem, CardStackViewProps } from "./CardStack.types";
/** Figma Card / CardSelection on Compact + Expanded Card Stack (`281×142`). */
const FIGMA_CARD_SELECTION_TILE_CLASS =
@@ -38,6 +38,87 @@ function CardStackHeaderLockup({
);
}
function matchesSearch(item: CardStackItem, query: string): boolean {
if (!query) return true;
const haystack = `${item.label} ${item.supportText ?? ""}`.toLowerCase();
return haystack.includes(query.toLowerCase());
}
function SearchGlyph() {
return (
<svg
aria-hidden
viewBox="0 0 24 24"
className="pointer-events-none absolute right-3 top-1/2 size-6 -translate-y-1/2 text-[var(--color-content-default-tertiary)]"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.5" />
<path
d="M20 20l-3.5-3.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
function StackCard({
item,
expanded,
selected,
size,
className,
onSelect,
onAlternativeSelect,
alternativePrefix,
}: {
item: CardStackItem;
expanded: boolean;
selected: boolean;
size: "m" | "l";
className?: string;
onSelect: (id: string) => void;
onAlternativeSelect?: (id: string) => void;
alternativePrefix: string;
}) {
const supportText =
!expanded && item.compactSupportText
? item.compactSupportText
: item.supportText;
const recommended = Boolean(expanded && item.recommended);
const alternative =
!expanded && item.alternative && onAlternativeSelect ? (
<p className="w-full text-x-small-paragraph text-[var(--color-content-invert-tertiary)]">
{alternativePrefix}{" "}
<button
type="button"
className="underline text-[var(--color-content-invert-primary)]"
onClick={() => onAlternativeSelect(item.alternative!.id)}
>
{item.alternative.label}
</button>
</p>
) : null;
return (
<div className="flex w-full flex-col gap-1">
<Selection
id={item.id}
label={item.label}
supportText={supportText}
recommended={recommended}
selected={selected}
size={size}
showInfoIcon={false}
className={className}
onClick={() => onSelect(item.id)}
/>
{alternative}
</div>
);
}
function CardStackAddCardButton({
label,
ariaLabel,
@@ -98,6 +179,12 @@ export function CardStackView({
addCardLabel,
addCardAriaLabel,
onAddCard,
deckLine,
searchPlaceholder,
searchQuery,
onSearchQueryChange,
alternativePrefix,
onAlternativeSelect,
}: CardStackViewProps) {
const addTile =
showAddCard && onAddCard && addCardLabel.length > 0 ? (
@@ -130,7 +217,9 @@ export function CardStackView({
// At lg+ the cards scroll inside this column and the See all / Show less
// control stays pinned below (Figma Flow — Right Rail).
if (layout === "singleStack") {
const displayedCards = expanded ? cards : compactCards;
const displayedCards = (expanded ? cards : compactCards).filter((item) =>
expanded ? matchesSearch(item, searchQuery) : true,
);
return (
<div
className={`flex w-full min-w-0 flex-col gap-6 lg:h-full lg:max-h-full lg:min-h-0 lg:flex-1 lg:overflow-hidden ${className}`}
@@ -142,18 +231,35 @@ export function CardStackView({
size={lockupSize}
wrapperClassName="min-w-0 shrink-0"
/>
{!expanded && deckLine ? (
<p className="shrink-0 text-x-small-paragraph text-[var(--color-content-default-tertiary)]">
{deckLine}
</p>
) : null}
{expanded && searchPlaceholder ? (
<div className="relative w-full shrink-0">
<input
type="search"
value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
className="h-10 w-full rounded-[var(--radius-200)] border border-[var(--color-border-default-control)] bg-[var(--color-surface-default-secondary)] px-3 py-2.5 pr-11 text-medium-paragraph text-[var(--color-content-default-primary)] placeholder:text-[var(--color-content-default-tertiary)] outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)]"
/>
<SearchGlyph />
</div>
) : null}
<div className="flex w-full min-w-0 flex-col gap-2 lg:min-h-0 lg:flex-1 lg:overflow-y-auto scrollbar-design">
{displayedCards.map((item) => (
<Selection
<StackCard
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
item={item}
expanded={expanded}
selected={isSelected(item.id)}
size="l"
showInfoIcon={false}
onClick={() => onCardSelect(item.id)}
onSelect={onCardSelect}
onAlternativeSelect={onAlternativeSelect}
alternativePrefix={alternativePrefix}
/>
))}
{addTile}
+1 -1
View File
@@ -72,7 +72,7 @@ function chipSurfaceStyle(
return paint(
"transparent",
"var(--color-content-default-brand-primary)",
"var(--color-border-default-control)",
"var(--color-content-default-tertiary)",
);
}