Skip to content

Commit

Permalink
feat: adds split-button support to fluent-menu (#32948)
Browse files Browse the repository at this point in the history
  • Loading branch information
eljefe223 authored Oct 4, 2024
1 parent 972683d commit e398359
Show file tree
Hide file tree
Showing 12 changed files with 449 additions and 7 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "feat: adds split-button",
"packageName": "@fluentui/web-components",
"email": "[email protected]",
"dependentChangeType": "patch"
}
41 changes: 38 additions & 3 deletions packages/web-components/docs/api-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -657,9 +657,12 @@ export class BaseRatingDisplay extends FASTElement {
generateIcons(): string;
protected getMaxIcons(): number;
protected getSelectedValue(): number;
icon?: string;
iconViewBox?: string;
max?: number;
// @internal (undocumented)
slottedIcon: HTMLElement[];
// @internal (undocumented)
slottedIconChanged(): void;
value?: number;
}

Expand Down Expand Up @@ -2289,7 +2292,10 @@ export const DividerDefinition: FASTElementDefinition<typeof Divider>;

// @public
export const DividerOrientation: {
readonly horizontal: "horizontal";
readonly horizontal: "horizontal"; /**
* Divider roles
* @public
*/
readonly vertical: "vertical";
};

Expand Down Expand Up @@ -2723,9 +2729,11 @@ export class Menu extends FASTElement {
openOnHoverChanged(oldValue: boolean, newValue: boolean): void;
persistOnItemClick?: boolean;
persistOnItemClickChanged(oldValue: boolean, newValue: boolean): void;
primaryAction: HTMLSlotElement;
setComponent(): void;
slottedMenuList: MenuList[];
slottedTriggers: HTMLElement[];
split?: boolean;
toggleHandler: (e: Event) => void;
toggleMenu: () => void;
triggerKeydownHandler: (e: KeyboardEvent) => boolean | void;
Expand Down Expand Up @@ -3597,7 +3605,10 @@ export const TablistDefinition: FASTElementDefinition<typeof Tablist>;

// @public
export const TablistOrientation: {
readonly horizontal: "horizontal";
readonly horizontal: "horizontal"; /**
* The appearance of the component
* @public
*/
readonly vertical: "vertical";
};

Expand Down Expand Up @@ -4128,6 +4139,30 @@ export const ValidationFlags: {
// @public (undocumented)
export type ValidationFlags = ValuesOf<typeof ValidationFlags>;

// @public
export const zIndexBackground = "var(--zIndexBackground)";

// @public
export const zIndexContent = "var(--zIndexContent)";

// @public
export const zIndexDebug = "var(--zIndexDebug)";

// @public
export const zIndexFloating = "var(--zIndexFloating)";

// @public
export const zIndexMessages = "var(--zIndexMessages)";

// @public
export const zIndexOverlay = "var(--zIndexOverlay)";

// @public
export const zIndexPopup = "var(--zIndexPopup)";

// @public
export const zIndexPriority = "var(--zIndexPriority)";

// Warnings were encountered during analysis:
//
// dist/dts/accordion-item/accordion-item.d.ts:11:5 - (ae-forgotten-export) The symbol "StaticallyComposableHTML" needs to be exported by the entry point index.d.ts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const exampleTheme = {
};
```

You can browse all the available tokens in **[Theme](/docs/theme-color--page)** section of the docs.
You can browse all the available tokens in **[Theme](/docs/theme-tokens--docs)** section of the docs.

## How theme is applied

Expand Down
4 changes: 4 additions & 0 deletions packages/web-components/src/button/button.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ export const baseButtonStyles = css`
}
:is([slot='end'], ::slotted([slot='end'])) {
flex-shrink: 0;
}
:host(:not(${iconOnlyState})) :is([slot='end'], :host(:not(${iconOnlyState}))::slotted([slot='end'])) {
margin-inline-start: var(--icon-spacing);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@ export default {

export const Default: Story = {};

export const IconOnly: Story = {
args: {
iconOnly: true,
},
};

export const Appearance: Story = {
render: renderComponent(html<StoryArgs<FluentMenuButton>>`
<fluent-menu-button>Default</fluent-menu-button>
Expand Down
39 changes: 39 additions & 0 deletions packages/web-components/src/menu/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,4 +342,43 @@ test.describe('Menu', () => {

await expect(menuItems.first()).toBeFocused();
});

test('should focus trigger after menu is closed', async ({ page }) => {
const element = page.locator('fluent-menu');
const menuButton = element.locator('fluent-menu-button');

page.setContent(/* html */ `
<fluent-menu>
<fluent-menu-button appearance="primary" slot="trigger" icon-only></fluent-menu-button>
<fluent-button appearance="primary" slot="primary-action">Primary Action</fluent-menu-button>
<fluent-menu-list>
<fluent-menu-item>
Item 1
<fluent-menu-list slot="submenu">
<fluent-menu-item> Subitem 1 </fluent-menu-item>
<fluent-menu-item> Subitem 2 </fluent-menu-item>
</fluent-menu-list>
</fluent-menu-item>
<fluent-menu-item role="menuitemcheckbox"> Item 2 </fluent-menu-item>
<fluent-menu-item role="menuitemcheckbox"> Item 3 </fluent-menu-item>
<fluent-divider role="separator" aria-orientation="horizontal" orientation="horizontal"></fluent-divider>
<fluent-menu-item>Menu item 4</fluent-menu-item>
<fluent-menu-item>Menu item 5</fluent-menu-item>
<fluent-menu-item>Menu item 6</fluent-menu-item>
<fluent-menu-item>Menu item 7</fluent-menu-item>
<fluent-menu-item>Menu item 8</fluent-menu-item>
</fluent-menu-list>
</fluent-menu>
`);

await menuButton.click();

await page.keyboard.press('Escape');

await expect(menuButton).toBeFocused();
});
});
58 changes: 57 additions & 1 deletion packages/web-components/src/menu/menu.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,22 @@ const defaultTriggerSlottedContent = html`<fluent-menu-button

const storyTemplate = html<StoryArgs<FluentMenu>>`
<fluent-menu
?split="${story => story.split}"
?open-on-hover="${story => story.openOnHover}"
?open-on-context="${story => story.openOnContext}"
?close-on-scroll="${story => story.closeOnScroll}"
?persist-on-item-click="${story => story.persistOnItemClick}"
style="${story => (story['--menu-max-height'] !== '' ? `--menu-max-height: ${story['--menu-max-height']};` : '')}"
>
${story => story.triggerSlottedContent?.()} ${story => story.slottedContent?.()}
${story => story.primaryActionSlottedContent?.()} ${story => story.triggerSlottedContent?.()}
${story => story.slottedContent?.()}
</fluent-menu>
`;

const generatePimaryActionSlottedContent = (content: string = 'Primary Action') => {
return html`<fluent-button appearance="primary" slot="primary-action">${content}</fluent-button>`;
};

export default {
title: 'Components/Menu',
render: renderComponent(storyTemplate),
Expand All @@ -41,6 +47,12 @@ export default {
'--menu-max-height': 'auto',
},
argTypes: {
split: {
control: 'boolean',
description: 'Sets the split visual state. Used in Cordination with the `primary-action` slot.',
name: 'split',
table: { category: 'attributes', type: { summary: 'boolean' } },
},
openOnHover: {
control: 'boolean',
description: 'Sets whether menu opens on hover',
Expand Down Expand Up @@ -73,6 +85,24 @@ export default {
category: 'CSS Custom Properties',
},
},
slottedContent: {
control: false,
description: 'The default slot',
name: '',
table: { category: 'slots', type: {} },
},
primaryActionSlottedContent: {
control: false,
description: 'The primary action slot. Used when the menu is `split`',
name: '',
table: { category: 'slots', type: {} },
},
triggerSlottedContent: {
control: false,
description: 'The trigger slot',
name: '',
table: { category: 'slots', type: {} },
},
},
} as Meta<FluentMenu>;

Expand Down Expand Up @@ -133,3 +163,29 @@ export const MenuWithInteractiveItems: Story = {
`,
},
};

const link = '<a href="/docs/components-button-split-button--docs">split-button</a>';

const storyDescription = `
The split-button variation, refer to ${link} for more examples.
`;

export const SplitButton: Story = {
parameters: {
docs: {
description: {
story: storyDescription,
},
},
},
args: {
split: true,
triggerSlottedContent: () => html`<fluent-menu-button
aria-label="Toggle Menu"
appearance="primary"
slot="trigger"
icon-only
></fluent-menu-button>`,
primaryActionSlottedContent: () => html`${generatePimaryActionSlottedContent()}`,
},
};
26 changes: 26 additions & 0 deletions packages/web-components/src/menu/menu.styles.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { css } from '@microsoft/fast-element';
import { display } from '../utils/index.js';
import { colorNeutralStroke1, strokeWidthThin } from '../theme/design-tokens.js';

/** Menu styles
* @public
Expand Down Expand Up @@ -28,4 +29,29 @@ export const styles = css`
::slotted([popover]:not(:popover-open)) {
display: none;
}
:host([split]) {
display: inline-flex;
}
:host([split]) ::slotted([slot='primary-action']) {
border-inline-end: ${strokeWidthThin} solid ${colorNeutralStroke1};
border-start-end-radius: 0;
border-end-end-radius: 0;
}
/* Keeps focus visible visuals above trigger slot*/
:host([split]) ::slotted([slot='primary-action']:focus-visible) {
z-index: 1;
}
:host([split]) ::slotted([slot='primary-action'][appearance='primary']) {
border-inline-end: ${strokeWidthThin} solid white;
}
:host([split]) ::slotted([slot='trigger']) {
border-inline-start: 0;
border-start-start-radius: 0;
border-end-start-radius: 0;
}
`;
3 changes: 2 additions & 1 deletion packages/web-components/src/menu/menu.template.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { elements, ElementViewTemplate, html, slotted } from '@microsoft/fast-element';
import { elements, ElementViewTemplate, html, ref, slotted } from '@microsoft/fast-element';
import type { Menu } from './menu.js';

export function menuTemplate<T extends Menu>(): ElementViewTemplate<T> {
Expand All @@ -10,6 +10,7 @@ export function menuTemplate<T extends Menu>(): ElementViewTemplate<T> {
?persist-on-item-click="${x => x.persistOnItemClick}"
@keydown="${(x, c) => x.menuKeydownHandler(c.event as KeyboardEvent)}"
>
<slot name="primary-action" ${ref('primaryAction')}></slot>
<slot name="trigger" ${slotted({ property: 'slottedTriggers', filter: elements() })}></slot>
<slot ${slotted({ property: 'slottedMenuList', filter: elements() })}></slot>
</template>
Expand Down
22 changes: 21 additions & 1 deletion packages/web-components/src/menu/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import { MenuItemRole } from '../menu-item/menu-item.options.js';
* @attr open-on-context - Determines if the menu should open on right click.
* @attr close-on-scroll - Determines if the menu should close on scroll.
* @attr persist-on-item-click - Determines if the menu open state should persist on click of menu item.
* @attr split - Determines if the menu is in split state.
*
* @cssproperty --menu-max-height - The max-height of the menu.
*
* @slot primary-action - Slot for the primary action elements. Used when in `split` state.
* @slot trigger - Slot for the trigger elements.
* @slot - Default slot for the menu list.
*
Expand Down Expand Up @@ -72,6 +74,13 @@ export class Menu extends FASTElement {
@attr({ attribute: 'persist-on-item-click', mode: 'boolean' })
public persistOnItemClick?: boolean;

/**
* Determines if the menu is in split state.
* @public
*/
@attr({ mode: 'boolean' })
public split?: boolean;

/**
* Holds the slotted menu list.
* @public
Expand All @@ -86,6 +95,13 @@ export class Menu extends FASTElement {
@observable
public slottedTriggers: HTMLElement[] = [];

/**
* Holds the primary slot element.
* @public
*/
@observable
public primaryAction!: HTMLSlotElement;

/**
* Defines whether the menu is open or not.
* @internal
Expand Down Expand Up @@ -360,7 +376,11 @@ export class Menu extends FASTElement {
break;
case keyTab:
if (this._open) this.closeMenu();
if (e.shiftKey && e.composedPath()[0] !== this._trigger) {
if (
e.shiftKey &&
e.composedPath()[0] !== this._trigger &&
(e.composedPath()[0] as HTMLElement).assignedSlot !== this.primaryAction
) {
this.focusTrigger();
} else if (e.shiftKey) {
return true;
Expand Down
Loading

0 comments on commit e398359

Please sign in to comment.