diff --git a/README.md b/README.md index cbef2cf..2337b7c 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ The package expects React 19 and Tailwind CSS 4. Install an exact release so a consumer upgrades deliberately: ```bash -pnpm add --save-exact @nextide/ui@2.0.0 +pnpm add --save-exact @nextide/ui@2.1.0 pnpm add --save-dev --save-exact tailwindcss@4.3.1 @tailwindcss/vite@4.3.1 ``` diff --git a/apps/playground/src/App.tsx b/apps/playground/src/App.tsx index 9ba2e32..cf5a162 100644 --- a/apps/playground/src/App.tsx +++ b/apps/playground/src/App.tsx @@ -23,6 +23,7 @@ import { PanelRightClose, PanelRightOpen, PanelLeft, + Plus, RadioTower, Search, Settings, @@ -47,7 +48,10 @@ import { } from "@nextide/ui/blocks/intelligence-progression-chart" import { SignalPlate } from "@nextide/ui/blocks/signal-plate" import { LiveguardCockpit } from "@nextide/ui/blocks/liveguard-cockpit" -import { NavigationPanel } from "@nextide/ui/blocks/navigation-panel" +import { + NavigationPanel, + defaultNavigationPanelSections, +} from "@nextide/ui/blocks/navigation-panel" import { ProgressiveSummaryRail } from "@nextide/ui/blocks/progressive-summary-rail" import { ReportContextBuilder, @@ -969,6 +973,8 @@ const blockPreviewNavigationLabels: Record = { creators: "Creators", settings: "Settings", "service-health": "Service Health", + "summer-launch": "Summer launch", + "partner-rollout": "Partner rollout", } type PlaygroundState = { @@ -2959,6 +2965,24 @@ function BlockPreview({ motionScale }: { motionScale: number }) { (_current: string, nextItemId: string) => nextItemId, "dashboard" ) + const [campaignsExpanded, setCampaignsExpanded] = useState(false) + const [navigationActionCount, setNavigationActionCount] = useState(0) + const navigationSections = defaultNavigationPanelSections.map((section) => ({ + ...section, + items: section.items.map((item) => + item.id === "campaigns" + ? { + ...item, + expanded: campaignsExpanded, + action: { label: "Create campaign", icon: }, + children: [ + { id: "summer-launch", label: "Summer launch" }, + { id: "partner-rollout", label: "Partner rollout" }, + ], + } + : item + ), + })) const activeNavigationLabel = blockPreviewNavigationLabels[activeNavigationItemId] ?? "Dashboard" @@ -3077,6 +3101,7 @@ function BlockPreview({ motionScale }: { motionScale: number }) { collapsed={navigationDrawer.iconsCollapsed} drawerCollapsed={navigationDrawer.drawerCollapsed} drawerTransitioning={navigationDrawer.transitioning} + sections={navigationSections} commandShortcut="" footer={
@@ -3085,6 +3110,8 @@ function BlockPreview({ motionScale }: { motionScale: number }) {
} onSelectItem={(item) => updateActiveNavigationItemId(item.id)} + onActionItem={() => setNavigationActionCount((count) => count + 1)} + onToggleItem={() => setCampaignsExpanded((expanded) => !expanded)} onToggle={navigationDrawer.toggleCollapsed} /> @@ -3103,7 +3130,17 @@ function BlockPreview({ motionScale }: { motionScale: number }) { {activeNavigationLabel} - Nominal +
+ Nominal + + {navigationActionCount + ? `Create campaign requested ${navigationActionCount} ${navigationActionCount === 1 ? "time" : "times"}` + : "No action requested"} + +
& { commandShortcut?: string onToggle?: () => void onSelectItem: (item: NavigationPanelItem) => void + onToggleItem?: (item: NavigationPanelItem) => void + onActionItem?: (item: NavigationPanelItem) => void footer?: React.ReactNode userMenu?: NavigationPanelUserMenu } @@ -140,6 +151,8 @@ type NavigationPanelCommandRowProps = { commandLabel: string commandShortcut?: string onSelectItem: (item: NavigationPanelItem) => void + onToggleItem?: (item: NavigationPanelItem) => void + onActionItem?: (item: NavigationPanelItem) => void onToggle?: () => void } @@ -150,6 +163,8 @@ type NavigationPanelNavProps = { drawerCollapsed: boolean drawerTransitioning: boolean onSelectItem: (item: NavigationPanelItem) => void + onToggleItem?: (item: NavigationPanelItem) => void + onActionItem?: (item: NavigationPanelItem) => void } type NavigationPanelFooterProps = { @@ -166,6 +181,8 @@ function NavigationPanelCommandRow({ commandLabel, commandShortcut, onSelectItem, + onToggleItem, + onActionItem, onToggle, }: NavigationPanelCommandRowProps) { const [searchFocused, setSearchFocused] = React.useState(false) @@ -179,12 +196,27 @@ function NavigationPanelCommandRow({ const searchItems = React.useMemo( () => sections.flatMap((section) => - section.items.map((item) => ({ - ...item, - sectionLabel: section.label, - })) + section.items.flatMap((item) => [ + { ...item, sectionLabel: section.label }, + ...(item.children ?? []).map((child) => ({ + ...child, + sectionLabel: section.label, + parent: item, + })), + ...(item.action && onActionItem + ? [ + { + ...item, + label: item.action.label, + icon: item.action.icon, + sectionLabel: section.label, + actionFor: item, + }, + ] + : []), + ]) ), - [sections] + [onActionItem, sections] ) const showSearchResults = searchFocused && searchValue.trim().length > 0 @@ -363,11 +395,22 @@ function NavigationPanelCommandRow({ return ( { - onSelectItem(item) + if (item.actionFor) { + onActionItem?.(item.actionFor) + } else if (item.parent && !item.parent.expanded) { + onToggleItem?.(item.parent) + onSelectItem(item) + } else { + onSelectItem(item) + } clearSearch() }} > @@ -400,6 +443,8 @@ function NavigationPanelNav({ drawerCollapsed, drawerTransitioning, onSelectItem, + onToggleItem, + onActionItem, }: NavigationPanelNavProps) { const { ref: navRef, onWheel } = useContainedScroll({ axis: "auto", @@ -409,7 +454,13 @@ function NavigationPanelNav({ const itemAnimationsRef = React.useRef>({}) const railRef = React.useRef(null) const railAnimationRef = React.useRef(null) - const previousCollapsedRef = React.useRef(collapsed) + const compact = collapsed || drawerCollapsed + const effectiveActiveItemId = getEffectiveNavigationItemId( + sections, + activeItemId, + compact + ) + const previousCompactRef = React.useRef(compact) const writeOutlineVars = React.useCallback( ( top: number, @@ -471,15 +522,14 @@ function NavigationPanelNav({ if (!nav) return const nextRects: Record = {} - for (const section of sections) { - for (const item of section.items) { - const element = itemRefs.current[item.id] - if (element) nextRects[item.id] = readNavigationItemMotionRect(element) - } + const visibleItems = getVisibleNavigationPanelItems(sections) + for (const item of visibleItems) { + const element = itemRefs.current[item.id] + if (element) nextRects[item.id] = readNavigationItemMotionRect(element) } const previousRects = itemRectsRef.current - const stateChanged = previousCollapsedRef.current !== collapsed + const stateChanged = previousCompactRef.current !== compact const reducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false @@ -496,7 +546,9 @@ function NavigationPanelNav({ styles.getPropertyValue("--nextide-drawer-icon-duration"), 160 ) - const activeElement = activeItemId ? itemRefs.current[activeItemId] : null + const activeElement = effectiveActiveItemId + ? itemRefs.current[effectiveActiveItemId] + : null const previousRailTop = Number.parseFloat( nav.style.getPropertyValue("--navigation-rail-top") ) @@ -506,35 +558,33 @@ function NavigationPanelNav({ nav.style.getPropertyValue("--navigation-rail-top") ) - for (const section of sections) { - for (const item of section.items) { - const element = itemRefs.current[item.id] - const previousRect = previousRects[item.id] - const nextRect = nextRects[item.id] - if (!element || !previousRect || !nextRect) continue - - const deltaY = previousRect.top - nextRect.top - if (Math.abs(deltaY) < 0.5) continue - - const animation = element.animate( - [ - { transform: `translate3d(0, ${deltaY}px, 0)` }, - { transform: "translate3d(0, 0, 0)" }, - ], - { - duration, - easing: "cubic-bezier(0.25, 1, 0.5, 1)", - } - ) - - itemAnimationsRef.current[item.id] = animation - void animation.finished - .then(() => { - if (itemAnimationsRef.current[item.id] !== animation) return - delete itemAnimationsRef.current[item.id] - }) - .catch(() => undefined) - } + for (const item of visibleItems) { + const element = itemRefs.current[item.id] + const previousRect = previousRects[item.id] + const nextRect = nextRects[item.id] + if (!element || !previousRect || !nextRect) continue + + const deltaY = previousRect.top - nextRect.top + if (Math.abs(deltaY) < 0.5) continue + + const animation = element.animate( + [ + { transform: `translate3d(0, ${deltaY}px, 0)` }, + { transform: "translate3d(0, 0, 0)" }, + ], + { + duration, + easing: "cubic-bezier(0.25, 1, 0.5, 1)", + } + ) + + itemAnimationsRef.current[item.id] = animation + void animation.finished + .then(() => { + if (itemAnimationsRef.current[item.id] !== animation) return + delete itemAnimationsRef.current[item.id] + }) + .catch(() => undefined) } if ( @@ -568,7 +618,7 @@ function NavigationPanelNav({ } itemRectsRef.current = nextRects - previousCollapsedRef.current = collapsed + previousCompactRef.current = compact return () => { for (const animation of Object.values(itemAnimationsRef.current)) { @@ -578,13 +628,15 @@ function NavigationPanelNav({ railAnimationRef.current?.cancel() railAnimationRef.current = null } - }, [activeItemId, collapsed, measureOutline, navRef, sections]) + }, [compact, effectiveActiveItemId, measureOutline, navRef, sections]) React.useLayoutEffect(() => { const nav = navRef.current if (!nav) return - const activeItem = activeItemId ? itemRefs.current[activeItemId] : null + const activeItem = effectiveActiveItemId + ? itemRefs.current[effectiveActiveItemId] + : null if (!activeItem) { const top = Number.parseFloat( @@ -625,7 +677,7 @@ function NavigationPanelNav({ window.removeEventListener("resize", scheduleMeasureOutline) } }, [ - activeItemId, + effectiveActiveItemId, drawerTransitioning, measureOutline, navRef, @@ -708,91 +760,235 @@ function NavigationPanelNav({
{section.items.map((item) => { const active = item.id === activeItemId + const activeChild = item.children?.find( + (child) => child.id === activeItemId + ) + const branchActive = Boolean(activeChild) + const hasChildren = Boolean(item.children?.length) + const compactChildActive = + branchActive && (collapsed || drawerCollapsed) return ( - + {item.action && + onActionItem && + !collapsed && + !drawerCollapsed ? ( + + ) : null} + {hasChildren && + onToggleItem && + !collapsed && + !drawerCollapsed ? ( + + ) : null} +
+ {hasChildren && + item.expanded && + !collapsed && + !drawerCollapsed ? ( +
+ {item.children?.map((child) => { + const childActive = child.id === activeItemId + + return ( + + ) + })} +
+ ) : null} + {activeChild && !item.expanded && !compact ? ( + + {activeChild.label} - - + ) : null} +
) })} @@ -860,6 +1056,8 @@ function NavigationPanel({ commandShortcut, onToggle, onSelectItem, + onToggleItem, + onActionItem, footer, userMenu, className, @@ -904,6 +1102,8 @@ function NavigationPanel({ commandLabel={commandLabel} commandShortcut={commandShortcut} onSelectItem={onSelectItem} + onToggleItem={onToggleItem} + onActionItem={onActionItem} onToggle={onToggle} /> + section.items.flatMap((item) => [ + item, + ...(item.expanded ? (item.children ?? []) : []), + ]) + ) +} + +function getEffectiveNavigationItemId( + sections: NavigationPanelSection[], + activeItemId: string | undefined, + compact: boolean +) { + if (!activeItemId) return activeItemId + + const parent = sections + .flatMap((section) => section.items) + .find((item) => item.children?.some((child) => child.id === activeItemId)) + + return parent && (compact || !parent.expanded) ? parent.id : activeItemId +} + export { NavigationPanel, defaultNavigationPanelSections, diff --git a/tests/e2e/qualification.spec.ts b/tests/e2e/qualification.spec.ts index a291258..11c1502 100644 --- a/tests/e2e/qualification.spec.ts +++ b/tests/e2e/qualification.spec.ts @@ -472,6 +472,106 @@ test("collapsed navigation search closes cleanly", async ({ page }) => { await expect(search).toHaveCSS("width", "44px") }) +test("navigation branches keep destinations and create actions distinct", async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto("/") + await page.getByRole("button", { name: /Patterns/ }).click() + + const navigation = page.locator('[data-slot="navigation-panel-frame"]').nth(1) + const expand = navigation.getByRole("button", { name: "Expand Campaigns" }) + + await expect(expand).toBeVisible() + await expect( + navigation.getByRole("button", { name: "Summer launch" }) + ).toHaveCount(0) + await expand.click() + + const report = navigation.getByRole("button", { name: "Summer launch" }) + await expect( + navigation.getByRole("button", { name: "Collapse Campaigns" }) + ).toBeVisible() + await report.click() + await expect(report).toHaveAttribute("aria-current", "page") + + await navigation.getByRole("button", { name: "Create campaign" }).click() + await expect( + page.getByText("Create campaign requested 1 time", { exact: true }) + ).toBeVisible() + await expect(report).toHaveAttribute("aria-current", "page") + + await navigation.getByRole("button", { name: "Collapse Campaigns" }).click() + const closedBranchGlyph = navigation + .getByRole("button", { name: "Campaigns" }) + .locator('[data-slot="navigation-panel-item-glyph"]') + const rail = navigation.locator('[data-slot="navigation-panel-rail"]') + await expect( + navigation.locator( + '[data-slot="navigation-panel-current-child"][aria-current="page"]' + ) + ).toHaveText("Summer launch") + await expect + .poll(async () => { + const glyphBox = await closedBranchGlyph.boundingBox() + const railBox = await rail.boundingBox() + return glyphBox && railBox ? Math.abs(railBox.y - (glyphBox.y - 2)) : 100 + }) + .toBeLessThan(1) + await navigation + .getByRole("combobox", { name: "Search" }) + .fill("Summer launch") + await page + .locator('[data-slot="autocomplete-item"]') + .filter({ hasText: "Summer launch" }) + .click() + await expect(report).toHaveAttribute("aria-current", "page") + + await page.setViewportSize({ width: 390, height: 900 }) + const action = navigation.getByRole("button", { name: "Create campaign" }) + const disclosure = navigation.getByRole("button", { + name: "Collapse Campaigns", + }) + await action.scrollIntoViewIfNeeded() + for (const control of [action, disclosure]) { + const box = await control.boundingBox() + expect(box).not.toBeNull() + expect(box!.width).toBeGreaterThanOrEqual(44) + expect(box!.height).toBeGreaterThanOrEqual(44) + } + + await page.setViewportSize({ width: 1440, height: 900 }) + await navigation.getByRole("button", { name: "Collapse sidebar" }).click() + const current = navigation.getByRole("button", { name: "Summer launch" }) + await expect(current).toHaveAttribute("aria-current", "page") + await expect(navigation).toHaveAttribute("data-collapsed", "true") + const currentGlyph = current.locator( + '[data-slot="navigation-panel-item-glyph"]' + ) + await expect + .poll(async () => { + const glyphBox = await currentGlyph.boundingBox() + const railBox = await rail.boundingBox() + return glyphBox && railBox + ? Math.max( + Math.abs(railBox.y - (glyphBox.y - 2)), + Math.abs(railBox.height - (glyphBox.height + 4)) + ) + : 100 + }) + .toBeLessThan(1) + await navigation + .getByRole("combobox", { name: "Search" }) + .fill("Create campaign") + await page + .locator('[data-slot="autocomplete-item"]') + .filter({ hasText: "Create campaign" }) + .click() + await expect( + page.getByText("Create campaign requested 2 times", { exact: true }) + ).toBeVisible() +}) + test("playground shows exact public names beside component examples", async ({ page, }) => {