From a2a50ff5a1ae35bff712aa070837ee5b0e2a04e6 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sat, 20 Jun 2026 01:34:58 +0800 Subject: [PATCH] feat(DropdownMenu): expose notch radius/stroke props + fix Notch border containment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DropdownMenu hardcoded the notch corner radius (16/10) and stroke width (1.5), so a notch wrapping a small trigger (e.g. an 8px icon button) always rendered an over-rounded head with a fixed border. Expose `notchRadius`, `notchInverseRadius`, and `notchStrokeWidth` — all defaulting to the existing values, so there's no behaviour change — and pass them through to the Notch. Also fix Notch stroke containment: the SVG element was sized `strokeWidth` larger than the content box and pinned top-left, so the centered stroke on the right and bottom edges rendered OUTSIDE the box. Size the element to the content box (keeping the padded viewBox) so the border sits fully inside on every edge. Adds a TightHead story demoing a notch tuned to a small icon button. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- .../dropdown-menu/DropdownMenu.stories.tsx | 24 +++++++++++++++++++ .../navigation/dropdown-menu/DropdownMenu.tsx | 20 +++++++++++++--- .../ui/surfaces/notch/Notch.test.tsx | 22 ++++++++++------- src/components/ui/surfaces/notch/Notch.tsx | 15 ++++++++---- 5 files changed, 67 insertions(+), 16 deletions(-) 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.)