diff --git a/.specs/dropdown-menu.md b/.specs/dropdown-menu.md index 2b7c996f6..c8b4cd3ca 100644 --- a/.specs/dropdown-menu.md +++ b/.specs/dropdown-menu.md @@ -7,9 +7,9 @@ spec_version: 1 figma: url: https://www.figma.com/design/t97pXRs7xME3SJDs5iZ5RF/Webkit?node-id=3775-16746 node_id: 3775:16746 -checksum: 5eedb67724f3eaa8c5f656a09f363f2600cd983a23cc55ed9f93a977919c066e +checksum: 4127c081919eccd8281c4b69251be703cd99317f3c72c7b7e89eec3c3e3f2d88 created: 2026-05-22 -last_updated: 2026-05-22 +last_updated: 2026-05-29 --- # Dropdown Menu — Component Spec @@ -35,6 +35,7 @@ Layered surface above the page (modal, drawer, menu). Migrated from the existing | `defaultOpen` | `boolean` | `undefined` | no | Initial open state when uncontrolled. | | `closeable` | `boolean` | `undefined` | no | When true, Escape and outside click close the menu. | | `closeOnSelect` | `boolean` | `undefined` | no | When true, selecting an item closes the menu. | +| `side` | `'top' \| 'bottom' \| 'left' \| 'right' \| 'auto'` | `'auto'` | no | Preferred panel placement. `auto` picks the side with the most viewport space; explicit sides flip to the opposite when they overflow. | | `sideOffset` | `number` | `undefined` | no | Gap between trigger and panel (px). | | `alignOffset` | `number` | `undefined` | no | Horizontal offset from trigger left edge (px). | diff --git a/.specs/menu-item.md b/.specs/menu-item.md index 3a596b462..2d9bee1fb 100644 --- a/.specs/menu-item.md +++ b/.specs/menu-item.md @@ -7,9 +7,9 @@ spec_version: 1 figma: url: https://www.figma.com/design/t97pXRs7xME3SJDs5iZ5RF/Webkit?node-id=3601-2693 node_id: 3601:2693 -checksum: 9142b6a3366d26151037777ebb01398851c84803f92a1b4c79631c75b0265043 +checksum: d084e89a360fad2914c4137f7e020e00588d18792b843d811637ad01edb1d2b6 created: 2026-05-22 -last_updated: 2026-05-22 +last_updated: 2026-05-27 --- # Menu Item — Component Spec diff --git a/.specs/sidebar.md b/.specs/sidebar.md index 28cae16d1..dbe81b24b 100644 --- a/.specs/sidebar.md +++ b/.specs/sidebar.md @@ -7,15 +7,48 @@ spec_version: 1 figma: url: https://www.figma.com/design/t97pXRs7xME3SJDs5iZ5RF/Webkit?node-id=3735-14866 node_id: 3735:14866 -checksum: 3e8dacf24608d93e15664ef7d188cf85d3212c3118e11979ad6439f3800949c8 +checksum: 5a95c6bb2fa2b2444eeeaa299f570536ecaf8250012366785ecfd2b5e2fba111 created: 2026-05-22 -last_updated: 2026-05-22 +last_updated: 2026-05-28 --- # Sidebar — Component Spec ## Purpose -Helps users move between views or sections. Lives at `packages/webkit/src/components/webkit/layout/sidebar/`. +Helps users move between views or sections. Composable application sidebar with optional header and footer regions; navigation content scrolls inside a built-in `ScrollArea`. + +## Usage + +```vue + + + +``` ## Sub-components @@ -25,7 +58,9 @@ Helps users move between views or sections. Lives at `packages/webkit/src/compon ## Props -| _none_ | — | — | — | Public API is slots-only. | +| Prop | Type | Default | Required | JSDoc | +|---|---|---|---|---| +| `ariaLabel` | `string` | `'Sidebar'` | no | Accessible name for the navigation landmark. | ## Events @@ -77,6 +112,8 @@ _none_ ## Stories (Storybook) - Default +- WithHeaderSearch +- WithHeaderAndProfileFooter ## Constraints — DO NOT diff --git a/apps/storybook/src/stories/webkit/layout/ScrollArea.stories.js b/apps/storybook/src/stories/webkit/layout/ScrollArea.stories.js index 5aadd64f7..abe901bcf 100644 --- a/apps/storybook/src/stories/webkit/layout/ScrollArea.stories.js +++ b/apps/storybook/src/stories/webkit/layout/ScrollArea.stories.js @@ -27,7 +27,7 @@ const meta = { docs: { description: { component: - 'Native overflow container with themed thin scrollbars and keyboard scrolling (arrow keys, Page Up/Down, Home/End). Pass height/width via `class` on the root; use inside sidebars via `SidebarGroup scroll`.' + 'Native overflow container with themed thin scrollbars and keyboard scrolling (arrow keys, Page Up/Down, Home/End). Pass height/width via `class` on the root; `Sidebar` wraps its nav content in `ScrollArea` automatically.' } } }, diff --git a/apps/storybook/src/stories/webkit/layout/Sidebar.stories.js b/apps/storybook/src/stories/webkit/layout/Sidebar.stories.js index 290b9b734..5b0822766 100644 --- a/apps/storybook/src/stories/webkit/layout/Sidebar.stories.js +++ b/apps/storybook/src/stories/webkit/layout/Sidebar.stories.js @@ -1,15 +1,79 @@ +import Avatar from '@aziontech/webkit/content/avatar' import InputText from '@aziontech/webkit/inputs/input-text' -import MenuItem from '@aziontech/webkit/navigation/menu-item' import Sidebar from '@aziontech/webkit/layout/sidebar' import SidebarFooter from '@aziontech/webkit/layout/sidebar-footer' import SidebarGroup from '@aziontech/webkit/layout/sidebar-group' -import { expect, userEvent, within } from '@storybook/test' +import MenuItem from '@aziontech/webkit/navigation/menu-item' +import DropdownMenu from '@aziontech/webkit/overlay/dropdown-menu' +import DropdownMenuContent from '@aziontech/webkit/overlay/dropdown-menu-content' +import DropdownMenuItem from '@aziontech/webkit/overlay/dropdown-menu-item' +import DropdownMenuPortal from '@aziontech/webkit/overlay/dropdown-menu-portal' +import DropdownMenuSeparator from '@aziontech/webkit/overlay/dropdown-menu-separator' +import DropdownMenuTrigger from '@aziontech/webkit/overlay/dropdown-menu-trigger' +import { ref } from 'vue' + +const sampleImage = + 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=96&h=96&fit=crop&crop=face' + +const profileMenuHeaderClasses = + 'flex flex-col gap-[var(--spacing-1)] px-[var(--spacing-2)] py-[var(--spacing-2)]' + +const profileMenuContentClasses = 'w-[18.625rem] min-w-[18.625rem]' + +const componentDocsDescription = [ + 'Helps users move between views or sections. Composable application sidebar with optional header and footer regions; navigation content scrolls inside a built-in `ScrollArea`.', + '', + '## Usage', + '', + '```vue', + '', + '', + '', + '```' +].join('\n') /** @type {import('@storybook/vue3').Meta} */ const meta = { title: 'Webkit/Layout/Sidebar', component: Sidebar, - subcomponents: { SidebarGroup, SidebarFooter, MenuItem }, + subcomponents: { + SidebarGroup, + SidebarFooter, + MenuItem, + Avatar, + InputText, + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuPortal, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator + }, tags: ['autodocs'], parameters: { layout: 'fullscreen', @@ -24,8 +88,7 @@ const meta = { }, docs: { description: { - component: - 'Composable application sidebar: `header` (search), scrollable groups (`SidebarGroup scroll`), optional `footer` (hidden when empty). Default story mirrors Console `getMenuItens()`.' + component: componentDocsDescription } } }, @@ -40,18 +103,18 @@ const meta = { } }, header: { - description: 'Top region — typically search (`InputText`).', control: false, + description: 'Optional top region (search, branding).', table: { type: { summary: 'VNode' }, category: 'slots' } }, default: { - description: 'Navigation region — wrap sections in ``.', control: false, + description: 'Navigation groups and menu items; region padding and group gap are applied by `Sidebar`.', table: { type: { summary: 'VNode' }, category: 'slots' } }, footer: { - description: 'Bottom region (pinned below scroll). Hidden when the slot is empty.', control: false, + description: 'Optional bottom region (profile, actions).', table: { type: { summary: 'VNode' }, category: 'slots' } } }, @@ -62,203 +125,382 @@ const meta = { export default meta -const sidebarTemplate = ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -` +function createHomeItem() { + return { + label: 'Home', + icon: 'ai ai-home', + to: '/', + id: 'home' + } +} -const Template = (args) => ({ - components: { Sidebar, SidebarGroup, MenuItem, InputText }, - setup() { - return { args } - }, - template: sidebarTemplate -}) +function createMarketplaceItem() { + return { + label: 'Marketplace', + icon: 'ai ai-marketplace', + to: '/marketplace', + id: 'marketplace' + } +} -/** @type {import('@storybook/vue3').StoryObj} */ -export const Default = { - render: Template, - parameters: { - docs: { - description: { - story: - 'Application menu template aligned with Console `getMenuItens()` (Home, Marketplace, Domains, Build, Secure, Store, Deploy, Observe, Tools, Edge Libraries, Marketplace Products).' - } - } +function createDomainsItem() { + return { + label: 'Domains', + icon: 'ai ai-workloads', + to: '/domains', + id: 'domains' } } -export const WithFooter = { - render: (args) => ({ - components: { Sidebar, SidebarGroup, SidebarFooter, MenuItem, InputText }, - setup() { - return { args } +function createBuildItems() { + return [ + { + label: 'Applications', + icon: 'ai ai-edge-application', + to: '/applications', + id: 'edge-application' }, - template: ` - - - - - - - - - - - ` - }), - parameters: { - docs: { - description: { - story: 'Footer slot pinned below the scrollable menu (use `#footer` or `SidebarFooter`).' - } + { label: 'Variables', icon: 'ai ai-variables', to: '/variables', id: 'variables' } + ] +} + +function createSecureItems() { + return [ + { + label: 'Connectors', + icon: 'ai ai-edge-connectors', + to: '/connectors', + id: 'edge-connectors' + }, + { label: 'Edge DNS', icon: 'ai ai-edge-dns', to: '/edge-dns', id: 'edge-dns' }, + { label: 'Firewalls', icon: 'ai ai-edge-firewall', to: '/firewalls', id: 'edge-firewall' } + ] +} + +function createStoreItems() { + return [ + { + label: 'Object Storage', + icon: 'ai ai-edge-storage', + to: '/object-storage', + id: 'object-storage' + }, + { + label: 'SQL Database', + icon: 'ai ai-edge-sql', + to: '/sql-database', + tag: 'Preview', + id: 'sql-database' } - } + ] } -export const WithoutSearch = { - render: (args) => ({ - components: { Sidebar, SidebarGroup, MenuItem }, - setup() { - return { args } +function createDeployItems() { + return [{ label: 'Edge Nodes', icon: 'ai ai-edge-nodes', to: '/edge-node', id: 'edge-nodes' }] +} + +function createObserveItems() { + return [ + { label: 'Data Stream', icon: 'ai ai-data-stream', to: '/data-stream', id: 'data-stream' }, + { label: 'Edge Pulse', icon: 'ai ai-edge-pulse', to: '/edge-pulse', id: 'edge-pulse' }, + { + label: 'Real-Time Metrics', + icon: 'ai ai-real-time-metrics', + to: '/real-time-metrics', + id: 'real-time-metrics' }, - template: ` - - - - - - - - - ` - }), - parameters: { - docs: { description: { story: 'Sidebar without the header search slot.' } } + { + label: 'Real-Time Events', + icon: 'ai ai-real-time-events', + to: '/real-time-events', + id: 'real-time-events' + }, + { label: 'SIEM', icon: 'pi pi-chart-bar', to: '/siem', id: 'siem' } + ] +} + +function createToolsItems() { + return [ + { + label: 'Real-Time Purge', + icon: 'ai ai-real-time-purge', + to: '/real-time-purge', + id: 'real-time-purge' + } + ] +} + +function createEdgeLibrariesItems() { + return [ + { + label: 'Certificate Manager', + icon: 'ai ai-digital-certificates', + to: '/digital-certificates', + id: 'digital-certificates' + }, + { label: 'Custom Pages', icon: 'ai ai-custom-pages', to: '/custom-pages', id: 'custom-pages' }, + { + label: 'Edge Services', + icon: 'ai ai-edge-services', + to: '/edge-services', + id: 'edge-services' + }, + { label: 'Functions', icon: 'ai ai-edge-functions', to: '/functions', id: 'edge-functions' }, + { + label: 'Network Lists', + icon: 'ai ai-network-lists', + to: '/network-lists', + id: 'network-lists' + }, + { label: 'WAF Rules', icon: 'ai ai-waf-rules', to: '/waf', id: 'waf-rules' } + ] +} + +function createMarketplaceProductsItems() { + return [ + { + label: 'Bot Manager', + icon: 'pi pi-wrench', + to: 'https://radware.eu.auth0.com/authorize?client_id=KnZSRL3CSoahL0ymcqfwsmt55EHxXxgS&response_type=code&connection=caixa-sso&prompt=login&scope=openid%20profile&redirect_uri=https://console.radwarecloud.com?connection=caixa-sso', + id: 'bot-manager', + external: true + }, + { + label: 'SIEM', + icon: 'pi pi-chart-bar', + to: 'https://caixa-siem.azion.com/login', + id: 'siem-external', + external: true + } + ] +} + +function getMenuItens(showMarketplaceProductsItens = true) { + return [ + createHomeItem(), + createMarketplaceItem(), + createDomainsItem(), + { label: 'Build', items: createBuildItems() }, + { label: 'Secure', items: createSecureItems() }, + { label: 'Store', items: createStoreItems() }, + { label: 'Deploy', items: createDeployItems() }, + { label: 'Observe', items: createObserveItems() }, + { label: 'Tools', items: createToolsItems() }, + { label: 'Edge Libraries', items: createEdgeLibrariesItems() }, + { + label: 'Marketplace Products', + visible: showMarketplaceProductsItens, + items: createMarketplaceProductsItems() + } + ] +} + +function splitMenuModel(showMarketplaceProductsItens = true) { + const visibleMenus = getMenuItens(showMarketplaceProductsItens).filter( + (menu) => menu.visible !== false + ) + + return { + rootItems: visibleMenus.filter((menu) => !menu.items), + sections: visibleMenus + .filter((menu) => Array.isArray(menu.items)) + .map((menu) => ({ + label: menu.label, + items: menu.items.filter((item) => item.visible !== false) + })) } } -export const LightDark = { - parameters: { - layout: 'fullscreen', - docs: { - description: { - story: 'Sidebar template on light and dark canvas backgrounds.' +const SidebarTemplate = + ({ withHeader = false, withFooter = false } = {}) => + (args) => ({ + components: { + Sidebar, + SidebarGroup, + SidebarFooter, + MenuItem, + InputText, + Avatar, + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuPortal, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator + }, + setup() { + const { rootItems, sections } = splitMenuModel(true) + const profileMenuOpen = ref(false) + + return { + args, + rootItems, + sections, + withHeader, + withFooter, + profileMenuOpen, + sampleImage, + profileMenuHeaderClasses, + profileMenuContentClasses } - } - }, - render: () => ({ - components: { Sidebar, SidebarGroup, MenuItem, InputText }, + }, template: ` -
-
- ${sidebarTemplate.replace('v-bind="args"', 'aria-label="Application light" class="h-screen w-[280px]"')} -
-
- ${sidebarTemplate.replace('v-bind="args"', 'aria-label="Application dark" class="h-screen w-[280px]"')} -
-
- ` + + + + + + + + + + + ` }) + +/** @type {import('@storybook/vue3').StoryObj} */ +export const Default = { + render: SidebarTemplate({ withHeader: false, withFooter: false }), + parameters: { + docs: { description: { story: 'Content-only sidebar (no header and no footer).' } } + } } -export const Accessibility = { - render: Template, +/** @type {import('@storybook/vue3').StoryObj} */ +export const WithHeaderSearch = { + render: SidebarTemplate({ withHeader: true, withFooter: false }), parameters: { - docs: { - description: { - story: - 'Keyboard: Tab moves through search and menu links. Landmark: aside + nav structure for screen readers.' - } - } - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement) - await step('Tab reaches the first menu link', async () => { - await userEvent.tab() - await userEvent.tab() - const home = canvas.getByRole('link', { name: 'Home' }) - expect(home).toHaveFocus() - }) + docs: { description: { story: 'Adds the header region with a search input.' } } } } -export const Playground = { - render: Template, +/** @type {import('@storybook/vue3').StoryObj} */ +export const WithHeaderAndProfileFooter = { + render: SidebarTemplate({ withHeader: true, withFooter: true }), parameters: { docs: { description: { - story: 'Interactive sidebar template — adjust `ariaLabel` via controls.' + story: + 'Header search plus profile footer with Avatar; account DropdownMenu opens above the trigger (`side="top"`).' } } } diff --git a/apps/storybook/src/stories/webkit/navigation/MenuItem.stories.js b/apps/storybook/src/stories/webkit/navigation/MenuItem.stories.js index 133266f9c..663610a87 100644 --- a/apps/storybook/src/stories/webkit/navigation/MenuItem.stories.js +++ b/apps/storybook/src/stories/webkit/navigation/MenuItem.stories.js @@ -1,5 +1,4 @@ import MenuItem from '@aziontech/webkit/navigation/menu-item' -import { expect, userEvent, within } from '@storybook/test' /** @type {import('@storybook/vue3').Meta} */ const meta = { @@ -7,7 +6,7 @@ const meta = { component: MenuItem, tags: ['autodocs'], parameters: { - layout: 'padded', + layout: 'centered', backgrounds: { default: 'dark' }, a11y: { config: { @@ -20,7 +19,7 @@ const meta = { docs: { description: { component: - 'Sidebar navigation row (option) or section overline (group). Maps to Figma Webkit MenuItem (node 3601:2693). Use inside `SidebarGroup` lists or standalone for compact nav patterns.' + 'Sidebar navigation row (`option`) or section overline (`group`). Supports optional icon, optional link rendering when `href` is set, and optional trailing tag.' } } }, @@ -140,8 +139,7 @@ const meta = { }, decorators: [ () => ({ - template: - '
' + template: '
' }) ] } @@ -153,25 +151,35 @@ const Template = (args) => ({ setup() { return { args } }, - template: '' + template: '' }) /** @type {import('@storybook/vue3').StoryObj} */ export const Default = { render: Template, parameters: { - docs: { description: { story: 'Default option row (Figma Type=Option, State=Default).' } } + docs: { description: { story: 'Default option row.' } } } } -export const Selected = { - args: { selected: true }, - render: Template, +/** @type {import('@storybook/vue3').StoryObj} */ +export const States = { + render: () => ({ + components: { MenuItem }, + template: ` +
    + + + +
+ ` + }), parameters: { - docs: { description: { story: 'Selected option with raised surface (Figma Selected=true).' } } + docs: { description: { story: 'Default, selected, and disabled option states.' } } } } +/** @type {import('@storybook/vue3').StoryObj} */ export const WithTag = { args: { tagValue: 'Label', tagSeverity: 'info' }, render: Template, @@ -180,90 +188,22 @@ export const WithTag = { } } +/** @type {import('@storybook/vue3').StoryObj} */ export const Group = { - args: { kind: 'group', label: 'Label Group' }, - decorators: [ - () => ({ - template: '
' - }) - ], - render: Template, - parameters: { - docs: { description: { story: 'Section overline label (Figma Type=Group).' } } - } -} - -export const Disabled = { - args: { disabled: true }, - render: Template, - parameters: { - docs: { description: { story: 'Disabled option — no interaction, muted tokens.' } } - } -} - -export const LightDark = { - parameters: { - layout: 'fullscreen', - docs: { - description: { - story: 'MenuItem option and group on light and dark canvas backgrounds.' - } - } - }, render: () => ({ components: { MenuItem }, template: ` -
-
-
    - - - -
-
-
-
    - - - -
-
-
+
    + + + + + + +
` - }) -} - -export const Accessibility = { - args: { href: '#home' }, - render: Template, + }), parameters: { - docs: { - description: { - story: - 'Keyboard: Tab focuses the link; Enter activates. Screen reader: current page announced when selected.' - } - } - }, - play: async ({ canvasElement, args, step }) => { - const canvas = within(canvasElement) - await step('Tab focuses the menu link', async () => { - await userEvent.tab() - expect(canvas.getByRole('link')).toHaveFocus() - }) - await step('Enter triggers click', async () => { - await userEvent.keyboard('{Enter}') - expect(args.onClick).toHaveBeenCalled() - }) - } -} - -export const Playground = { - render: Template, - parameters: { - docs: { - description: { - story: 'Drive every prop from the Controls panel.' - } - } + docs: { description: { story: 'Group header with five option rows.' } } } } diff --git a/apps/storybook/src/stories/webkit/overlay/DropdownMenu.stories.js b/apps/storybook/src/stories/webkit/overlay/DropdownMenu.stories.js index 3efccc4b0..d0d00d314 100644 --- a/apps/storybook/src/stories/webkit/overlay/DropdownMenu.stories.js +++ b/apps/storybook/src/stories/webkit/overlay/DropdownMenu.stories.js @@ -77,6 +77,13 @@ export default { description: 'When true, selecting an item closes the menu', table: { defaultValue: { summary: true } } }, + side: { + control: { type: 'select' }, + options: ['auto', 'top', 'bottom', 'left', 'right'], + description: + 'Preferred panel placement. `auto` picks the side with the most viewport space; explicit sides flip when they overflow.', + table: { defaultValue: { summary: 'auto' } } + }, sideOffset: { control: 'number', table: { defaultValue: { summary: 4 } } diff --git a/apps/storybook/src/stories/webkit/templates/PlatformShell.stories.js b/apps/storybook/src/stories/webkit/templates/PlatformShell.stories.js index 29cc142d4..799f49b2d 100644 --- a/apps/storybook/src/stories/webkit/templates/PlatformShell.stories.js +++ b/apps/storybook/src/stories/webkit/templates/PlatformShell.stories.js @@ -100,7 +100,6 @@ const navigationTemplate = ` - @@ -160,7 +159,6 @@ const navigationTemplate = ` target="_blank" /> - ` diff --git a/packages/webkit/src/components/layout/sidebar/sidebar-group.vue b/packages/webkit/src/components/layout/sidebar/sidebar-group.vue index 62c0e2540..2b0a23e89 100644 --- a/packages/webkit/src/components/layout/sidebar/sidebar-group.vue +++ b/packages/webkit/src/components/layout/sidebar/sidebar-group.vue @@ -3,7 +3,6 @@ import { cn } from '../../../utils/cn' import MenuItem from '../../navigation/menu-item/menu-item.vue' - import ScrollArea from '../scroll-area/scroll-area.vue' import { SidebarInjectionKey } from './injection-key' defineOptions({ @@ -14,16 +13,10 @@ interface Props { /** Section overline label; omit for unlabeled groups (e.g. top-level links). */ label?: string - /** - * When true, renders a scrollable full-height container for nested groups. - * Use a single outer `SidebarGroup` with `scroll` wrapping all menu sections. - */ - scroll?: boolean } withDefaults(defineProps(), { - label: undefined, - scroll: false + label: undefined }) defineSlots<{ @@ -38,23 +31,11 @@ (attrs['data-testid'] as string | undefined) ?? `${ctx?.testId ?? 'layout-sidebar'}__group` ) - const scrollClasses = computed(() => - cn('flex h-full min-h-0 flex-1 flex-col gap-[var(--spacing-md)]', attrs.class) - ) - const sectionClasses = computed(() => cn('flex w-full min-w-0 shrink-0 flex-col', attrs.class))