From fe4e31f8fc41796556e266f4a51b31c4225a548d Mon Sep 17 00:00:00 2001 From: Dominic da Costa Date: Wed, 5 Aug 2026 10:08:18 +0200 Subject: [PATCH 01/34] feat(ui): install stepper primitive from radix-vue [FE-2977] Wraps radix-vue's Stepper primitive family (Root/Item/Trigger/ Indicator/Title/Description/Separator) following the house convention (.vue + .ce.vue + config.ts + types.ts + index.ts), and adds a steps-array-driven Stepper composite. Barrelled from src/index.ts. --- src/index.ts | 1 + src/ui/stepper/Stepper.ce.vue | 104 ++++++++++++++++++++++++++ src/ui/stepper/Stepper.vue | 33 ++++++++ src/ui/stepper/StepperDescription.vue | 31 ++++++++ src/ui/stepper/StepperIndicator.vue | 38 ++++++++++ src/ui/stepper/StepperItem.vue | 32 ++++++++ src/ui/stepper/StepperSeparator.vue | 35 +++++++++ src/ui/stepper/StepperTitle.vue | 30 ++++++++ src/ui/stepper/StepperTrigger.vue | 35 +++++++++ src/ui/stepper/index.ts | 9 +++ src/ui/stepper/stepper.config.ts | 67 +++++++++++++++++ src/ui/stepper/types.ts | 40 ++++++++++ 12 files changed, 455 insertions(+) create mode 100644 src/ui/stepper/Stepper.ce.vue create mode 100644 src/ui/stepper/Stepper.vue create mode 100644 src/ui/stepper/StepperDescription.vue create mode 100644 src/ui/stepper/StepperIndicator.vue create mode 100644 src/ui/stepper/StepperItem.vue create mode 100644 src/ui/stepper/StepperSeparator.vue create mode 100644 src/ui/stepper/StepperTitle.vue create mode 100644 src/ui/stepper/StepperTrigger.vue create mode 100644 src/ui/stepper/index.ts create mode 100644 src/ui/stepper/stepper.config.ts create mode 100644 src/ui/stepper/types.ts diff --git a/src/index.ts b/src/index.ts index 367233f40c..d921c75863 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,6 +51,7 @@ export * from "./ui/separator"; export * from "./ui/skeleton"; export * from "./ui/sonner"; export * from "./ui/spinner"; +export * from "./ui/stepper"; export * from "./ui/switch"; export * from "./ui/tabs"; export * from "./ui/tabs"; diff --git a/src/ui/stepper/Stepper.ce.vue b/src/ui/stepper/Stepper.ce.vue new file mode 100644 index 0000000000..be2e36c6a1 --- /dev/null +++ b/src/ui/stepper/Stepper.ce.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/ui/stepper/Stepper.vue b/src/ui/stepper/Stepper.vue new file mode 100644 index 0000000000..47b0ea6f73 --- /dev/null +++ b/src/ui/stepper/Stepper.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/ui/stepper/StepperDescription.vue b/src/ui/stepper/StepperDescription.vue new file mode 100644 index 0000000000..f685475995 --- /dev/null +++ b/src/ui/stepper/StepperDescription.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/ui/stepper/StepperIndicator.vue b/src/ui/stepper/StepperIndicator.vue new file mode 100644 index 0000000000..01145a7faf --- /dev/null +++ b/src/ui/stepper/StepperIndicator.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/ui/stepper/StepperItem.vue b/src/ui/stepper/StepperItem.vue new file mode 100644 index 0000000000..030d04c011 --- /dev/null +++ b/src/ui/stepper/StepperItem.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/ui/stepper/StepperSeparator.vue b/src/ui/stepper/StepperSeparator.vue new file mode 100644 index 0000000000..7dc3b7986f --- /dev/null +++ b/src/ui/stepper/StepperSeparator.vue @@ -0,0 +1,35 @@ + + + diff --git a/src/ui/stepper/StepperTitle.vue b/src/ui/stepper/StepperTitle.vue new file mode 100644 index 0000000000..c2f0515be3 --- /dev/null +++ b/src/ui/stepper/StepperTitle.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/ui/stepper/StepperTrigger.vue b/src/ui/stepper/StepperTrigger.vue new file mode 100644 index 0000000000..06b8a80598 --- /dev/null +++ b/src/ui/stepper/StepperTrigger.vue @@ -0,0 +1,35 @@ + + + diff --git a/src/ui/stepper/index.ts b/src/ui/stepper/index.ts new file mode 100644 index 0000000000..fe426e70f3 --- /dev/null +++ b/src/ui/stepper/index.ts @@ -0,0 +1,9 @@ +export { default as StepperRoot } from "./Stepper.vue"; +export { default as StepperItem } from "./StepperItem.vue"; +export { default as StepperTrigger } from "./StepperTrigger.vue"; +export { default as StepperIndicator } from "./StepperIndicator.vue"; +export { default as StepperTitle } from "./StepperTitle.vue"; +export { default as StepperDescription } from "./StepperDescription.vue"; +export { default as StepperSeparator } from "./StepperSeparator.vue"; +export { default as Stepper } from "./Stepper.ce.vue"; +export { type StepperProps, type StepperStepProps } from "./types"; diff --git a/src/ui/stepper/stepper.config.ts b/src/ui/stepper/stepper.config.ts new file mode 100644 index 0000000000..ed4d0d0ff2 --- /dev/null +++ b/src/ui/stepper/stepper.config.ts @@ -0,0 +1,67 @@ +import { cva } from "class-variance-authority"; + +export const rootVariants = cva("flex gap-2", { + variants: { + orientation: { + horizontal: "w-full items-start", + vertical: "flex-col" + } + }, + defaultVariants: { + orientation: "horizontal" + } +}); + +export const itemVariants = cva("group flex gap-2", { + variants: { + orientation: { + horizontal: "flex-1 flex-col items-center text-center", + vertical: "w-full items-start text-left" + } + }, + defaultVariants: { + orientation: "horizontal" + } +}); + +export const triggerVariants = cva("flex gap-2 rounded-lg p-1", { + variants: { + orientation: { + horizontal: "flex-col items-center text-center", + vertical: "flex-row items-center text-left" + } + }, + defaultVariants: { + orientation: "horizontal" + } +}); + +export const separatorVariants = cva( + "bg-control-default group-data-[state=completed]:bg-control-checked group-data-[disabled]:opacity-50", + { + variants: { + orientation: { + horizontal: "mt-5 h-px flex-1", + vertical: "ml-5 h-8 w-px" + } + }, + defaultVariants: { + orientation: "horizontal" + } + } +); + +export default { + stepper: { + root: rootVariants, + item: itemVariants, + trigger: triggerVariants, + indicator: cva( + "bg-control-surface text-control-foreground shadow-control-default group-data-[state=active]:bg-control-checked group-data-[state=active]:text-control-checked-contrast group-data-[state=active]:shadow-control-checked group-data-[state=completed]:bg-control-checked group-data-[state=completed]:text-control-checked-contrast flex size-10 shrink-0 items-center justify-center rounded-full text-sm font-medium transition-colors duration-200 group-data-[disabled]:opacity-50" + ), + content: cva("flex flex-col gap-0.5"), + title: cva("text-display text-sm font-semibold"), + description: cva("text-muted text-xs"), + separator: separatorVariants + } +}; diff --git a/src/ui/stepper/types.ts b/src/ui/stepper/types.ts new file mode 100644 index 0000000000..147ec50cce --- /dev/null +++ b/src/ui/stepper/types.ts @@ -0,0 +1,40 @@ +import type { IconProps } from "../icon"; +import type { CxOptions } from "class-variance-authority"; +import type { StepperRootProps } from "radix-vue"; +import type { HTMLAttributes } from "vue"; + +export type StepperStepProps = { + step: number; + title?: string; + description?: string; + icon?: IconProps["icon"]; + disabled?: boolean; + completed?: boolean; +}; + +export type StepperProps = Omit< + StepperRootProps, + "modelValue" | "defaultValue" | "orientation" +> & { + modelValue?: number; + defaultValue?: number; + // --- state + steps: StepperStepProps[]; + // --- variants + orientation?: "horizontal" | "vertical"; + // --- styles + uiConfig?: { + stepper: { + root?: CxOptions; + item?: CxOptions; + trigger?: CxOptions; + indicator?: CxOptions; + content?: CxOptions; + title?: CxOptions; + description?: CxOptions; + separator?: CxOptions; + }; + }; + class?: HTMLAttributes["class"]; + itemClass?: HTMLAttributes["class"]; +}; From f1abbc3de870e69bea502fb236ebe2c0501be306 Mon Sep 17 00:00:00 2001 From: Dominic da Costa Date: Wed, 5 Aug 2026 10:08:18 +0200 Subject: [PATCH 02/34] feat(ui): install stepper primitive from radix-vue [FE-2977] Wraps radix-vue's Stepper primitive family (Root/Item/Trigger/ Indicator/Title/Description/Separator) following the house convention (.vue + .ce.vue + config.ts + types.ts + index.ts), and adds a steps-array-driven Stepper composite. Barrelled from src/index.ts. --- src/index.ts | 1 + src/ui/stepper/Stepper.ce.vue | 104 ++++++++++++++++++++++++++ src/ui/stepper/Stepper.vue | 33 ++++++++ src/ui/stepper/StepperDescription.vue | 31 ++++++++ src/ui/stepper/StepperIndicator.vue | 38 ++++++++++ src/ui/stepper/StepperItem.vue | 32 ++++++++ src/ui/stepper/StepperSeparator.vue | 35 +++++++++ src/ui/stepper/StepperTitle.vue | 30 ++++++++ src/ui/stepper/StepperTrigger.vue | 35 +++++++++ src/ui/stepper/index.ts | 9 +++ src/ui/stepper/stepper.config.ts | 67 +++++++++++++++++ src/ui/stepper/types.ts | 40 ++++++++++ 12 files changed, 455 insertions(+) create mode 100644 src/ui/stepper/Stepper.ce.vue create mode 100644 src/ui/stepper/Stepper.vue create mode 100644 src/ui/stepper/StepperDescription.vue create mode 100644 src/ui/stepper/StepperIndicator.vue create mode 100644 src/ui/stepper/StepperItem.vue create mode 100644 src/ui/stepper/StepperSeparator.vue create mode 100644 src/ui/stepper/StepperTitle.vue create mode 100644 src/ui/stepper/StepperTrigger.vue create mode 100644 src/ui/stepper/index.ts create mode 100644 src/ui/stepper/stepper.config.ts create mode 100644 src/ui/stepper/types.ts diff --git a/src/index.ts b/src/index.ts index 367233f40c..d921c75863 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,6 +51,7 @@ export * from "./ui/separator"; export * from "./ui/skeleton"; export * from "./ui/sonner"; export * from "./ui/spinner"; +export * from "./ui/stepper"; export * from "./ui/switch"; export * from "./ui/tabs"; export * from "./ui/tabs"; diff --git a/src/ui/stepper/Stepper.ce.vue b/src/ui/stepper/Stepper.ce.vue new file mode 100644 index 0000000000..be2e36c6a1 --- /dev/null +++ b/src/ui/stepper/Stepper.ce.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/ui/stepper/Stepper.vue b/src/ui/stepper/Stepper.vue new file mode 100644 index 0000000000..47b0ea6f73 --- /dev/null +++ b/src/ui/stepper/Stepper.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/ui/stepper/StepperDescription.vue b/src/ui/stepper/StepperDescription.vue new file mode 100644 index 0000000000..f685475995 --- /dev/null +++ b/src/ui/stepper/StepperDescription.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/ui/stepper/StepperIndicator.vue b/src/ui/stepper/StepperIndicator.vue new file mode 100644 index 0000000000..01145a7faf --- /dev/null +++ b/src/ui/stepper/StepperIndicator.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/ui/stepper/StepperItem.vue b/src/ui/stepper/StepperItem.vue new file mode 100644 index 0000000000..030d04c011 --- /dev/null +++ b/src/ui/stepper/StepperItem.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/ui/stepper/StepperSeparator.vue b/src/ui/stepper/StepperSeparator.vue new file mode 100644 index 0000000000..7dc3b7986f --- /dev/null +++ b/src/ui/stepper/StepperSeparator.vue @@ -0,0 +1,35 @@ + + + diff --git a/src/ui/stepper/StepperTitle.vue b/src/ui/stepper/StepperTitle.vue new file mode 100644 index 0000000000..c2f0515be3 --- /dev/null +++ b/src/ui/stepper/StepperTitle.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/ui/stepper/StepperTrigger.vue b/src/ui/stepper/StepperTrigger.vue new file mode 100644 index 0000000000..06b8a80598 --- /dev/null +++ b/src/ui/stepper/StepperTrigger.vue @@ -0,0 +1,35 @@ + + + diff --git a/src/ui/stepper/index.ts b/src/ui/stepper/index.ts new file mode 100644 index 0000000000..fe426e70f3 --- /dev/null +++ b/src/ui/stepper/index.ts @@ -0,0 +1,9 @@ +export { default as StepperRoot } from "./Stepper.vue"; +export { default as StepperItem } from "./StepperItem.vue"; +export { default as StepperTrigger } from "./StepperTrigger.vue"; +export { default as StepperIndicator } from "./StepperIndicator.vue"; +export { default as StepperTitle } from "./StepperTitle.vue"; +export { default as StepperDescription } from "./StepperDescription.vue"; +export { default as StepperSeparator } from "./StepperSeparator.vue"; +export { default as Stepper } from "./Stepper.ce.vue"; +export { type StepperProps, type StepperStepProps } from "./types"; diff --git a/src/ui/stepper/stepper.config.ts b/src/ui/stepper/stepper.config.ts new file mode 100644 index 0000000000..ed4d0d0ff2 --- /dev/null +++ b/src/ui/stepper/stepper.config.ts @@ -0,0 +1,67 @@ +import { cva } from "class-variance-authority"; + +export const rootVariants = cva("flex gap-2", { + variants: { + orientation: { + horizontal: "w-full items-start", + vertical: "flex-col" + } + }, + defaultVariants: { + orientation: "horizontal" + } +}); + +export const itemVariants = cva("group flex gap-2", { + variants: { + orientation: { + horizontal: "flex-1 flex-col items-center text-center", + vertical: "w-full items-start text-left" + } + }, + defaultVariants: { + orientation: "horizontal" + } +}); + +export const triggerVariants = cva("flex gap-2 rounded-lg p-1", { + variants: { + orientation: { + horizontal: "flex-col items-center text-center", + vertical: "flex-row items-center text-left" + } + }, + defaultVariants: { + orientation: "horizontal" + } +}); + +export const separatorVariants = cva( + "bg-control-default group-data-[state=completed]:bg-control-checked group-data-[disabled]:opacity-50", + { + variants: { + orientation: { + horizontal: "mt-5 h-px flex-1", + vertical: "ml-5 h-8 w-px" + } + }, + defaultVariants: { + orientation: "horizontal" + } + } +); + +export default { + stepper: { + root: rootVariants, + item: itemVariants, + trigger: triggerVariants, + indicator: cva( + "bg-control-surface text-control-foreground shadow-control-default group-data-[state=active]:bg-control-checked group-data-[state=active]:text-control-checked-contrast group-data-[state=active]:shadow-control-checked group-data-[state=completed]:bg-control-checked group-data-[state=completed]:text-control-checked-contrast flex size-10 shrink-0 items-center justify-center rounded-full text-sm font-medium transition-colors duration-200 group-data-[disabled]:opacity-50" + ), + content: cva("flex flex-col gap-0.5"), + title: cva("text-display text-sm font-semibold"), + description: cva("text-muted text-xs"), + separator: separatorVariants + } +}; diff --git a/src/ui/stepper/types.ts b/src/ui/stepper/types.ts new file mode 100644 index 0000000000..147ec50cce --- /dev/null +++ b/src/ui/stepper/types.ts @@ -0,0 +1,40 @@ +import type { IconProps } from "../icon"; +import type { CxOptions } from "class-variance-authority"; +import type { StepperRootProps } from "radix-vue"; +import type { HTMLAttributes } from "vue"; + +export type StepperStepProps = { + step: number; + title?: string; + description?: string; + icon?: IconProps["icon"]; + disabled?: boolean; + completed?: boolean; +}; + +export type StepperProps = Omit< + StepperRootProps, + "modelValue" | "defaultValue" | "orientation" +> & { + modelValue?: number; + defaultValue?: number; + // --- state + steps: StepperStepProps[]; + // --- variants + orientation?: "horizontal" | "vertical"; + // --- styles + uiConfig?: { + stepper: { + root?: CxOptions; + item?: CxOptions; + trigger?: CxOptions; + indicator?: CxOptions; + content?: CxOptions; + title?: CxOptions; + description?: CxOptions; + separator?: CxOptions; + }; + }; + class?: HTMLAttributes["class"]; + itemClass?: HTMLAttributes["class"]; +}; From 2aacca7bcb914b064a94e65fc6489cad73addf60 Mon Sep 17 00:00:00 2001 From: Dominic da Costa Date: Wed, 5 Aug 2026 10:08:36 +0200 Subject: [PATCH 03/34] feat(ui): install context-menu primitive from radix-vue [FE-2977] Wraps radix-vue's ContextMenu primitive family, mirroring the existing dropdown-menu composite (design tokens, Portal + `to` target, items-array-driven ContextMenu.ce.vue). Backs the action-slot `context-menu` placement (wired in labs-nuxt separately). Barrelled from src/index.ts. --- src/index.ts | 1 + src/ui/context-menu/ContextMenu.ce.vue | 103 ++++++++++++++++++ src/ui/context-menu/ContextMenu.vue | 19 ++++ .../context-menu/ContextMenuCheckboxItem.vue | 44 ++++++++ src/ui/context-menu/ContextMenuContent.vue | 47 ++++++++ src/ui/context-menu/ContextMenuGroup.vue | 11 ++ src/ui/context-menu/ContextMenuItem.vue | 36 ++++++ src/ui/context-menu/ContextMenuLabel.vue | 32 ++++++ src/ui/context-menu/ContextMenuRadioGroup.vue | 19 ++++ src/ui/context-menu/ContextMenuRadioItem.vue | 45 ++++++++ src/ui/context-menu/ContextMenuSeparator.vue | 27 +++++ src/ui/context-menu/ContextMenuShortcut.vue | 14 +++ src/ui/context-menu/ContextMenuSub.vue | 19 ++++ src/ui/context-menu/ContextMenuSubContent.vue | 37 +++++++ src/ui/context-menu/ContextMenuSubTrigger.vue | 41 +++++++ src/ui/context-menu/ContextMenuTrigger.vue | 17 +++ src/ui/context-menu/context-menu.config.ts | 14 +++ src/ui/context-menu/index.ts | 17 +++ src/ui/context-menu/types.ts | 46 ++++++++ 19 files changed, 589 insertions(+) create mode 100644 src/ui/context-menu/ContextMenu.ce.vue create mode 100644 src/ui/context-menu/ContextMenu.vue create mode 100644 src/ui/context-menu/ContextMenuCheckboxItem.vue create mode 100644 src/ui/context-menu/ContextMenuContent.vue create mode 100644 src/ui/context-menu/ContextMenuGroup.vue create mode 100644 src/ui/context-menu/ContextMenuItem.vue create mode 100644 src/ui/context-menu/ContextMenuLabel.vue create mode 100644 src/ui/context-menu/ContextMenuRadioGroup.vue create mode 100644 src/ui/context-menu/ContextMenuRadioItem.vue create mode 100644 src/ui/context-menu/ContextMenuSeparator.vue create mode 100644 src/ui/context-menu/ContextMenuShortcut.vue create mode 100644 src/ui/context-menu/ContextMenuSub.vue create mode 100644 src/ui/context-menu/ContextMenuSubContent.vue create mode 100644 src/ui/context-menu/ContextMenuSubTrigger.vue create mode 100644 src/ui/context-menu/ContextMenuTrigger.vue create mode 100644 src/ui/context-menu/context-menu.config.ts create mode 100644 src/ui/context-menu/index.ts create mode 100644 src/ui/context-menu/types.ts diff --git a/src/index.ts b/src/index.ts index d921c75863..b980916fc6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export * from "./ui/button-group"; export * from "./ui/card"; export * from "./ui/carousel"; export * from "./ui/checkbox"; +export * from "./ui/context-menu"; export * from "./ui/checkbox-cards"; export * from "./ui/checkbox-group"; export * from "./ui/combobox"; diff --git a/src/ui/context-menu/ContextMenu.ce.vue b/src/ui/context-menu/ContextMenu.ce.vue new file mode 100644 index 0000000000..7a21ac81a0 --- /dev/null +++ b/src/ui/context-menu/ContextMenu.ce.vue @@ -0,0 +1,103 @@ + + + diff --git a/src/ui/context-menu/ContextMenu.vue b/src/ui/context-menu/ContextMenu.vue new file mode 100644 index 0000000000..a692c7d4d0 --- /dev/null +++ b/src/ui/context-menu/ContextMenu.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/ui/context-menu/ContextMenuCheckboxItem.vue b/src/ui/context-menu/ContextMenuCheckboxItem.vue new file mode 100644 index 0000000000..7a597cfd44 --- /dev/null +++ b/src/ui/context-menu/ContextMenuCheckboxItem.vue @@ -0,0 +1,44 @@ + + + diff --git a/src/ui/context-menu/ContextMenuContent.vue b/src/ui/context-menu/ContextMenuContent.vue new file mode 100644 index 0000000000..6a240622cc --- /dev/null +++ b/src/ui/context-menu/ContextMenuContent.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/ui/context-menu/ContextMenuGroup.vue b/src/ui/context-menu/ContextMenuGroup.vue new file mode 100644 index 0000000000..ee2a1e1bbf --- /dev/null +++ b/src/ui/context-menu/ContextMenuGroup.vue @@ -0,0 +1,11 @@ + + + diff --git a/src/ui/context-menu/ContextMenuItem.vue b/src/ui/context-menu/ContextMenuItem.vue new file mode 100644 index 0000000000..1882a3baf1 --- /dev/null +++ b/src/ui/context-menu/ContextMenuItem.vue @@ -0,0 +1,36 @@ + + + diff --git a/src/ui/context-menu/ContextMenuLabel.vue b/src/ui/context-menu/ContextMenuLabel.vue new file mode 100644 index 0000000000..2921e90395 --- /dev/null +++ b/src/ui/context-menu/ContextMenuLabel.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/ui/context-menu/ContextMenuRadioGroup.vue b/src/ui/context-menu/ContextMenuRadioGroup.vue new file mode 100644 index 0000000000..41853c45a6 --- /dev/null +++ b/src/ui/context-menu/ContextMenuRadioGroup.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/ui/context-menu/ContextMenuRadioItem.vue b/src/ui/context-menu/ContextMenuRadioItem.vue new file mode 100644 index 0000000000..33caffcc86 --- /dev/null +++ b/src/ui/context-menu/ContextMenuRadioItem.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/ui/context-menu/ContextMenuSeparator.vue b/src/ui/context-menu/ContextMenuSeparator.vue new file mode 100644 index 0000000000..a8ab026b42 --- /dev/null +++ b/src/ui/context-menu/ContextMenuSeparator.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/ui/context-menu/ContextMenuShortcut.vue b/src/ui/context-menu/ContextMenuShortcut.vue new file mode 100644 index 0000000000..d6fce88453 --- /dev/null +++ b/src/ui/context-menu/ContextMenuShortcut.vue @@ -0,0 +1,14 @@ + + + diff --git a/src/ui/context-menu/ContextMenuSub.vue b/src/ui/context-menu/ContextMenuSub.vue new file mode 100644 index 0000000000..8c7047748f --- /dev/null +++ b/src/ui/context-menu/ContextMenuSub.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/ui/context-menu/ContextMenuSubContent.vue b/src/ui/context-menu/ContextMenuSubContent.vue new file mode 100644 index 0000000000..e6021eaf18 --- /dev/null +++ b/src/ui/context-menu/ContextMenuSubContent.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/ui/context-menu/ContextMenuSubTrigger.vue b/src/ui/context-menu/ContextMenuSubTrigger.vue new file mode 100644 index 0000000000..5c08c0e578 --- /dev/null +++ b/src/ui/context-menu/ContextMenuSubTrigger.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/ui/context-menu/ContextMenuTrigger.vue b/src/ui/context-menu/ContextMenuTrigger.vue new file mode 100644 index 0000000000..151908874b --- /dev/null +++ b/src/ui/context-menu/ContextMenuTrigger.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/ui/context-menu/context-menu.config.ts b/src/ui/context-menu/context-menu.config.ts new file mode 100644 index 0000000000..9f16784c12 --- /dev/null +++ b/src/ui/context-menu/context-menu.config.ts @@ -0,0 +1,14 @@ +import { cva } from "class-variance-authority"; + +export const contentVariants = cva( + `bg-control-surface text-display data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 shadow-control-default control-radius z-50 min-w-48 border-none p-0` +); + +export default { + contextMenu: { + content: contentVariants, + label: cva("border-control-default border-b px-5 py-3 text-sm font-medium"), + group: cva("p-2"), + item: cva("[[data-highlighted]_&]:bg-button-ghost-hover font-normal") + } +}; diff --git a/src/ui/context-menu/index.ts b/src/ui/context-menu/index.ts new file mode 100644 index 0000000000..a224666938 --- /dev/null +++ b/src/ui/context-menu/index.ts @@ -0,0 +1,17 @@ +export { ContextMenuPortal } from "radix-vue"; +export { default as ContextMenuRoot } from "./ContextMenu.vue"; +export { default as ContextMenuTrigger } from "./ContextMenuTrigger.vue"; +export { default as ContextMenuContent } from "./ContextMenuContent.vue"; +export { default as ContextMenuGroup } from "./ContextMenuGroup.vue"; +export { default as ContextMenuRadioGroup } from "./ContextMenuRadioGroup.vue"; +export { default as ContextMenuItem } from "./ContextMenuItem.vue"; +export { default as ContextMenuCheckboxItem } from "./ContextMenuCheckboxItem.vue"; +export { default as ContextMenuRadioItem } from "./ContextMenuRadioItem.vue"; +export { default as ContextMenuShortcut } from "./ContextMenuShortcut.vue"; +export { default as ContextMenuSeparator } from "./ContextMenuSeparator.vue"; +export { default as ContextMenuLabel } from "./ContextMenuLabel.vue"; +export { default as ContextMenuSub } from "./ContextMenuSub.vue"; +export { default as ContextMenuSubTrigger } from "./ContextMenuSubTrigger.vue"; +export { default as ContextMenuSubContent } from "./ContextMenuSubContent.vue"; +export { default as ContextMenu } from "./ContextMenu.ce.vue"; +export { type ContextMenuProps, type ContextMenuItemProps } from "./types"; diff --git a/src/ui/context-menu/types.ts b/src/ui/context-menu/types.ts new file mode 100644 index 0000000000..342656b484 --- /dev/null +++ b/src/ui/context-menu/types.ts @@ -0,0 +1,46 @@ +import type { PortalTarget } from "../../utils"; +import type { AvatarProps } from "../avatar"; +import type { ButtonProps } from "../button"; +import type { IconProps } from "../icon"; +import type { CxOptions } from "class-variance-authority"; +import type { + ContextMenuContentProps, + ContextMenuRootProps, + ContextMenuTriggerProps +} from "radix-vue"; +import type { HTMLAttributes } from "vue"; + +export type ContextMenuItemProps = { + label: string; + value: string; + icon?: IconProps["icon"]; + avatar?: Partial; + handler?: (...args: unknown[]) => unknown; + disabled?: boolean; + hidden?: boolean; + dataAttrs?: Record<`data-${string}`, string | number | boolean>; + class?: HTMLAttributes["class"]; +}; + +export type ContextMenuProps = ContextMenuRootProps & + ContextMenuContentProps & + Pick & { + title?: string; + label?: string; + to?: PortalTarget; + // --- state + items: ContextMenuItemProps[]; + // --- variants + size?: ButtonProps["size"]; + // --- styles + uiConfig?: { + contextMenu: { + content?: CxOptions; + item?: CxOptions; + label?: CxOptions; + group?: CxOptions; + }; + }; + class?: HTMLAttributes["class"]; + popoverClass?: HTMLAttributes["class"]; + }; From fce0fbe26200cec7a1804a12a8b9b731e7d88f74 Mon Sep 17 00:00:00 2001 From: Dominic da Costa Date: Wed, 5 Aug 2026 10:08:36 +0200 Subject: [PATCH 04/34] feat(ui): install context-menu primitive from radix-vue [FE-2977] Wraps radix-vue's ContextMenu primitive family, mirroring the existing dropdown-menu composite (design tokens, Portal + `to` target, items-array-driven ContextMenu.ce.vue). Backs the action-slot `context-menu` placement (wired in labs-nuxt separately). Barrelled from src/index.ts. --- src/index.ts | 1 + src/ui/context-menu/ContextMenu.ce.vue | 103 ++++++++++++++++++ src/ui/context-menu/ContextMenu.vue | 19 ++++ .../context-menu/ContextMenuCheckboxItem.vue | 44 ++++++++ src/ui/context-menu/ContextMenuContent.vue | 47 ++++++++ src/ui/context-menu/ContextMenuGroup.vue | 11 ++ src/ui/context-menu/ContextMenuItem.vue | 36 ++++++ src/ui/context-menu/ContextMenuLabel.vue | 32 ++++++ src/ui/context-menu/ContextMenuRadioGroup.vue | 19 ++++ src/ui/context-menu/ContextMenuRadioItem.vue | 45 ++++++++ src/ui/context-menu/ContextMenuSeparator.vue | 27 +++++ src/ui/context-menu/ContextMenuShortcut.vue | 14 +++ src/ui/context-menu/ContextMenuSub.vue | 19 ++++ src/ui/context-menu/ContextMenuSubContent.vue | 37 +++++++ src/ui/context-menu/ContextMenuSubTrigger.vue | 41 +++++++ src/ui/context-menu/ContextMenuTrigger.vue | 17 +++ src/ui/context-menu/context-menu.config.ts | 14 +++ src/ui/context-menu/index.ts | 17 +++ src/ui/context-menu/types.ts | 46 ++++++++ 19 files changed, 589 insertions(+) create mode 100644 src/ui/context-menu/ContextMenu.ce.vue create mode 100644 src/ui/context-menu/ContextMenu.vue create mode 100644 src/ui/context-menu/ContextMenuCheckboxItem.vue create mode 100644 src/ui/context-menu/ContextMenuContent.vue create mode 100644 src/ui/context-menu/ContextMenuGroup.vue create mode 100644 src/ui/context-menu/ContextMenuItem.vue create mode 100644 src/ui/context-menu/ContextMenuLabel.vue create mode 100644 src/ui/context-menu/ContextMenuRadioGroup.vue create mode 100644 src/ui/context-menu/ContextMenuRadioItem.vue create mode 100644 src/ui/context-menu/ContextMenuSeparator.vue create mode 100644 src/ui/context-menu/ContextMenuShortcut.vue create mode 100644 src/ui/context-menu/ContextMenuSub.vue create mode 100644 src/ui/context-menu/ContextMenuSubContent.vue create mode 100644 src/ui/context-menu/ContextMenuSubTrigger.vue create mode 100644 src/ui/context-menu/ContextMenuTrigger.vue create mode 100644 src/ui/context-menu/context-menu.config.ts create mode 100644 src/ui/context-menu/index.ts create mode 100644 src/ui/context-menu/types.ts diff --git a/src/index.ts b/src/index.ts index d921c75863..b980916fc6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export * from "./ui/button-group"; export * from "./ui/card"; export * from "./ui/carousel"; export * from "./ui/checkbox"; +export * from "./ui/context-menu"; export * from "./ui/checkbox-cards"; export * from "./ui/checkbox-group"; export * from "./ui/combobox"; diff --git a/src/ui/context-menu/ContextMenu.ce.vue b/src/ui/context-menu/ContextMenu.ce.vue new file mode 100644 index 0000000000..7a21ac81a0 --- /dev/null +++ b/src/ui/context-menu/ContextMenu.ce.vue @@ -0,0 +1,103 @@ + + + diff --git a/src/ui/context-menu/ContextMenu.vue b/src/ui/context-menu/ContextMenu.vue new file mode 100644 index 0000000000..a692c7d4d0 --- /dev/null +++ b/src/ui/context-menu/ContextMenu.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/ui/context-menu/ContextMenuCheckboxItem.vue b/src/ui/context-menu/ContextMenuCheckboxItem.vue new file mode 100644 index 0000000000..7a597cfd44 --- /dev/null +++ b/src/ui/context-menu/ContextMenuCheckboxItem.vue @@ -0,0 +1,44 @@ + + + diff --git a/src/ui/context-menu/ContextMenuContent.vue b/src/ui/context-menu/ContextMenuContent.vue new file mode 100644 index 0000000000..6a240622cc --- /dev/null +++ b/src/ui/context-menu/ContextMenuContent.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/ui/context-menu/ContextMenuGroup.vue b/src/ui/context-menu/ContextMenuGroup.vue new file mode 100644 index 0000000000..ee2a1e1bbf --- /dev/null +++ b/src/ui/context-menu/ContextMenuGroup.vue @@ -0,0 +1,11 @@ + + + diff --git a/src/ui/context-menu/ContextMenuItem.vue b/src/ui/context-menu/ContextMenuItem.vue new file mode 100644 index 0000000000..1882a3baf1 --- /dev/null +++ b/src/ui/context-menu/ContextMenuItem.vue @@ -0,0 +1,36 @@ + + + diff --git a/src/ui/context-menu/ContextMenuLabel.vue b/src/ui/context-menu/ContextMenuLabel.vue new file mode 100644 index 0000000000..2921e90395 --- /dev/null +++ b/src/ui/context-menu/ContextMenuLabel.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/ui/context-menu/ContextMenuRadioGroup.vue b/src/ui/context-menu/ContextMenuRadioGroup.vue new file mode 100644 index 0000000000..41853c45a6 --- /dev/null +++ b/src/ui/context-menu/ContextMenuRadioGroup.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/ui/context-menu/ContextMenuRadioItem.vue b/src/ui/context-menu/ContextMenuRadioItem.vue new file mode 100644 index 0000000000..33caffcc86 --- /dev/null +++ b/src/ui/context-menu/ContextMenuRadioItem.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/ui/context-menu/ContextMenuSeparator.vue b/src/ui/context-menu/ContextMenuSeparator.vue new file mode 100644 index 0000000000..a8ab026b42 --- /dev/null +++ b/src/ui/context-menu/ContextMenuSeparator.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/ui/context-menu/ContextMenuShortcut.vue b/src/ui/context-menu/ContextMenuShortcut.vue new file mode 100644 index 0000000000..d6fce88453 --- /dev/null +++ b/src/ui/context-menu/ContextMenuShortcut.vue @@ -0,0 +1,14 @@ + + + diff --git a/src/ui/context-menu/ContextMenuSub.vue b/src/ui/context-menu/ContextMenuSub.vue new file mode 100644 index 0000000000..8c7047748f --- /dev/null +++ b/src/ui/context-menu/ContextMenuSub.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/ui/context-menu/ContextMenuSubContent.vue b/src/ui/context-menu/ContextMenuSubContent.vue new file mode 100644 index 0000000000..e6021eaf18 --- /dev/null +++ b/src/ui/context-menu/ContextMenuSubContent.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/ui/context-menu/ContextMenuSubTrigger.vue b/src/ui/context-menu/ContextMenuSubTrigger.vue new file mode 100644 index 0000000000..5c08c0e578 --- /dev/null +++ b/src/ui/context-menu/ContextMenuSubTrigger.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/ui/context-menu/ContextMenuTrigger.vue b/src/ui/context-menu/ContextMenuTrigger.vue new file mode 100644 index 0000000000..151908874b --- /dev/null +++ b/src/ui/context-menu/ContextMenuTrigger.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/ui/context-menu/context-menu.config.ts b/src/ui/context-menu/context-menu.config.ts new file mode 100644 index 0000000000..9f16784c12 --- /dev/null +++ b/src/ui/context-menu/context-menu.config.ts @@ -0,0 +1,14 @@ +import { cva } from "class-variance-authority"; + +export const contentVariants = cva( + `bg-control-surface text-display data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 shadow-control-default control-radius z-50 min-w-48 border-none p-0` +); + +export default { + contextMenu: { + content: contentVariants, + label: cva("border-control-default border-b px-5 py-3 text-sm font-medium"), + group: cva("p-2"), + item: cva("[[data-highlighted]_&]:bg-button-ghost-hover font-normal") + } +}; diff --git a/src/ui/context-menu/index.ts b/src/ui/context-menu/index.ts new file mode 100644 index 0000000000..a224666938 --- /dev/null +++ b/src/ui/context-menu/index.ts @@ -0,0 +1,17 @@ +export { ContextMenuPortal } from "radix-vue"; +export { default as ContextMenuRoot } from "./ContextMenu.vue"; +export { default as ContextMenuTrigger } from "./ContextMenuTrigger.vue"; +export { default as ContextMenuContent } from "./ContextMenuContent.vue"; +export { default as ContextMenuGroup } from "./ContextMenuGroup.vue"; +export { default as ContextMenuRadioGroup } from "./ContextMenuRadioGroup.vue"; +export { default as ContextMenuItem } from "./ContextMenuItem.vue"; +export { default as ContextMenuCheckboxItem } from "./ContextMenuCheckboxItem.vue"; +export { default as ContextMenuRadioItem } from "./ContextMenuRadioItem.vue"; +export { default as ContextMenuShortcut } from "./ContextMenuShortcut.vue"; +export { default as ContextMenuSeparator } from "./ContextMenuSeparator.vue"; +export { default as ContextMenuLabel } from "./ContextMenuLabel.vue"; +export { default as ContextMenuSub } from "./ContextMenuSub.vue"; +export { default as ContextMenuSubTrigger } from "./ContextMenuSubTrigger.vue"; +export { default as ContextMenuSubContent } from "./ContextMenuSubContent.vue"; +export { default as ContextMenu } from "./ContextMenu.ce.vue"; +export { type ContextMenuProps, type ContextMenuItemProps } from "./types"; diff --git a/src/ui/context-menu/types.ts b/src/ui/context-menu/types.ts new file mode 100644 index 0000000000..342656b484 --- /dev/null +++ b/src/ui/context-menu/types.ts @@ -0,0 +1,46 @@ +import type { PortalTarget } from "../../utils"; +import type { AvatarProps } from "../avatar"; +import type { ButtonProps } from "../button"; +import type { IconProps } from "../icon"; +import type { CxOptions } from "class-variance-authority"; +import type { + ContextMenuContentProps, + ContextMenuRootProps, + ContextMenuTriggerProps +} from "radix-vue"; +import type { HTMLAttributes } from "vue"; + +export type ContextMenuItemProps = { + label: string; + value: string; + icon?: IconProps["icon"]; + avatar?: Partial; + handler?: (...args: unknown[]) => unknown; + disabled?: boolean; + hidden?: boolean; + dataAttrs?: Record<`data-${string}`, string | number | boolean>; + class?: HTMLAttributes["class"]; +}; + +export type ContextMenuProps = ContextMenuRootProps & + ContextMenuContentProps & + Pick & { + title?: string; + label?: string; + to?: PortalTarget; + // --- state + items: ContextMenuItemProps[]; + // --- variants + size?: ButtonProps["size"]; + // --- styles + uiConfig?: { + contextMenu: { + content?: CxOptions; + item?: CxOptions; + label?: CxOptions; + group?: CxOptions; + }; + }; + class?: HTMLAttributes["class"]; + popoverClass?: HTMLAttributes["class"]; + }; From 51a3373d4652069ca68cba3d0682f3e150f28b70 Mon Sep 17 00:00:00 2001 From: Dominic da Costa Date: Wed, 5 Aug 2026 10:24:53 +0200 Subject: [PATCH 05/34] test(ui): mount + barrel-resolution proof for stepper and context-menu [FE-2977] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 18 (AC4): prove both new primitives mount and resolve from the package barrel (src/index.ts), not just their local module. Imports are deferred behind a canvas-getContext stub (icon-animated/lottie-web probes canvas support at barrel-import time; unrelated to this pair) so the real barrel loads under vitest+jsdom. context-menu: barrel resolution + a real right-click open/reveal round-trip on the raw radix-vue primitives — green. stepper: barrel resolution passes, but mounting the data-driven Stepper convenience component with real `steps` data is red — `StepperTitle`/`StepperDescription` throw "Injection Symbol(StepperItemContext) not found" (not nested under StepperItem), and StepperSeparator warns "missing template or render function". Genuine capability gaps, not test artifacts. --- .../__tests__/context-menu.spec.ts | 84 +++++++++++++++++++ src/ui/stepper/__tests__/stepper.spec.ts | 67 +++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 src/ui/context-menu/__tests__/context-menu.spec.ts create mode 100644 src/ui/stepper/__tests__/stepper.spec.ts diff --git a/src/ui/context-menu/__tests__/context-menu.spec.ts b/src/ui/context-menu/__tests__/context-menu.spec.ts new file mode 100644 index 0000000000..c9bae3feb7 --- /dev/null +++ b/src/ui/context-menu/__tests__/context-menu.spec.ts @@ -0,0 +1,84 @@ +import { mount, type VueWrapper } from "@vue/test-utils"; +import { describe, it, expect, afterEach, beforeAll } from "vitest"; +import { defineComponent, h } from "vue"; +import type { ContextMenuItemProps } from "../../../index"; + +let barrel: typeof import("../../../index"); + +beforeAll(async () => { + // lottie-web (icon-animated's dep) probes canvas support at import time; + // jsdom has no native 2D context, so a bare barrel import throws before + // any test runs. Stub only what that probe touches, then import for real. + HTMLCanvasElement.prototype.getContext = (() => ({ + fillStyle: "", + fillRect: () => {} + })) as unknown as typeof HTMLCanvasElement.prototype.getContext; + + barrel = await import("../../../index"); +}); + +describe("context-menu", () => { + let wrapper: VueWrapper | undefined; + + afterEach(() => { + wrapper?.unmount(); + wrapper = undefined; + }); + + it("resolves the context-menu family from the package barrel", () => { + expect(barrel.ContextMenu).toBeTruthy(); + expect(barrel.ContextMenuRoot).toBeTruthy(); + expect(barrel.ContextMenuTrigger).toBeTruthy(); + expect(barrel.ContextMenuPortal).toBeTruthy(); + expect(barrel.ContextMenuContent).toBeTruthy(); + expect(barrel.ContextMenuItem).toBeTruthy(); + }); + + it("reveals its item only once the trigger receives a contextmenu event", async () => { + const Host = defineComponent({ + setup() { + return () => + h(barrel.ContextMenuRoot, null, { + default: () => [ + h(barrel.ContextMenuTrigger, null, { + default: () => "Right click me" + }), + h(barrel.ContextMenuPortal, null, { + default: () => + h(barrel.ContextMenuContent, null, { + default: () => + h( + barrel.ContextMenuItem, + { value: "resend" }, + { default: () => "Resend verification" } + ) + }) + }) + ] + }); + } + }); + + wrapper = mount(Host, { attachTo: document.body }); + + expect(document.body.textContent).not.toContain("Resend verification"); + + await wrapper.get("span").trigger("contextmenu"); + + expect(document.body.textContent).toContain("Resend verification"); + }); + + it("mounts the data-driven convenience component from its items prop", () => { + const items: ContextMenuItemProps[] = [ + { label: "Resend verification", value: "resend" }, + { label: "Delete", value: "delete" } + ]; + + wrapper = mount(barrel.ContextMenu, { + props: { items }, + attachTo: document.body + }); + + expect(wrapper.exists()).toBe(true); + }); +}); diff --git a/src/ui/stepper/__tests__/stepper.spec.ts b/src/ui/stepper/__tests__/stepper.spec.ts new file mode 100644 index 0000000000..62c695af2c --- /dev/null +++ b/src/ui/stepper/__tests__/stepper.spec.ts @@ -0,0 +1,67 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, beforeAll } from "vitest"; +import type { StepperStepProps } from "../../../index"; + +let barrel: typeof import("../../../index"); + +beforeAll(async () => { + // lottie-web (icon-animated's dep) probes canvas support at import time; + // jsdom has no native 2D context, so a bare barrel import throws before + // any test runs. Stub only what that probe touches, then import for real. + HTMLCanvasElement.prototype.getContext = (() => ({ + fillStyle: "", + fillRect: () => {} + })) as unknown as typeof HTMLCanvasElement.prototype.getContext; + + barrel = await import("../../../index"); +}); + +describe("stepper", () => { + it("resolves the stepper family from the package barrel", () => { + expect(barrel.Stepper).toBeTruthy(); + expect(barrel.StepperRoot).toBeTruthy(); + expect(barrel.StepperItem).toBeTruthy(); + expect(barrel.StepperTrigger).toBeTruthy(); + expect(barrel.StepperIndicator).toBeTruthy(); + expect(barrel.StepperTitle).toBeTruthy(); + expect(barrel.StepperDescription).toBeTruthy(); + expect(barrel.StepperSeparator).toBeTruthy(); + }); + + it("mounts and renders every step's title and description", () => { + const steps: StepperStepProps[] = [ + { step: 1, title: "Details", description: "Enter your details" }, + { step: 2, title: "Payment", description: "Add a payment method" }, + { step: 3, title: "Confirm", description: "Review and confirm" } + ]; + + const wrapper = mount(barrel.Stepper, { + props: { steps, modelValue: 2 } + }); + + const text = wrapper.text(); + + steps.forEach(step => { + expect(text).toContain(step.title); + expect(text).toContain(step.description); + }); + }); + + it("renders one title and one description component per step", () => { + const steps: StepperStepProps[] = [ + { step: 1, title: "Details", description: "Enter your details" }, + { step: 2, title: "Payment", description: "Add a payment method" } + ]; + + const wrapper = mount(barrel.Stepper, { + props: { steps, modelValue: 1 } + }); + + expect(wrapper.findAllComponents(barrel.StepperTitle)).toHaveLength( + steps.length + ); + expect(wrapper.findAllComponents(barrel.StepperDescription)).toHaveLength( + steps.length + ); + }); +}); From 331eb8dcf0be446a90ccfe98985e21e784842e0b Mon Sep 17 00:00:00 2001 From: Dominic da Costa Date: Wed, 5 Aug 2026 10:24:53 +0200 Subject: [PATCH 06/34] test(ui): mount + barrel-resolution proof for stepper and context-menu [FE-2977] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 18 (AC4): prove both new primitives mount and resolve from the package barrel (src/index.ts), not just their local module. Imports are deferred behind a canvas-getContext stub (icon-animated/lottie-web probes canvas support at barrel-import time; unrelated to this pair) so the real barrel loads under vitest+jsdom. context-menu: barrel resolution + a real right-click open/reveal round-trip on the raw radix-vue primitives — green. stepper: barrel resolution passes, but mounting the data-driven Stepper convenience component with real `steps` data is red — `StepperTitle`/`StepperDescription` throw "Injection Symbol(StepperItemContext) not found" (not nested under StepperItem), and StepperSeparator warns "missing template or render function". Genuine capability gaps, not test artifacts. --- .../__tests__/context-menu.spec.ts | 84 +++++++++++++++++++ src/ui/stepper/__tests__/stepper.spec.ts | 67 +++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 src/ui/context-menu/__tests__/context-menu.spec.ts create mode 100644 src/ui/stepper/__tests__/stepper.spec.ts diff --git a/src/ui/context-menu/__tests__/context-menu.spec.ts b/src/ui/context-menu/__tests__/context-menu.spec.ts new file mode 100644 index 0000000000..c9bae3feb7 --- /dev/null +++ b/src/ui/context-menu/__tests__/context-menu.spec.ts @@ -0,0 +1,84 @@ +import { mount, type VueWrapper } from "@vue/test-utils"; +import { describe, it, expect, afterEach, beforeAll } from "vitest"; +import { defineComponent, h } from "vue"; +import type { ContextMenuItemProps } from "../../../index"; + +let barrel: typeof import("../../../index"); + +beforeAll(async () => { + // lottie-web (icon-animated's dep) probes canvas support at import time; + // jsdom has no native 2D context, so a bare barrel import throws before + // any test runs. Stub only what that probe touches, then import for real. + HTMLCanvasElement.prototype.getContext = (() => ({ + fillStyle: "", + fillRect: () => {} + })) as unknown as typeof HTMLCanvasElement.prototype.getContext; + + barrel = await import("../../../index"); +}); + +describe("context-menu", () => { + let wrapper: VueWrapper | undefined; + + afterEach(() => { + wrapper?.unmount(); + wrapper = undefined; + }); + + it("resolves the context-menu family from the package barrel", () => { + expect(barrel.ContextMenu).toBeTruthy(); + expect(barrel.ContextMenuRoot).toBeTruthy(); + expect(barrel.ContextMenuTrigger).toBeTruthy(); + expect(barrel.ContextMenuPortal).toBeTruthy(); + expect(barrel.ContextMenuContent).toBeTruthy(); + expect(barrel.ContextMenuItem).toBeTruthy(); + }); + + it("reveals its item only once the trigger receives a contextmenu event", async () => { + const Host = defineComponent({ + setup() { + return () => + h(barrel.ContextMenuRoot, null, { + default: () => [ + h(barrel.ContextMenuTrigger, null, { + default: () => "Right click me" + }), + h(barrel.ContextMenuPortal, null, { + default: () => + h(barrel.ContextMenuContent, null, { + default: () => + h( + barrel.ContextMenuItem, + { value: "resend" }, + { default: () => "Resend verification" } + ) + }) + }) + ] + }); + } + }); + + wrapper = mount(Host, { attachTo: document.body }); + + expect(document.body.textContent).not.toContain("Resend verification"); + + await wrapper.get("span").trigger("contextmenu"); + + expect(document.body.textContent).toContain("Resend verification"); + }); + + it("mounts the data-driven convenience component from its items prop", () => { + const items: ContextMenuItemProps[] = [ + { label: "Resend verification", value: "resend" }, + { label: "Delete", value: "delete" } + ]; + + wrapper = mount(barrel.ContextMenu, { + props: { items }, + attachTo: document.body + }); + + expect(wrapper.exists()).toBe(true); + }); +}); diff --git a/src/ui/stepper/__tests__/stepper.spec.ts b/src/ui/stepper/__tests__/stepper.spec.ts new file mode 100644 index 0000000000..62c695af2c --- /dev/null +++ b/src/ui/stepper/__tests__/stepper.spec.ts @@ -0,0 +1,67 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, beforeAll } from "vitest"; +import type { StepperStepProps } from "../../../index"; + +let barrel: typeof import("../../../index"); + +beforeAll(async () => { + // lottie-web (icon-animated's dep) probes canvas support at import time; + // jsdom has no native 2D context, so a bare barrel import throws before + // any test runs. Stub only what that probe touches, then import for real. + HTMLCanvasElement.prototype.getContext = (() => ({ + fillStyle: "", + fillRect: () => {} + })) as unknown as typeof HTMLCanvasElement.prototype.getContext; + + barrel = await import("../../../index"); +}); + +describe("stepper", () => { + it("resolves the stepper family from the package barrel", () => { + expect(barrel.Stepper).toBeTruthy(); + expect(barrel.StepperRoot).toBeTruthy(); + expect(barrel.StepperItem).toBeTruthy(); + expect(barrel.StepperTrigger).toBeTruthy(); + expect(barrel.StepperIndicator).toBeTruthy(); + expect(barrel.StepperTitle).toBeTruthy(); + expect(barrel.StepperDescription).toBeTruthy(); + expect(barrel.StepperSeparator).toBeTruthy(); + }); + + it("mounts and renders every step's title and description", () => { + const steps: StepperStepProps[] = [ + { step: 1, title: "Details", description: "Enter your details" }, + { step: 2, title: "Payment", description: "Add a payment method" }, + { step: 3, title: "Confirm", description: "Review and confirm" } + ]; + + const wrapper = mount(barrel.Stepper, { + props: { steps, modelValue: 2 } + }); + + const text = wrapper.text(); + + steps.forEach(step => { + expect(text).toContain(step.title); + expect(text).toContain(step.description); + }); + }); + + it("renders one title and one description component per step", () => { + const steps: StepperStepProps[] = [ + { step: 1, title: "Details", description: "Enter your details" }, + { step: 2, title: "Payment", description: "Add a payment method" } + ]; + + const wrapper = mount(barrel.Stepper, { + props: { steps, modelValue: 1 } + }); + + expect(wrapper.findAllComponents(barrel.StepperTitle)).toHaveLength( + steps.length + ); + expect(wrapper.findAllComponents(barrel.StepperDescription)).toHaveLength( + steps.length + ); + }); +}); From 38841c2f03d5abc7833e7cd1904eca42714691ee Mon Sep 17 00:00:00 2001 From: Dominic da Costa Date: Wed, 5 Aug 2026 10:41:10 +0200 Subject: [PATCH 07/34] fix(ui): nest StepperSeparator inside StepperItem [FE-2977] radix-vue's StepperSeparator injects StepperItemContext to derive its data-state; as a sibling of StepperItem it has no providing ancestor and throws on mount for any multi-step render. --- src/ui/stepper/Stepper.ce.vue | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ui/stepper/Stepper.ce.vue b/src/ui/stepper/Stepper.ce.vue index be2e36c6a1..dce8939171 100644 --- a/src/ui/stepper/Stepper.ce.vue +++ b/src/ui/stepper/Stepper.ce.vue @@ -38,12 +38,12 @@ - - + + From f8a15fbcd2b0379d9d87de47b1cc0abac9257699 Mon Sep 17 00:00:00 2001 From: Dominic da Costa Date: Wed, 5 Aug 2026 10:41:10 +0200 Subject: [PATCH 08/34] fix(ui): nest StepperSeparator inside StepperItem [FE-2977] radix-vue's StepperSeparator injects StepperItemContext to derive its data-state; as a sibling of StepperItem it has no providing ancestor and throws on mount for any multi-step render. --- src/ui/stepper/Stepper.ce.vue | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ui/stepper/Stepper.ce.vue b/src/ui/stepper/Stepper.ce.vue index be2e36c6a1..dce8939171 100644 --- a/src/ui/stepper/Stepper.ce.vue +++ b/src/ui/stepper/Stepper.ce.vue @@ -38,12 +38,12 @@ - - + + From 799ed8c88959f184b571e0af36cc84bf041e0558 Mon Sep 17 00:00:00 2001 From: Dominic da Costa Date: Wed, 5 Aug 2026 11:02:16 +0200 Subject: [PATCH 09/34] test(ui): prove context-menu convenience wrapper's reason for existing [FE-2977] Replace the wrapper.exists()-only assertion on ContextMenu with real behaviour proofs (per-item handler on select, hidden-item exclusion, items-prop rendering) and add the stepper analog (per-item disabled forwarding) so the wrapper's data-driven mapping is actually verified, not just its mount. --- .../__tests__/context-menu.spec.ts | 54 +++++++++++++++++-- src/ui/stepper/__tests__/stepper.spec.ts | 24 +++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/ui/context-menu/__tests__/context-menu.spec.ts b/src/ui/context-menu/__tests__/context-menu.spec.ts index c9bae3feb7..2e97459e76 100644 --- a/src/ui/context-menu/__tests__/context-menu.spec.ts +++ b/src/ui/context-menu/__tests__/context-menu.spec.ts @@ -1,5 +1,5 @@ import { mount, type VueWrapper } from "@vue/test-utils"; -import { describe, it, expect, afterEach, beforeAll } from "vitest"; +import { describe, it, expect, afterEach, beforeAll, vi } from "vitest"; import { defineComponent, h } from "vue"; import type { ContextMenuItemProps } from "../../../index"; @@ -68,17 +68,63 @@ describe("context-menu", () => { expect(document.body.textContent).toContain("Resend verification"); }); - it("mounts the data-driven convenience component from its items prop", () => { + it("renders every visible item's label from the items prop", async () => { const items: ContextMenuItemProps[] = [ { label: "Resend verification", value: "resend" }, { label: "Delete", value: "delete" } ]; wrapper = mount(barrel.ContextMenu, { - props: { items }, + props: { items, label: "Actions" }, attachTo: document.body }); - expect(wrapper.exists()).toBe(true); + await wrapper.get("span").trigger("contextmenu"); + + expect(document.body.textContent).toContain("Resend verification"); + expect(document.body.textContent).toContain("Delete"); + expect(wrapper.findAllComponents(barrel.ContextMenuItem)).toHaveLength( + items.length + ); + }); + + it("does not render an item marked hidden", async () => { + const items: ContextMenuItemProps[] = [ + { label: "Resend verification", value: "resend" }, + { label: "Delete", value: "delete", hidden: true } + ]; + + wrapper = mount(barrel.ContextMenu, { + props: { items, label: "Actions" }, + attachTo: document.body + }); + + await wrapper.get("span").trigger("contextmenu"); + + expect(document.body.textContent).toContain("Resend verification"); + expect(document.body.textContent).not.toContain("Delete"); + expect(wrapper.findAllComponents(barrel.ContextMenuItem)).toHaveLength(1); + }); + + it("invokes only the selected item's own handler", async () => { + const resendHandler = vi.fn(); + const deleteHandler = vi.fn(); + const items: ContextMenuItemProps[] = [ + { label: "Resend verification", value: "resend", handler: resendHandler }, + { label: "Delete", value: "delete", handler: deleteHandler } + ]; + + wrapper = mount(barrel.ContextMenu, { + props: { items, label: "Actions" }, + attachTo: document.body + }); + + await wrapper.get("span").trigger("contextmenu"); + + const menuItems = wrapper.findAllComponents(barrel.ContextMenuItem); + await menuItems[1].vm.$emit("select", new Event("select")); + + expect(deleteHandler).toHaveBeenCalledTimes(1); + expect(resendHandler).not.toHaveBeenCalled(); }); }); diff --git a/src/ui/stepper/__tests__/stepper.spec.ts b/src/ui/stepper/__tests__/stepper.spec.ts index 62c695af2c..717f256cf5 100644 --- a/src/ui/stepper/__tests__/stepper.spec.ts +++ b/src/ui/stepper/__tests__/stepper.spec.ts @@ -64,4 +64,28 @@ describe("stepper", () => { steps.length ); }); + + it("forwards each step's own disabled flag to its StepperItem, not a shared value", () => { + const steps: StepperStepProps[] = [ + { step: 1, title: "Details", description: "Enter your details" }, + { + step: 2, + title: "Payment", + description: "Add a payment method", + disabled: true + }, + { step: 3, title: "Confirm", description: "Review and confirm" } + ]; + + const wrapper = mount(barrel.Stepper, { + props: { steps, modelValue: 1 } + }); + + const stepperItems = wrapper.findAllComponents(barrel.StepperItem); + + expect(stepperItems).toHaveLength(steps.length); + expect(stepperItems[0].props("disabled")).toBeFalsy(); + expect(stepperItems[1].props("disabled")).toBe(true); + expect(stepperItems[2].props("disabled")).toBeFalsy(); + }); }); From 6654178b3cab7ffc84c0836aba0d9a32a95e513c Mon Sep 17 00:00:00 2001 From: Dominic da Costa Date: Wed, 5 Aug 2026 11:02:16 +0200 Subject: [PATCH 10/34] test(ui): prove context-menu convenience wrapper's reason for existing [FE-2977] Replace the wrapper.exists()-only assertion on ContextMenu with real behaviour proofs (per-item handler on select, hidden-item exclusion, items-prop rendering) and add the stepper analog (per-item disabled forwarding) so the wrapper's data-driven mapping is actually verified, not just its mount. --- .../__tests__/context-menu.spec.ts | 54 +++++++++++++++++-- src/ui/stepper/__tests__/stepper.spec.ts | 24 +++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/ui/context-menu/__tests__/context-menu.spec.ts b/src/ui/context-menu/__tests__/context-menu.spec.ts index c9bae3feb7..2e97459e76 100644 --- a/src/ui/context-menu/__tests__/context-menu.spec.ts +++ b/src/ui/context-menu/__tests__/context-menu.spec.ts @@ -1,5 +1,5 @@ import { mount, type VueWrapper } from "@vue/test-utils"; -import { describe, it, expect, afterEach, beforeAll } from "vitest"; +import { describe, it, expect, afterEach, beforeAll, vi } from "vitest"; import { defineComponent, h } from "vue"; import type { ContextMenuItemProps } from "../../../index"; @@ -68,17 +68,63 @@ describe("context-menu", () => { expect(document.body.textContent).toContain("Resend verification"); }); - it("mounts the data-driven convenience component from its items prop", () => { + it("renders every visible item's label from the items prop", async () => { const items: ContextMenuItemProps[] = [ { label: "Resend verification", value: "resend" }, { label: "Delete", value: "delete" } ]; wrapper = mount(barrel.ContextMenu, { - props: { items }, + props: { items, label: "Actions" }, attachTo: document.body }); - expect(wrapper.exists()).toBe(true); + await wrapper.get("span").trigger("contextmenu"); + + expect(document.body.textContent).toContain("Resend verification"); + expect(document.body.textContent).toContain("Delete"); + expect(wrapper.findAllComponents(barrel.ContextMenuItem)).toHaveLength( + items.length + ); + }); + + it("does not render an item marked hidden", async () => { + const items: ContextMenuItemProps[] = [ + { label: "Resend verification", value: "resend" }, + { label: "Delete", value: "delete", hidden: true } + ]; + + wrapper = mount(barrel.ContextMenu, { + props: { items, label: "Actions" }, + attachTo: document.body + }); + + await wrapper.get("span").trigger("contextmenu"); + + expect(document.body.textContent).toContain("Resend verification"); + expect(document.body.textContent).not.toContain("Delete"); + expect(wrapper.findAllComponents(barrel.ContextMenuItem)).toHaveLength(1); + }); + + it("invokes only the selected item's own handler", async () => { + const resendHandler = vi.fn(); + const deleteHandler = vi.fn(); + const items: ContextMenuItemProps[] = [ + { label: "Resend verification", value: "resend", handler: resendHandler }, + { label: "Delete", value: "delete", handler: deleteHandler } + ]; + + wrapper = mount(barrel.ContextMenu, { + props: { items, label: "Actions" }, + attachTo: document.body + }); + + await wrapper.get("span").trigger("contextmenu"); + + const menuItems = wrapper.findAllComponents(barrel.ContextMenuItem); + await menuItems[1].vm.$emit("select", new Event("select")); + + expect(deleteHandler).toHaveBeenCalledTimes(1); + expect(resendHandler).not.toHaveBeenCalled(); }); }); diff --git a/src/ui/stepper/__tests__/stepper.spec.ts b/src/ui/stepper/__tests__/stepper.spec.ts index 62c695af2c..717f256cf5 100644 --- a/src/ui/stepper/__tests__/stepper.spec.ts +++ b/src/ui/stepper/__tests__/stepper.spec.ts @@ -64,4 +64,28 @@ describe("stepper", () => { steps.length ); }); + + it("forwards each step's own disabled flag to its StepperItem, not a shared value", () => { + const steps: StepperStepProps[] = [ + { step: 1, title: "Details", description: "Enter your details" }, + { + step: 2, + title: "Payment", + description: "Add a payment method", + disabled: true + }, + { step: 3, title: "Confirm", description: "Review and confirm" } + ]; + + const wrapper = mount(barrel.Stepper, { + props: { steps, modelValue: 1 } + }); + + const stepperItems = wrapper.findAllComponents(barrel.StepperItem); + + expect(stepperItems).toHaveLength(steps.length); + expect(stepperItems[0].props("disabled")).toBeFalsy(); + expect(stepperItems[1].props("disabled")).toBe(true); + expect(stepperItems[2].props("disabled")).toBeFalsy(); + }); }); From 7843de588a91e0759c87930a56982062015d2405 Mon Sep 17 00:00:00 2001 From: Dominic da Costa Date: Wed, 5 Aug 2026 11:19:47 +0200 Subject: [PATCH 11/34] docs(ui): list ContextMenu and Stepper in the primitives table Both are verified radix-vue installs resolving from the package barrel; the README's component index was missing them. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index ac926e289e..48df22885b 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ import { Button } from "@upmind-automation/upmind-ui"; | `Dialog` | Modal dialogs | | `Popover` | Contextual overlays | | `Tooltip` | Hover information | +| `ContextMenu` | Right-click contextual menu | +| `Stepper` | Multi-step progress indicator | ### Form Components From 76f456e473b5bbe9193fa2de1726713464dc8c42 Mon Sep 17 00:00:00 2001 From: Dominic da Costa Date: Wed, 5 Aug 2026 11:19:47 +0200 Subject: [PATCH 12/34] docs(ui): list ContextMenu and Stepper in the primitives table Both are verified radix-vue installs resolving from the package barrel; the README's component index was missing them. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index ac926e289e..48df22885b 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ import { Button } from "@upmind-automation/upmind-ui"; | `Dialog` | Modal dialogs | | `Popover` | Contextual overlays | | `Tooltip` | Hover information | +| `ContextMenu` | Right-click contextual menu | +| `Stepper` | Multi-step progress indicator | ### Form Components From 94ae570bbec49a1576da5390f3b9a526712cc996 Mon Sep 17 00:00:00 2001 From: Dominic da Costa Date: Wed, 5 Aug 2026 12:24:47 +0200 Subject: [PATCH 13/34] refactor(FE-2977): use defineModel + repo Icon in stepper/context-menu Operator direction (overrides sibling convention): Vue defineModel not useVModel; the repo Icon component (check/chevron-right/circle) not lucide-vue-next. --- src/ui/context-menu/ContextMenuCheckboxItem.vue | 4 ++-- src/ui/context-menu/ContextMenuRadioItem.vue | 4 ++-- src/ui/context-menu/ContextMenuSubTrigger.vue | 4 ++-- src/ui/stepper/Stepper.ce.vue | 14 +++----------- src/ui/stepper/types.ts | 1 - 5 files changed, 9 insertions(+), 18 deletions(-) diff --git a/src/ui/context-menu/ContextMenuCheckboxItem.vue b/src/ui/context-menu/ContextMenuCheckboxItem.vue index 7a597cfd44..d17db2277d 100644 --- a/src/ui/context-menu/ContextMenuCheckboxItem.vue +++ b/src/ui/context-menu/ContextMenuCheckboxItem.vue @@ -1,5 +1,5 @@