Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .beads/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ sem/
# contributors to accidentally commit upstream issue databases.
# The JSONL files (issues.jsonl, interactions.jsonl) and config files
# are tracked by git by default since no pattern above ignores them.
.jsonl.lock
Empty file removed .beads/.jsonl.lock
Empty file.
5 changes: 3 additions & 2 deletions apps/web/public/installation/manual/menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ export type ZardMenuTrigger = 'click' | 'hover';

@Directive({
selector: '[z-menu]',
standalone: true,
host: {
role: 'button',
'[attr.tabindex]': "'0'",
'[attr.aria-haspopup]': "'menu'",
'[attr.aria-expanded]': 'cdkTrigger.isOpen()',
'[attr.data-state]': "cdkTrigger.isOpen() ? 'open': 'closed'",
Expand All @@ -49,7 +49,7 @@ export class ZardMenuDirective implements OnInit, OnDestroy {

protected readonly cdkTrigger = inject(CdkMenuTrigger, { host: true });
private readonly document = inject(DOCUMENT);
private readonly elementRef = inject(ElementRef);
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly menuManager = inject(ZardMenuManagerService);
private readonly platformId = inject(PLATFORM_ID);

Expand Down Expand Up @@ -112,6 +112,7 @@ export class ZardMenuDirective implements OnInit, OnDestroy {
return;
}

element.focus({ preventScroll: true });
this.cancelScheduledClose();
this.menuManager.registerHoverMenu(this);
this.cdkTrigger.open();
Expand Down
2 changes: 1 addition & 1 deletion apps/web/public/r/menu.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"files": [
{
"name": "menu.directive.ts",
"content": "import type { BooleanInput } from '@angular/cdk/coercion';\nimport { CdkMenuTrigger } from '@angular/cdk/menu';\nimport type { ConnectedPosition } from '@angular/cdk/overlay';\nimport { isPlatformBrowser } from '@angular/common';\nimport {\n booleanAttribute,\n computed,\n Directive,\n DOCUMENT,\n effect,\n ElementRef,\n inject,\n input,\n type OnDestroy,\n type OnInit,\n PLATFORM_ID,\n type TemplateRef,\n untracked,\n} from '@angular/core';\n\nimport { ZardMenuManagerService } from './menu-manager.service';\nimport { MENU_POSITIONS_MAP, type ZardMenuPlacement } from './menu-positions';\n\nexport type ZardMenuTrigger = 'click' | 'hover';\n\n@Directive({\n selector: '[z-menu]',\n standalone: true,\n host: {\n role: 'button',\n '[attr.aria-haspopup]': \"'menu'\",\n '[attr.aria-expanded]': 'cdkTrigger.isOpen()',\n '[attr.data-state]': \"cdkTrigger.isOpen() ? 'open': 'closed'\",\n '[attr.data-disabled]': \"zDisabled() ? '' : undefined\",\n '[style.cursor]': \"'pointer'\",\n },\n hostDirectives: [\n {\n directive: CdkMenuTrigger,\n inputs: ['cdkMenuTriggerFor: zMenuTriggerFor'],\n },\n ],\n})\nexport class ZardMenuDirective implements OnInit, OnDestroy {\n private static readonly MENU_CONTENT_SELECTOR = '.cdk-overlay-pane [z-menu-content]';\n\n protected readonly cdkTrigger = inject(CdkMenuTrigger, { host: true });\n private readonly document = inject(DOCUMENT);\n private readonly elementRef = inject(ElementRef);\n private readonly menuManager = inject(ZardMenuManagerService);\n private readonly platformId = inject(PLATFORM_ID);\n\n private closeTimeout: ReturnType<typeof setTimeout> | null = null;\n private readonly cleanupFunctions: Array<() => void> = [];\n\n readonly zMenuTriggerFor = input.required<TemplateRef<void>>();\n readonly zDisabled = input<boolean, BooleanInput>(false, { transform: booleanAttribute });\n readonly zTrigger = input<ZardMenuTrigger>('click');\n readonly zHoverDelay = input<number>(100);\n readonly zPlacement = input<ZardMenuPlacement>('bottomLeft');\n\n private readonly menuPositions = computed(() => this.getPositionsByPlacement(this.zPlacement()));\n\n constructor() {\n effect(() => {\n const positions = this.menuPositions();\n untracked(() => {\n this.cdkTrigger.menuPosition = positions;\n });\n });\n }\n\n private getPositionsByPlacement(placement: ZardMenuPlacement): ConnectedPosition[] {\n return MENU_POSITIONS_MAP[placement] || MENU_POSITIONS_MAP['bottomLeft'];\n }\n\n ngOnInit(): void {\n const isMobile = this.isMobileDevice();\n\n // If trigger is hover but device is mobile, skip hover behavior\n // The CDK MenuTrigger will handle click by default\n if (this.zTrigger() === 'hover' && !isMobile) {\n this.initializeHoverBehavior();\n }\n }\n\n ngOnDestroy(): void {\n this.cancelScheduledClose();\n this.menuManager.unregisterHoverMenu(this);\n this.cleanupFunctions.forEach(cleanup => cleanup());\n this.cleanupFunctions.length = 0;\n }\n\n close(): void {\n this.cancelScheduledClose();\n this.cdkTrigger.close();\n }\n\n private initializeHoverBehavior(): void {\n this.setupTriggerListeners();\n this.setupMenuOpenListener();\n }\n\n private setupTriggerListeners(): void {\n const element = this.elementRef.nativeElement;\n\n this.addEventListenerWithCleanup(element, 'mouseenter', () => {\n if (this.zDisabled()) {\n return;\n }\n\n this.cancelScheduledClose();\n this.menuManager.registerHoverMenu(this);\n this.cdkTrigger.open();\n });\n\n this.addEventListenerWithCleanup(element, 'mouseleave', event => this.scheduleCloseIfNeeded(event as MouseEvent));\n }\n\n private setupMenuOpenListener(): void {\n const openSubscription = this.cdkTrigger.opened.subscribe(() => {\n setTimeout(() => this.setupMenuContentListeners(), 0);\n });\n\n const closeSubscription = this.cdkTrigger.closed.subscribe(() => {\n this.menuManager.unregisterHoverMenu(this);\n });\n\n this.cleanupFunctions.push(\n () => openSubscription.unsubscribe(),\n () => closeSubscription.unsubscribe(),\n );\n }\n\n private setupMenuContentListeners(): void {\n const menuContent = this.document.querySelector(ZardMenuDirective.MENU_CONTENT_SELECTOR);\n if (!menuContent) {\n return;\n }\n\n this.addEventListenerWithCleanup(menuContent, 'mouseenter', () => this.cancelScheduledClose());\n this.addEventListenerWithCleanup(menuContent, 'mouseleave', event =>\n this.scheduleCloseIfNeeded(event as MouseEvent),\n );\n }\n\n private cancelScheduledClose(): void {\n if (this.closeTimeout) {\n clearTimeout(this.closeTimeout);\n this.closeTimeout = null;\n }\n }\n\n private scheduleCloseIfNeeded(event: MouseEvent): void {\n if (this.shouldKeepMenuOpen(event.relatedTarget as Element)) {\n return;\n }\n\n this.scheduleMenuClose();\n }\n\n private shouldKeepMenuOpen(relatedTarget: Element | null): boolean {\n if (!relatedTarget) {\n return false;\n }\n\n const isMovingToTrigger = this.elementRef.nativeElement.contains(relatedTarget);\n const isMovingToMenu = relatedTarget.closest(ZardMenuDirective.MENU_CONTENT_SELECTOR);\n const isMovingToOtherTrigger =\n relatedTarget.matches('[z-menu]') && !this.elementRef.nativeElement.contains(relatedTarget);\n\n if (isMovingToOtherTrigger) {\n return false;\n }\n\n return isMovingToTrigger || !!isMovingToMenu;\n }\n\n private scheduleMenuClose(): void {\n this.closeTimeout = setTimeout(() => {\n this.cdkTrigger.close();\n }, this.zHoverDelay());\n }\n\n private addEventListenerWithCleanup(\n element: Element,\n eventType: string,\n handler: (event: MouseEvent | Event) => void,\n options?: AddEventListenerOptions,\n ): void {\n if (isPlatformBrowser(this.platformId)) {\n element.addEventListener(eventType, handler, options);\n this.cleanupFunctions.push(() => element.removeEventListener(eventType, handler, options));\n }\n }\n\n private isMobileDevice(): boolean {\n if (!isPlatformBrowser(this.platformId)) {\n return false; // Default to desktop behavior on server\n }\n\n const window = this.document.defaultView;\n if (!window) {\n return false;\n }\n\n const { navigator } = window;\n const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;\n\n // Check for mobile user agent\n const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;\n const isMobileUA = mobileRegex.test(navigator.userAgent);\n\n // Check viewport width for small screens\n const isSmallScreen = window.innerWidth <= 768;\n\n return hasTouch && (isMobileUA || isSmallScreen);\n }\n}\n"
"content": "import type { BooleanInput } from '@angular/cdk/coercion';\nimport { CdkMenuTrigger } from '@angular/cdk/menu';\nimport type { ConnectedPosition } from '@angular/cdk/overlay';\nimport { isPlatformBrowser } from '@angular/common';\nimport {\n booleanAttribute,\n computed,\n Directive,\n DOCUMENT,\n effect,\n ElementRef,\n inject,\n input,\n type OnDestroy,\n type OnInit,\n PLATFORM_ID,\n type TemplateRef,\n untracked,\n} from '@angular/core';\n\nimport { ZardMenuManagerService } from './menu-manager.service';\nimport { MENU_POSITIONS_MAP, type ZardMenuPlacement } from './menu-positions';\n\nexport type ZardMenuTrigger = 'click' | 'hover';\n\n@Directive({\n selector: '[z-menu]',\n host: {\n role: 'button',\n '[attr.tabindex]': \"'0'\",\n '[attr.aria-haspopup]': \"'menu'\",\n '[attr.aria-expanded]': 'cdkTrigger.isOpen()',\n '[attr.data-state]': \"cdkTrigger.isOpen() ? 'open': 'closed'\",\n '[attr.data-disabled]': \"zDisabled() ? '' : undefined\",\n '[style.cursor]': \"'pointer'\",\n },\n hostDirectives: [\n {\n directive: CdkMenuTrigger,\n inputs: ['cdkMenuTriggerFor: zMenuTriggerFor'],\n },\n ],\n})\nexport class ZardMenuDirective implements OnInit, OnDestroy {\n private static readonly MENU_CONTENT_SELECTOR = '.cdk-overlay-pane [z-menu-content]';\n\n protected readonly cdkTrigger = inject(CdkMenuTrigger, { host: true });\n private readonly document = inject(DOCUMENT);\n private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);\n private readonly menuManager = inject(ZardMenuManagerService);\n private readonly platformId = inject(PLATFORM_ID);\n\n private closeTimeout: ReturnType<typeof setTimeout> | null = null;\n private readonly cleanupFunctions: Array<() => void> = [];\n\n readonly zMenuTriggerFor = input.required<TemplateRef<void>>();\n readonly zDisabled = input<boolean, BooleanInput>(false, { transform: booleanAttribute });\n readonly zTrigger = input<ZardMenuTrigger>('click');\n readonly zHoverDelay = input<number>(100);\n readonly zPlacement = input<ZardMenuPlacement>('bottomLeft');\n\n private readonly menuPositions = computed(() => this.getPositionsByPlacement(this.zPlacement()));\n\n constructor() {\n effect(() => {\n const positions = this.menuPositions();\n untracked(() => {\n this.cdkTrigger.menuPosition = positions;\n });\n });\n }\n\n private getPositionsByPlacement(placement: ZardMenuPlacement): ConnectedPosition[] {\n return MENU_POSITIONS_MAP[placement] || MENU_POSITIONS_MAP['bottomLeft'];\n }\n\n ngOnInit(): void {\n const isMobile = this.isMobileDevice();\n\n // If trigger is hover but device is mobile, skip hover behavior\n // The CDK MenuTrigger will handle click by default\n if (this.zTrigger() === 'hover' && !isMobile) {\n this.initializeHoverBehavior();\n }\n }\n\n ngOnDestroy(): void {\n this.cancelScheduledClose();\n this.menuManager.unregisterHoverMenu(this);\n this.cleanupFunctions.forEach(cleanup => cleanup());\n this.cleanupFunctions.length = 0;\n }\n\n close(): void {\n this.cancelScheduledClose();\n this.cdkTrigger.close();\n }\n\n private initializeHoverBehavior(): void {\n this.setupTriggerListeners();\n this.setupMenuOpenListener();\n }\n\n private setupTriggerListeners(): void {\n const element = this.elementRef.nativeElement;\n\n this.addEventListenerWithCleanup(element, 'mouseenter', () => {\n if (this.zDisabled()) {\n return;\n }\n\n element.focus({ preventScroll: true });\n this.cancelScheduledClose();\n this.menuManager.registerHoverMenu(this);\n this.cdkTrigger.open();\n });\n\n this.addEventListenerWithCleanup(element, 'mouseleave', event => this.scheduleCloseIfNeeded(event as MouseEvent));\n }\n\n private setupMenuOpenListener(): void {\n const openSubscription = this.cdkTrigger.opened.subscribe(() => {\n setTimeout(() => this.setupMenuContentListeners(), 0);\n });\n\n const closeSubscription = this.cdkTrigger.closed.subscribe(() => {\n this.menuManager.unregisterHoverMenu(this);\n });\n\n this.cleanupFunctions.push(\n () => openSubscription.unsubscribe(),\n () => closeSubscription.unsubscribe(),\n );\n }\n\n private setupMenuContentListeners(): void {\n const menuContent = this.document.querySelector(ZardMenuDirective.MENU_CONTENT_SELECTOR);\n if (!menuContent) {\n return;\n }\n\n this.addEventListenerWithCleanup(menuContent, 'mouseenter', () => this.cancelScheduledClose());\n this.addEventListenerWithCleanup(menuContent, 'mouseleave', event =>\n this.scheduleCloseIfNeeded(event as MouseEvent),\n );\n }\n\n private cancelScheduledClose(): void {\n if (this.closeTimeout) {\n clearTimeout(this.closeTimeout);\n this.closeTimeout = null;\n }\n }\n\n private scheduleCloseIfNeeded(event: MouseEvent): void {\n if (this.shouldKeepMenuOpen(event.relatedTarget as Element)) {\n return;\n }\n\n this.scheduleMenuClose();\n }\n\n private shouldKeepMenuOpen(relatedTarget: Element | null): boolean {\n if (!relatedTarget) {\n return false;\n }\n\n const isMovingToTrigger = this.elementRef.nativeElement.contains(relatedTarget);\n const isMovingToMenu = relatedTarget.closest(ZardMenuDirective.MENU_CONTENT_SELECTOR);\n const isMovingToOtherTrigger =\n relatedTarget.matches('[z-menu]') && !this.elementRef.nativeElement.contains(relatedTarget);\n\n if (isMovingToOtherTrigger) {\n return false;\n }\n\n return isMovingToTrigger || !!isMovingToMenu;\n }\n\n private scheduleMenuClose(): void {\n this.closeTimeout = setTimeout(() => {\n this.cdkTrigger.close();\n }, this.zHoverDelay());\n }\n\n private addEventListenerWithCleanup(\n element: Element,\n eventType: string,\n handler: (event: MouseEvent | Event) => void,\n options?: AddEventListenerOptions,\n ): void {\n if (isPlatformBrowser(this.platformId)) {\n element.addEventListener(eventType, handler, options);\n this.cleanupFunctions.push(() => element.removeEventListener(eventType, handler, options));\n }\n }\n\n private isMobileDevice(): boolean {\n if (!isPlatformBrowser(this.platformId)) {\n return false; // Default to desktop behavior on server\n }\n\n const window = this.document.defaultView;\n if (!window) {\n return false;\n }\n\n const { navigator } = window;\n const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;\n\n // Check for mobile user agent\n const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;\n const isMobileUA = mobileRegex.test(navigator.userAgent);\n\n // Check viewport width for small screens\n const isSmallScreen = window.innerWidth <= 768;\n\n return hasTouch && (isMobileUA || isSmallScreen);\n }\n}\n"
},
{
"name": "menu.variants.ts",
Expand Down
5 changes: 3 additions & 2 deletions libs/zard/src/lib/shared/components/menu/menu.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ export type ZardMenuTrigger = 'click' | 'hover';

@Directive({
selector: '[z-menu]',
standalone: true,
host: {
role: 'button',
'[attr.tabindex]': "'0'",
'[attr.aria-haspopup]': "'menu'",
'[attr.aria-expanded]': 'cdkTrigger.isOpen()',
'[attr.data-state]': "cdkTrigger.isOpen() ? 'open': 'closed'",
Expand All @@ -46,7 +46,7 @@ export class ZardMenuDirective implements OnInit, OnDestroy {

protected readonly cdkTrigger = inject(CdkMenuTrigger, { host: true });
private readonly document = inject(DOCUMENT);
private readonly elementRef = inject(ElementRef);
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly menuManager = inject(ZardMenuManagerService);
private readonly platformId = inject(PLATFORM_ID);

Expand Down Expand Up @@ -109,6 +109,7 @@ export class ZardMenuDirective implements OnInit, OnDestroy {
return;
}

element.focus({ preventScroll: true });
this.cancelScheduledClose();
this.menuManager.registerHoverMenu(this);
this.cdkTrigger.open();
Expand Down
Loading