diff --git a/package.json b/package.json index 344230f7d..9255dd2be 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mirrorstack-ai/web-ui-kit", "packageManager": "pnpm@10.29.3", - "version": "0.5.10", + "version": "0.5.11", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/src/components/ui/navigation/dropdown-menu/DropdownMenu.stories.tsx b/src/components/ui/navigation/dropdown-menu/DropdownMenu.stories.tsx index 859a66b98..51429069d 100644 --- a/src/components/ui/navigation/dropdown-menu/DropdownMenu.stories.tsx +++ b/src/components/ui/navigation/dropdown-menu/DropdownMenu.stories.tsx @@ -31,3 +31,27 @@ export const Playground: Story = { onSelect: (item) => console.log("Selected:", item.id), }, }; + +/** + * The notch head tuned to a small trigger: `notchWidth`/`notchHeight` size the + * tab to the icon button, and `notchRadius`/`notchInverseRadius` round the head + * down to the button's own corner radius instead of the default bulbous 16. + */ +export const TightHead: Story = { + args: { + trigger: ( + + ), + notchWidth: 34, + notchHeight: 38, + notchRadius: 8, + notchInverseRadius: 6, + items: [ + { id: "how", label: "How it works", icon: "play_circle" }, + { id: "source", label: "View source", icon: "code" }, + { type: "separator" as const }, + { id: "subscribe", label: "Get early access", icon: "mail" }, + ], + onSelect: (item) => console.log("Selected:", item.id), + }, +}; diff --git a/src/components/ui/navigation/dropdown-menu/DropdownMenu.tsx b/src/components/ui/navigation/dropdown-menu/DropdownMenu.tsx index 6446361a4..e6966add4 100644 --- a/src/components/ui/navigation/dropdown-menu/DropdownMenu.tsx +++ b/src/components/ui/navigation/dropdown-menu/DropdownMenu.tsx @@ -18,6 +18,7 @@ const DD_NOTCH_W = 52; const DD_NOTCH_H = 46; const DD_R = 16; const DD_IR = 10; +const DD_SW = 1.5; // Item density tokens per `size` — "lg" pads up to comfortable touch targets. const SIZES = { @@ -56,6 +57,16 @@ export interface DropdownMenuProps { notchWidth?: number; /** Height of the notch tab (SVG units). Default `46`. */ notchHeight?: number; + /** Corner radius of the notch outline (SVG units). Default `16`. Lower it to + * match a small trigger's own radius for a less-rounded head (e.g. an icon + * button with an 8px radius). */ + notchRadius?: number; + /** Radius of the notch's inverse (concave) corners, where the tab curves back + * into the card (SVG units). Default `10`. Usually scaled down alongside + * `notchRadius` for a tighter head. */ + notchInverseRadius?: number; + /** Stroke (border) width of the notch outline, in px. Default `1.5`. */ + notchStrokeWidth?: number; /** Render the kit's signature notch wrapping the trigger. Default `true`. Set * to `false` for a plain floating menu (e.g. selects, period pickers). */ useNotch?: boolean; @@ -89,6 +100,9 @@ export function DropdownMenu({ offset = 0, notchWidth = DD_NOTCH_W, notchHeight = DD_NOTCH_H, + notchRadius = DD_R, + notchInverseRadius = DD_IR, + notchStrokeWidth = DD_SW, useNotch = true, placement = "bottom", size = "md", @@ -207,10 +221,10 @@ export function DropdownMenu({ notchHeight={notchHeight} notchSide={openUp ? "top" : "bottom"} notchOffset={offset} - radius={DD_R} - inverseRadius={DD_IR} + radius={notchRadius} + inverseRadius={notchInverseRadius} stroke="var(--color-primary)" - strokeWidth={1.5} + strokeWidth={notchStrokeWidth} className={cn("absolute left-0", openUp ? "bottom-0" : "top-0")} /> )} diff --git a/src/components/ui/surfaces/notch/Notch.test.tsx b/src/components/ui/surfaces/notch/Notch.test.tsx index dc3b387ad..44c6bd566 100644 --- a/src/components/ui/surfaces/notch/Notch.test.tsx +++ b/src/components/ui/surfaces/notch/Notch.test.tsx @@ -19,8 +19,12 @@ describe("Notch", () => { , ); const svg = container.querySelector("svg"); - expect(svg).toHaveAttribute("width", "241"); - expect(svg).toHaveAttribute("height", "151"); + // Element is the content box (width+notchWidth × height); the centered + // stroke is accommodated by the padded viewBox (content + strokeWidth, + // offset by -strokeWidth/2) so the border stays inside the box. + expect(svg).toHaveAttribute("width", "240"); + expect(svg).toHaveAttribute("height", "150"); + expect(svg).toHaveAttribute("viewBox", "-0.5 -0.5 241 151"); }); it("sets correct SVG dimensions for bottom notch", () => { @@ -28,8 +32,9 @@ describe("Notch", () => { , ); const svg = container.querySelector("svg"); - expect(svg).toHaveAttribute("width", "201"); - expect(svg).toHaveAttribute("height", "201"); + expect(svg).toHaveAttribute("width", "200"); + expect(svg).toHaveAttribute("height", "200"); + expect(svg).toHaveAttribute("viewBox", "-0.5 -0.5 201 201"); }); it("renders head only", () => { @@ -38,10 +43,11 @@ describe("Notch", () => { ); const svg = container.querySelector("svg"); // offset=0 → atStart=true, topIr=0, botIr=6 - // pathW = 60 + 6 = 66, + stroke = 67 - // pathH = 40 + 0 + 6 = 46, + stroke = 47 - expect(svg).toHaveAttribute("width", "67"); - expect(svg).toHaveAttribute("height", "47"); + // pathW = 60 + 6 = 66 (element width); pathH = 40 + 0 + 6 = 46 (element height) + // viewBox pads by strokeWidth so the stroke stays inside the box: 67 × 47. + expect(svg).toHaveAttribute("width", "66"); + expect(svg).toHaveAttribute("height", "46"); + expect(svg).toHaveAttribute("viewBox", "-0.5 -0.5 67 47"); }); it("applies fill and stroke", () => { diff --git a/src/components/ui/surfaces/notch/Notch.tsx b/src/components/ui/surfaces/notch/Notch.tsx index edfc4a537..518407222 100644 --- a/src/components/ui/surfaces/notch/Notch.tsx +++ b/src/components/ui/surfaces/notch/Notch.tsx @@ -197,9 +197,13 @@ export function Notch({ const transform = getTransform(notchSide, pathW, pathH); return ( + // Element sized to the CONTENT box; the viewBox stays padded by `pad` on + // every side so the centered stroke renders fully INSIDE the box. (Sizing + // the element to svgW+strokeWidth instead pins it `pad` larger and pushes + // the right & bottom strokes outside the box.)