Skip to content
Merged
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@aymurai/ui",
"version": "0.5.0",
"version": "0.6.0",
"description": "AymurAI shared React component library, extracted from the Figma UI Library and authored with Panda CSS.",
"license": "MIT",
"type": "module",
Expand Down
6 changes: 3 additions & 3 deletions src/components/archives/ArchiveRow.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const Default: Story = {
args: {
icon: <FileIcon size={24} />,
title: "Archivonombrelargolarguisimo.doc",
description: "11 pag. - 21.5 mb",
description: "11 pag. · 21.5 mb",
trailingAction: <TrashButton />,
},
render: (args) => (
Expand Down Expand Up @@ -73,7 +73,7 @@ export const LongTitle: Story = {
args: {
icon: <FileIcon size={24} />,
title: "Un-nombre-de-archivo-extremadamente-largo-que-no-entra.docx",
description: "34 pag. - 8.1 mb",
description: "34 pag. · 8.1 mb",
trailingAction: <TrashButton />,
},
render: (args) => (
Expand Down Expand Up @@ -102,7 +102,7 @@ export const DocumentPreviewComposition: Story = {
<ArchiveRow
icon={<FileIcon size={24} />}
title="Archivonombrelargolarguisimo.doc"
description="11 pag. - 21.5 mb"
description="11 pag. · 21.5 mb"
trailingAction={<TrashButton />}
/>
</div>
Expand Down
75 changes: 75 additions & 0 deletions src/components/person-menu/PersonMenu.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { PersonMenu, type PersonMenuOption } from "./PersonMenu";

const ROLES: PersonMenuOption[] = [
{ id: "r1", initials: "JU", name: "Juez/a", color: "violet" },
{ id: "r2", initials: "FI", name: "Fiscal", color: "yellow" },
{ id: "r3", initials: "DE", name: "Defensor/a", color: "green-light" },
{ id: "r4", initials: "DN", name: "Denunciante", color: "blue" },
{ id: "r5", initials: "AC", name: "Acusado/a", color: "orange" },
];

const meta = {
title: "Components/PersonMenu",
component: PersonMenu,
parameters: {
layout: "centered",
figma: {
url: "https://www.figma.com/design/2BahKpebYzaccFih0ZB79y?node-id=40002701:44844",
},
},
args: {
options: ROLES,
onSelectOption: () => {},
footerLabel: "Nueva persona",
onFooterAction: () => {},
},
} satisfies Meta<typeof PersonMenu>;

export default meta;
type Story = StoryObj<typeof meta>;

/** Replica of node 40002701:44844. */
export const Default: Story = {};

export const WithSelection: Story = { args: { selectedIndex: 1 } };

/** All roles already used: only the footer action remains. */
export const Empty: Story = { args: { options: [] } };

/** The longest label in the real set, to check the hug width doesn't break. */
export const LongNames: Story = {
args: {
options: [
...ROLES,
{
id: "r6",
initials: "NA",
name: "Niño/a - Adolescente",
color: "red",
},
],
},
};

/** As desktop-app's SpeakerPicker uses it: a free-text name input in the footer. */
export const WithFooterSlot: Story = {
render: (args) => {
const [name, setName] = useState("");
return (
<PersonMenu
{...args}
footerSlot={
<input
aria-label="Nombre de la persona"
placeholder="Nombre de la persona"
value={name}
onChange={(e) => setName(e.target.value)}
style={{ width: "100%", padding: 8 }}
/>
}
/>
);
},
};
109 changes: 109 additions & 0 deletions src/components/person-menu/PersonMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { PersonMenu, type PersonMenuOption } from "./PersonMenu";

const OPTIONS: PersonMenuOption[] = [
{ id: "r1", initials: "DE", name: "Defensor/a", color: "green-light" },
{ id: "r2", initials: "FI", name: "Fiscal", color: "yellow" },
{ id: "r3", initials: "JU", name: "Juez/a", color: "violet" },
];

describe("PersonMenu", () => {
it("renders one button per option, named after the person", () => {
render(<PersonMenu options={OPTIONS} onSelectOption={vi.fn()} />);
expect(
screen.getByRole("button", { name: "Defensor/a" }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Fiscal" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Juez/a" })).toBeInTheDocument();
});

it("reports the index of the clicked option", () => {
const onSelectOption = vi.fn();
render(<PersonMenu options={OPTIONS} onSelectOption={onSelectOption} />);
screen.getByRole("button", { name: "Fiscal" }).click();
expect(onSelectOption).toHaveBeenCalledWith(1);
});

it("marks the selected option with aria-current", () => {
render(
<PersonMenu
options={OPTIONS}
onSelectOption={vi.fn()}
selectedIndex={2}
/>,
);
expect(screen.getByRole("button", { name: "Juez/a" })).toHaveAttribute(
"aria-current",
"true",
);
expect(screen.getByRole("button", { name: "Fiscal" })).not.toHaveAttribute(
"aria-current",
);
});

it("renders the footer action and reports clicks on it", () => {
const onFooterAction = vi.fn();
render(
<PersonMenu
options={OPTIONS}
onSelectOption={vi.fn()}
footerLabel="Nueva persona"
onFooterAction={onFooterAction}
/>,
);
screen.getByRole("button", { name: "Nueva persona" }).click();
expect(onFooterAction).toHaveBeenCalledTimes(1);
});

it("lets footerSlot replace the footer button", () => {
render(
<PersonMenu
options={OPTIONS}
onSelectOption={vi.fn()}
footerLabel="Nueva persona"
onFooterAction={vi.fn()}
footerSlot={<input aria-label="Nombre de la persona" />}
/>,
);
expect(screen.getByLabelText("Nombre de la persona")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Nueva persona" }),
).not.toBeInTheDocument();
});

it("renders only the footer when there are no options left", () => {
render(
<PersonMenu
options={[]}
onSelectOption={vi.fn()}
footerLabel="Nueva persona"
onFooterAction={vi.fn()}
/>,
);
expect(
screen.getByRole("button", { name: "Nueva persona" }),
).toBeInTheDocument();
expect(screen.getAllByRole("button")).toHaveLength(1);
});

it("renders nothing when there is neither an option nor a footer", () => {
const { container } = render(
<PersonMenu options={[]} onSelectOption={vi.fn()} />,
);
expect(container).toBeEmptyDOMElement();
});

it("labels the group so assistive tech can announce it", () => {
render(
<PersonMenu
options={OPTIONS}
onSelectOption={vi.fn()}
aria-label="Elegir persona o rol"
/>,
);
expect(
screen.getByRole("group", { name: "Elegir persona o rol" }),
).toBeInTheDocument();
});
});
140 changes: 140 additions & 0 deletions src/components/person-menu/PersonMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { PlusIcon } from "@phosphor-icons/react";
import type { ReactNode } from "react";
import { css, cx } from "@/styled/css";
import type { AvatarColor } from "../avatar";
import { AvatarPill } from "../avatar-pill";
import { Button } from "../button";

/**
* PersonMenu — floating list of people/roles to choose from, with an
* optional footer action. AymurAI UI Library node 40002701:44844
* ("single select").
*
* It's just the surface: it doesn't mount a Popover or handle opening or
* anchoring. The consumer positions it — SidePanel anchors it with a Popover
* on the "Nuevo" button; desktop-app's SpeakerPicker places it inside its
* own floating bar.
*
* Each row is wrapped in a <button> because AvatarPill renders a <span> and
* isn't focusable: in a menu that would leave the options out of keyboard
* reach.
*/
export type PersonMenuOption = {
/** Stable consumer identity; used as the React key when available. */
id?: string;
/** Avatar initials, e.g. "FI" */
initials: string;
/** Displayed name, e.g. "Fiscal" */
name: string;
color?: AvatarColor;
};

export type PersonMenuProps = {
/** Menu rows, in render order. May be empty. */
options: PersonMenuOption[];
/** Receives the index in `options` of the chosen row. */
onSelectOption: (index: number) => void;
/** Index marked as selected. */
selectedIndex?: number;
/** Footer action label; omit to skip rendering it. */
footerLabel?: string;
onFooterAction?: () => void;
/**
* Content under the rows instead of the button, when the consumer needs
* something else (e.g. a free-text name input). Takes priority over
* `footerLabel`/`onFooterAction`.
*/
footerSlot?: ReactNode;
/** Accessible label for the container. */
"aria-label"?: string;
className?: string;
};

const surface = css({
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: "1", // 4px
p: "2", // 8px
bg: "bg.secondary",
rounded: "md", // 8px
// Figma uses the shared `shadow` style (0 0 10px rgba(0,0,0,.1)); `menu`
// is the existing floating-surface token and the difference is imperceptible.
boxShadow: "menu",
});

const optionButton = css({
display: "block",
width: "full",
textAlign: "left",
borderWidth: "0",
bg: "[transparent]",
p: "0",
cursor: "pointer",
rounded: "xl", // 24px — focus ring follows the pill shape
"&:focus-visible": {
outline: "primary-alt",
outlineWidth: "[2px]",
outlineOffset: "[2px]",
},
});

// Button has no 40px size (sm=32, md=48) and centers its content; Figma
// calls for 40px height and left-aligned content.
const footerButton = css({
w: "full",
h: "10", // 40px
justifyContent: "flex-start",
});

export function PersonMenu({
options,
onSelectOption,
selectedIndex,
footerLabel,
onFooterAction,
footerSlot,
className,
"aria-label": ariaLabel = "Personas",
}: PersonMenuProps) {
const footer =
footerSlot ??
(footerLabel && onFooterAction ? (
<Button
variant="tertiary"
size="sm"
onClick={onFooterAction}
className={footerButton}
>
<PlusIcon size={16} />
{footerLabel}
</Button>
) : null);

if (options.length === 0 && !footer) return null;

return (
<div className={cx(surface, className)} role="group" aria-label={ariaLabel}>
{options.map((option, index) => (
<button
key={option.id ?? `${option.initials}-${option.name}-${index}`}
type="button"
className={optionButton}
aria-label={option.name}
aria-current={index === selectedIndex ? "true" : undefined}
onClick={() => onSelectOption(index)}
>
<AvatarPill
initials={option.initials}
name={option.name}
color={option.color}
state={index === selectedIndex ? "selected" : "default"}
/>
</button>
))}
{footer}
</div>
);
}

export default PersonMenu;
5 changes: 5 additions & 0 deletions src/components/person-menu/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {
PersonMenu,
type PersonMenuOption,
type PersonMenuProps,
} from "./PersonMenu";
Loading
Loading