diff --git a/package.json b/package.json index 45245d1..cb923a2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/archives/ArchiveRow.stories.tsx b/src/components/archives/ArchiveRow.stories.tsx index 25700dd..1005a0e 100644 --- a/src/components/archives/ArchiveRow.stories.tsx +++ b/src/components/archives/ArchiveRow.stories.tsx @@ -33,7 +33,7 @@ export const Default: Story = { args: { icon: , title: "Archivonombrelargolarguisimo.doc", - description: "11 pag. - 21.5 mb", + description: "11 pag. · 21.5 mb", trailingAction: , }, render: (args) => ( @@ -73,7 +73,7 @@ export const LongTitle: Story = { args: { icon: , title: "Un-nombre-de-archivo-extremadamente-largo-que-no-entra.docx", - description: "34 pag. - 8.1 mb", + description: "34 pag. · 8.1 mb", trailingAction: , }, render: (args) => ( @@ -102,7 +102,7 @@ export const DocumentPreviewComposition: Story = { } title="Archivonombrelargolarguisimo.doc" - description="11 pag. - 21.5 mb" + description="11 pag. · 21.5 mb" trailingAction={} /> diff --git a/src/components/person-menu/PersonMenu.stories.tsx b/src/components/person-menu/PersonMenu.stories.tsx new file mode 100644 index 0000000..436777a --- /dev/null +++ b/src/components/person-menu/PersonMenu.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +/** 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 ( + setName(e.target.value)} + style={{ width: "100%", padding: 8 }} + /> + } + /> + ); + }, +}; diff --git a/src/components/person-menu/PersonMenu.test.tsx b/src/components/person-menu/PersonMenu.test.tsx new file mode 100644 index 0000000..ebaa3fd --- /dev/null +++ b/src/components/person-menu/PersonMenu.test.tsx @@ -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(); + 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(); + screen.getByRole("button", { name: "Fiscal" }).click(); + expect(onSelectOption).toHaveBeenCalledWith(1); + }); + + it("marks the selected option with aria-current", () => { + render( + , + ); + 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( + , + ); + screen.getByRole("button", { name: "Nueva persona" }).click(); + expect(onFooterAction).toHaveBeenCalledTimes(1); + }); + + it("lets footerSlot replace the footer button", () => { + render( + } + />, + ); + 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( + , + ); + 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( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("labels the group so assistive tech can announce it", () => { + render( + , + ); + expect( + screen.getByRole("group", { name: "Elegir persona o rol" }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/person-menu/PersonMenu.tsx b/src/components/person-menu/PersonMenu.tsx new file mode 100644 index 0000000..9b8ae57 --- /dev/null +++ b/src/components/person-menu/PersonMenu.tsx @@ -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 + ) : null); + + if (options.length === 0 && !footer) return null; + + return ( +
+ {options.map((option, index) => ( + + ))} + {footer} +
+ ); +} + +export default PersonMenu; diff --git a/src/components/person-menu/index.ts b/src/components/person-menu/index.ts new file mode 100644 index 0000000..206165b --- /dev/null +++ b/src/components/person-menu/index.ts @@ -0,0 +1,5 @@ +export { + PersonMenu, + type PersonMenuOption, + type PersonMenuProps, +} from "./PersonMenu"; diff --git a/src/components/side-panel/SidePanel.stories.tsx b/src/components/side-panel/SidePanel.stories.tsx index 8f5bf45..ef920c0 100644 --- a/src/components/side-panel/SidePanel.stories.tsx +++ b/src/components/side-panel/SidePanel.stories.tsx @@ -1,7 +1,15 @@ import type { Meta, StoryObj } from "@storybook/react"; import { useState } from "react"; import { TooltipProvider } from "../tooltip"; -import { SidePanel } from "./SidePanel"; +import { SidePanel, type SidePanelPerson } from "./SidePanel"; + +const ROLE_OPTIONS: SidePanelPerson[] = [ + { 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/SidePanel", @@ -27,11 +35,7 @@ type Story = StoryObj; const PEOPLE = [ { initials: "AB", name: "Persona 1", color: "violet" as const }, { initials: "AB", name: "Persona 2", color: "green" as const }, - { initials: "JU", name: "Jueza", color: "red" as const }, - { initials: "FI", name: "Fiscal", color: "yellow" as const }, - { initials: "DE", name: "Defensor", color: "pink" as const }, - { initials: "AB", name: "Imputado", color: "orange" as const }, - { initials: "DE", name: "Defensor 2", color: "green-light" as const }, + { initials: "AB", name: "Persona 3", color: "orange" as const }, ]; const RENAMEABLE_PEOPLE = [ @@ -75,6 +79,8 @@ export const Default: Story = { onSelectPerson={setSelected} timestamp={time} onTimestampChange={setTime} + newPersonOptions={ROLE_OPTIONS} + onSelectNewPersonOption={() => {}} /> ); }, @@ -100,6 +106,8 @@ export const Sizes: Story = { selectedIndex={selected} onSelectPerson={setSelected} timestamp="01:15" + newPersonOptions={ROLE_OPTIONS} + onSelectNewPersonOption={() => {}} /> ))} @@ -125,6 +133,8 @@ export const NarrowContainer: Story = { }} people={PEOPLE} timestamp="01:15" + newPersonOptions={ROLE_OPTIONS} + onSelectNewPersonOption={() => {}} /> ), @@ -150,10 +160,12 @@ export const MergeConfirmation: Story = { selectedIndex={selected} onSelectPerson={setSelected} timestamp="01:15" - previousTurnName="Fiscal" - nextTurnName="Defensor" - onMergePrevious={() => window.alert("Unido con Fiscal")} - onMergeNext={() => window.alert("Unido con Defensor")} + previousTurnName="Persona 2" + nextTurnName="Persona 3" + onMergePrevious={() => window.alert("Unido con Persona 2")} + onMergeNext={() => window.alert("Unido con Persona 3")} + newPersonOptions={ROLE_OPTIONS} + onSelectNewPersonOption={() => {}} /> ); }, @@ -174,6 +186,8 @@ export const InvalidTimestamp: Story = { people={PEOPLE} timestamp={time} onTimestampChange={setTime} + newPersonOptions={ROLE_OPTIONS} + onSelectNewPersonOption={() => {}} /> ); }, @@ -227,3 +241,30 @@ export const RenameAndCollision: Story = { ); }, }; + +/** Replica of node 40002701:44829, with the "Nuevo" dropdown available. */ +export const WithNewPersonMenu: Story = { + args: { + people: [ + { id: "s1", initials: "P1", name: "Persona 1", renamable: true }, + { id: "s2", initials: "P2", name: "Persona 2", renamable: true }, + { id: "s3", initials: "P3", name: "Persona 3", renamable: true }, + ], + selectedIndex: 0, + timestamp: "01:15", + turn: { initials: "P1", name: "Persona 1", time: "01:15", color: "violet" }, + newPersonOptions: ROLE_OPTIONS, + onSelectNewPersonOption: () => {}, + }, +}; + +/** Without `newPersonOptions`: "Nuevo" calls onNewPerson directly (backwards-compatible). */ +export const WithoutNewPersonMenu: Story = { + args: { + people: [{ id: "s1", initials: "P1", name: "Persona 1", renamable: true }], + timestamp: "01:15", + turn: { initials: "P1", name: "Persona 1", time: "01:15", color: "violet" }, + newPersonOptions: undefined, + onNewPerson: () => window.alert("onNewPerson"), + }, +}; diff --git a/src/components/side-panel/SidePanel.test.tsx b/src/components/side-panel/SidePanel.test.tsx new file mode 100644 index 0000000..d289bbc --- /dev/null +++ b/src/components/side-panel/SidePanel.test.tsx @@ -0,0 +1,111 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "../tooltip"; +import { SidePanel, type SidePanelPerson } from "./SidePanel"; + +// Radix measures its floating surfaces with ResizeObserver, which jsdom lacks. +beforeAll(() => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + Element.prototype.hasPointerCapture = () => false; + Element.prototype.setPointerCapture = () => {}; + Element.prototype.releasePointerCapture = () => {}; + Element.prototype.scrollIntoView = () => {}; +}); + +const PEOPLE: SidePanelPerson[] = [ + { id: "s1", initials: "P1", name: "Persona 1", renamable: true }, + { id: "s2", initials: "P2", name: "Persona 2", renamable: true }, +]; + +function renderPanel( + props: Partial> = {}, +) { + return render( + + + , + ); +} + +describe("SidePanel people section", () => { + it("titles the section 'Personas identificadas'", () => { + renderPanel(); + expect(screen.getByText("Personas identificadas")).toBeInTheDocument(); + }); + + it("does not title it 'Personas sugeridas' anymore", () => { + renderPanel(); + expect(screen.queryByText("Personas sugeridas")).not.toBeInTheDocument(); + }); +}); + +const ROLES: SidePanelPerson[] = [ + { id: "r1", initials: "JU", name: "Juez/a" }, + { id: "r2", initials: "FI", name: "Fiscal" }, +]; + +describe("SidePanel new-person menu", () => { + it("calls onNewPerson directly when there are no options", async () => { + const onNewPerson = vi.fn(); + renderPanel({ onNewPerson }); + await userEvent.click(screen.getByRole("button", { name: /Nuevo/ })); + expect(onNewPerson).toHaveBeenCalledTimes(1); + expect( + screen.queryByRole("button", { name: "Fiscal" }), + ).not.toBeInTheDocument(); + }); + + it("opens a menu with the roles when options are provided", async () => { + renderPanel({ newPersonOptions: ROLES, onSelectNewPersonOption: vi.fn() }); + expect( + screen.queryByRole("button", { name: "Fiscal" }), + ).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /Nuevo/ })); + expect(screen.getByRole("button", { name: "Juez/a" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Fiscal" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Nueva persona" }), + ).toBeInTheDocument(); + }); + + it("reports the chosen role index and closes the menu", async () => { + const onSelectNewPersonOption = vi.fn(); + renderPanel({ newPersonOptions: ROLES, onSelectNewPersonOption }); + await userEvent.click(screen.getByRole("button", { name: /Nuevo/ })); + await userEvent.click(screen.getByRole("button", { name: "Fiscal" })); + expect(onSelectNewPersonOption).toHaveBeenCalledWith(1); + expect( + screen.queryByRole("button", { name: "Fiscal" }), + ).not.toBeInTheDocument(); + }); + + it("routes the footer action to onNewPerson and closes the menu", async () => { + const onNewPerson = vi.fn(); + renderPanel({ + newPersonOptions: ROLES, + onSelectNewPersonOption: vi.fn(), + onNewPerson, + }); + await userEvent.click(screen.getByRole("button", { name: /Nuevo/ })); + await userEvent.click( + screen.getByRole("button", { name: "Nueva persona" }), + ); + expect(onNewPerson).toHaveBeenCalledTimes(1); + expect( + screen.queryByRole("button", { name: "Nueva persona" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/side-panel/SidePanel.tsx b/src/components/side-panel/SidePanel.tsx index a1f4779..e9af1dc 100644 --- a/src/components/side-panel/SidePanel.tsx +++ b/src/components/side-panel/SidePanel.tsx @@ -14,6 +14,8 @@ import { DialogDescription, DialogTitle, } from "../dialog"; +import { PersonMenu } from "../person-menu"; +import { Popover, PopoverContent, PopoverTrigger } from "../popover"; import { TextField } from "../text-field"; import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip"; @@ -30,7 +32,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip"; * * Composite assembled from {@link AvatarPill}, {@link TextField}, * {@link Button}, {@link Dialog} and {@link Tooltip}. Sections: selected - * turn card, suggested people, timestamp, and turn actions (merge + * turn card, identified people, timestamp, and turn actions (merge * previous/next, add below, delete). * * Merge actions use the Phosphor "ArrowsMergeIcon" glyph: base points down @@ -78,7 +80,19 @@ export type SidePanelProps = { /** Index of the selected pill in `people` */ selectedIndex?: number; onSelectPerson?: (index: number) => void; + /** + * With `newPersonOptions`, this is the handler for the "Nueva persona" + * action in the menu's footer. Without options, it's the "Nuevo" button's + * direct click handler, as before. + */ onNewPerson?: () => void; + /** + * Options for the "Nuevo" dropdown (e.g. suggested roles). Empty or + * omitted → "Nuevo" calls `onNewPerson` directly, as before. + */ + newPersonOptions?: SidePanelPerson[]; + /** Receives the index in `newPersonOptions` of the chosen option. */ + onSelectNewPersonOption?: (index: number) => void; /** Persists a non-conflicting speaker rename. Set with `onMergePeople` to enable editing. */ onRenamePerson?: (index: number, nextName: string) => void; /** Merges the edited source identity into the existing target identity. Set with `onRenamePerson`. */ @@ -160,6 +174,16 @@ const pills = css({ gap: "2", // 8px w: "full", }); +// Figma node 40002701:44839 ("Pills"): two 40px rows with 8px gap — pills +// on top (wrapping if needed), the "Nuevo" button always below, never +// stuck to the end of the last pill row. +const peopleGroup = css({ + ...stack.raw({ gap: "2" }), // 8px + w: "full", +}); +// Figma node 40002701:45247 shows the button at 40px; Button has no such +// size (sm=32, md=48). +const newButton = css({ h: "10" }); const actions = css({ ...stack.raw({ gap: "4" }), w: "full" }); // 16px const fullWidthButton = css({ w: "full" }); const divider = css({ @@ -276,6 +300,8 @@ export function SidePanel({ selectedIndex, onSelectPerson, onNewPerson, + newPersonOptions, + onSelectNewPersonOption, onRenamePerson, onMergePeople, timestamp, @@ -290,6 +316,7 @@ export function SidePanel({ className, }: SidePanelProps) { const [confirm, setConfirm] = useState(null); + const [menuOpen, setMenuOpen] = useState(false); const [editingIndex, setEditingIndex] = useState(null); const [editValue, setEditValue] = useState(""); const [renameConflict, setRenameConflict] = @@ -393,49 +420,92 @@ export function SidePanel({ - {/* Suggested people */} -
-
- {people.map((person, index) => { - const isEditing = editingIndex === index; - const canRename = - person.renamable && onRenamePerson && onMergePeople; + {/* Identified people */} +
+
+
+ {people.map((person, index) => { + const isEditing = editingIndex === index; + const canRename = + person.renamable && onRenamePerson && onMergePeople; - return ( - - onSelectPerson?.(index)} - onRename={canRename ? () => startEditing(index) : undefined} - editValue={isEditing ? editValue : undefined} - onEditValueChange={isEditing ? setEditValue : undefined} - onEditCommit={ - isEditing - ? (value) => handleRenameCommit(index, value) - : undefined + return ( + - - ); - })} - + className={css({ display: "inline-flex" })} + > + onSelectPerson?.(index)} + onRename={canRename ? () => startEditing(index) : undefined} + editValue={isEditing ? editValue : undefined} + onEditValueChange={isEditing ? setEditValue : undefined} + onEditCommit={ + isEditing + ? (value) => handleRenameCommit(index, value) + : undefined + } + onEditCancel={isEditing ? finishEditing : undefined} + renameInputLabel={`Editar nombre de ${person.name}`} + /> + + ); + })} +
+
+ {newPersonOptions && newPersonOptions.length > 0 ? ( + + + + + + { + setMenuOpen(false); + onSelectNewPersonOption?.(index); + }} + footerLabel="Nueva persona" + onFooterAction={() => { + setMenuOpen(false); + onNewPerson?.(); + }} + /> + + + ) : ( + + )} +