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
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Directive, ElementRef, EventEmitter, HostListener, Input, Output, Renderer2 } from '@angular/core';
import { Directive, ElementRef, Input, Renderer2 } from '@angular/core';
import type { MenuButton } from '@ni/nimble-components/dist/esm/menu-button';
import type { ButtonAppearance } from '@ni/nimble-components/dist/esm/menu-button/types';
import type { ButtonAppearance, MenuButtonToggleEventDetail } from '@ni/nimble-components/dist/esm/menu-button/types';
import { BooleanValueOrAttribute, toBooleanProperty } from '../utilities/template-value-helpers';

export type { MenuButton };
export type { MenuButtonToggleEventDetail };

/**
* Directive to provide Angular integration for the menu button.
Expand Down Expand Up @@ -46,14 +47,5 @@ export class NimbleMenuButtonDirective {
this.renderer.setProperty(this.elementRef.nativeElement, 'open', toBooleanProperty(value));
}

@Output() public openChange = new EventEmitter<boolean>();
Comment thread
mollykreis marked this conversation as resolved.

public constructor(private readonly renderer: Renderer2, private readonly elementRef: ElementRef<MenuButton>) {}

@HostListener('open-change', ['$event'])
public onOpenChange($event: Event): void {
if ($event.target === this.elementRef.nativeElement) {
this.openChange.emit(this.open);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "major",
"comment": "Add 'beforetoggle' event on menu button and rename 'open-change' event to 'toggle'",
"packageName": "@ni/nimble-angular",
"email": "20542556+mollykreis@users.noreply.github.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "major",
"comment": "Add 'beforetoggle' event on menu button and rename 'open-change' event to 'toggle'",
"packageName": "@ni/nimble-blazor",
"email": "20542556+mollykreis@users.noreply.github.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "major",
"comment": "Add 'beforetoggle' event on menu button and rename 'open-change' event to 'toggle'.\nUpdate menu button to work when the slotted menu is nested within additional slots.",
"packageName": "@ni/nimble-components",
"email": "20542556+mollykreis@users.noreply.github.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
@inherits ComponentBase
<nimble-menu-button
open="@BindConverter.FormatValue(Open)"
@onnimblemenubuttonopenchange="(__value) => UpdateOpen(__value.Open)"
@onnimblemenubuttontoggle="(__value) => HandleToggle(__value)"
@onnimblemenubuttonbeforetoggle="(__value) => HandleBeforeToggle(__value)"
appearance="@Appearance.ToAttributeValue()"
position="@Position.ToAttributeValue()"
disabled="@Disabled"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,31 @@ public partial class NimbleMenuButton : ComponentBase
/// Gets or sets a callback that's invoked when 'open' changes
/// </summary>
[Parameter]
public EventCallback<bool?> OpenChanged { get; set; }
public EventCallback<MenuButtonToggleEventArgs> Toggle { get; set; }

/// <summary>
/// Gets or sets a callback that's invoked before the 'open' state of the menu button changes
/// </summary>
[Parameter]
public EventCallback<MenuButtonToggleEventArgs> BeforeToggle { get; set; }

/// <summary>
/// Called when 'open' changes on the web component.
/// </summary>
/// <param name="value">New value of open</param>
protected async void UpdateOpen(bool? value)
/// <param name="eventArgs">The state of the menu button</param>
protected async void HandleToggle(MenuButtonToggleEventArgs eventArgs)
{
Open = eventArgs.NewState;
await Toggle.InvokeAsync(eventArgs);
}

/// <summary>
/// Called when the 'beforetoggle' event is fired on the web component
/// </summary>
/// <param name="eventArgs">The state of the menu button</param>
protected async void HandleBeforeToggle(MenuButtonToggleEventArgs eventArgs)
{
Open = value;
await OpenChanged.InvokeAsync(value);
await BeforeToggle.InvokeAsync(eventArgs);
}

/// <summary>
Expand Down
8 changes: 5 additions & 3 deletions packages/nimble-blazor/NimbleBlazor/EventHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ public class CheckboxChangeEventArgs : EventArgs
public bool Checked { get; set; }
}

public class MenuButtonOpenChangeEventArgs : EventArgs
public class MenuButtonToggleEventArgs : EventArgs
{
public bool Open { get; set; }
public bool NewState { get; set; }
public bool OldState { get; set; }
}

[EventHandler("onnimbletabsactiveidchange", typeof(TabsChangeEventArgs), enableStopPropagation: true, enablePreventDefault: true)]
[EventHandler("onnimblecheckedchange", typeof(CheckboxChangeEventArgs), enableStopPropagation: true, enablePreventDefault: true)]
[EventHandler("onnimblemenubuttonopenchange", typeof(MenuButtonOpenChangeEventArgs), enableStopPropagation: true, enablePreventDefault: true)]
[EventHandler("onnimblemenubuttontoggle", typeof(MenuButtonToggleEventArgs), enableStopPropagation: true, enablePreventDefault: false)]
[EventHandler("onnimblemenubuttonbeforetoggle", typeof(MenuButtonToggleEventArgs), enableStopPropagation: true, enablePreventDefault: false)]
public static class EventHandlers
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,22 @@ export function afterStarted(Blazor) {
}
});
// Used by NimbleMenuButton.razor
Blazor.registerCustomEventType('nimblemenubuttonopenchange', {
browserEventName: 'open-change',
Blazor.registerCustomEventType('nimblemenubuttontoggle', {
browserEventName: 'toggle',
createEventArgs: event => {
return {
open: event.target.open
newState: event.detail.newState,
oldState: event.detail.oldState
};
}
});
// Used by NimbleMenuButton.razor
Blazor.registerCustomEventType('nimblemenubuttonbeforetoggle', {
browserEventName: 'beforetoggle',
createEventArgs: event => {
return {
newState: event.detail.newState,
oldState: event.detail.oldState
};
}
});
Expand Down
86 changes: 71 additions & 15 deletions packages/nimble-components/src/menu-button/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { ButtonAppearance } from '../button/types';
import type { ToggleButton } from '../toggle-button';
import { styles } from './styles';
import { template } from './template';
import { MenuButtonPosition } from './types';
import { MenuButtonToggleEventDetail, MenuButtonPosition } from './types';
import type { ButtonPattern } from '../patterns/button/types';
import type { AnchoredRegion } from '../anchored-region';

Expand Down Expand Up @@ -108,7 +108,11 @@ export class MenuButton extends FoundationElement implements ButtonPattern {
if (!this.open) {
// Only fire an event here if the menu is changing to being closed. Otherwise,
// wait until the menu is actually opened before firing the event.
this.$emit('open-change');
const eventDetail: MenuButtonToggleEventDetail = {
oldState: true,
newState: false
};
this.$emit('toggle', eventDetail);
}
}

Expand All @@ -120,7 +124,11 @@ export class MenuButton extends FoundationElement implements ButtonPattern {
this.focusMenu();
}

this.$emit('open-change');
const eventDetail: MenuButtonToggleEventDetail = {
oldState: false,
newState: true
};
this.$emit('toggle', eventDetail);
}

public focusoutHandler(e: FocusEvent): boolean {
Expand All @@ -129,18 +137,21 @@ export class MenuButton extends FoundationElement implements ButtonPattern {
}

const focusTarget = e.relatedTarget as HTMLElement;
if (!this.contains(focusTarget)) {
this.open = false;
if (
!this.contains(focusTarget)
&& !this.getMenu()?.contains(focusTarget)
) {
this.setOpen(false);
return false;
}

return true;
}

public toggleButtonCheckedChangeHandler(e: Event): boolean {
this.open = this.toggleButton!.checked;
this.setOpen(this.toggleButton!.checked);
// Don't bubble the 'change' event from the toggle button because
// the menu button has its own 'open-change' event.
// the menu button has its own 'toggle' event.
e.stopPropagation();
return false;
}
Expand All @@ -149,10 +160,10 @@ export class MenuButton extends FoundationElement implements ButtonPattern {
switch (e.key) {
case keyArrowUp:
this.focusLastItemWhenOpened = true;
this.open = true;
this.setOpen(true);
return false;
case keyArrowDown:
this.open = true;
this.setOpen(true);
return false;
default:
return true;
Expand All @@ -162,32 +173,77 @@ export class MenuButton extends FoundationElement implements ButtonPattern {
public menuKeyDownHandler(e: KeyboardEvent): boolean {
switch (e.key) {
case keyEscape:
this.open = false;
this.setOpen(false);
this.toggleButton!.focus();
return false;
default:
return true;
}
}

private get menu(): HTMLElement | undefined {
return this.slottedMenus?.length ? this.slottedMenus[0] : undefined;
private setOpen(newValue: boolean): void {
if (this.open === newValue) {
return;
}

const eventDetail: MenuButtonToggleEventDetail = {
oldState: this.open,
newState: newValue
};
this.$emit('beforetoggle', eventDetail);

this.open = newValue;
}

private getMenu(): HTMLElement | undefined {
// Get the menu that is slotted within the menu-button, taking into account
// that it may be nested within multiple 'slot' elements, such as when used
// within a table.
if (!this.slottedMenus?.length) {
return undefined;
}

let currentItem: HTMLElement | undefined = this.slottedMenus[0];
while (currentItem) {
if (currentItem.getAttribute('role') === 'menu') {
return currentItem;
}

if (this.isSlotElement(currentItem)) {
const firstNode = currentItem.assignedNodes()[0];
if (firstNode instanceof HTMLElement) {
currentItem = firstNode;
} else {
currentItem = undefined;
}
} else {
return undefined;
}
}

return undefined;
}

private isSlotElement(
element: HTMLElement | undefined
): element is HTMLSlotElement {
return element?.nodeName === 'SLOT';
}

private focusMenu(): void {
this.menu?.focus();
this.getMenu()?.focus();
}

private focusLastMenuItem(): void {
const menuItems = this.menu?.querySelectorAll('[role=menuitem]');
const menuItems = this.getMenu()?.querySelectorAll('[role=menuitem]');
if (menuItems?.length) {
const lastMenuItem = menuItems[menuItems.length - 1] as HTMLElement;
lastMenuItem.focus();
}
}

private readonly menuChangeHandler = (): void => {
this.open = false;
this.setOpen(false);
this.toggleButton!.focus();
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,12 @@ _Methods_

_Events_

- `open-change` (event) - event for when the opened state has changed
- `beforetoggle` (event) - event fired before the opened state has changed. The event detail contains:
- `newState` - boolean - The value of `open` on the menu button that the element is transitioning in to.
- `oldState` - boolean - The value of `open` on the menu button that the element is transitioning out of.
- `toggle` (event) - event for when the opened state has changed
- `newState` - boolean - The value of `open` on the menu button that the element transitioned in to.
- `oldState` - boolean - The value of `open` on the menu button that the element transitioned out of.

_CSS Classes and CSS Custom Properties that affect the component_

Expand Down
4 changes: 2 additions & 2 deletions packages/nimble-components/src/menu-button/template.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { elements, html, ref, slotted, when } from '@microsoft/fast-element';
import { html, ref, slotted, when } from '@microsoft/fast-element';
import { DesignSystem } from '@microsoft/fast-foundation';
import type { MenuButton } from '.';
import { ToggleButton } from '../toggle-button';
Expand Down Expand Up @@ -43,7 +43,7 @@ export const template = html<MenuButton>`
${ref('region')}
>
<span part="menu">
<slot name="menu" ${slotted({ property: 'slottedMenus', filter: elements('[role=menu]') })}></slot>
<slot name="menu" ${slotted({ property: 'slottedMenus' })}></slot>
</span>
</${DesignSystem.tagFor(AnchoredRegion)}>
`
Expand Down
Loading