From f2dff4d019e4000b9417c415a8ac1ae6a8617187 Mon Sep 17 00:00:00 2001 From: BrdyBrn Date: Thu, 20 Apr 2023 13:13:30 -0700 Subject: [PATCH] Adds Menu and MenuItem as new web components (#26765) * menu init * updates docs * menu init * menu item init * registers menu-item * adds some menu styles * adds default menu item icons * adds menu stories * updates default menu icons * styles menu and menu item * updates readme * swaps part for class css * removes dead code * adds menu and menu item to package.json * removes dead code * updates menu styles * yarn change * adds menu header * updates jsdocs * removes dead file * removes dead code * removes split button styling * adds display helper * api report * flattens menu and menu item directories * removes element specification from slotted selector * formatting * optimizes styles * optmizes styles * updates disabled css selector to bool * removes dead code * consolidates menu docs into one story * updates menu storybook content * optimizes styling * revert api-report * updates disabled pseudo selector * fixes disabled state * fixes css syntax error * updates storybook content * updates storybook copy for consistency * revert api report * updates yarn change message * maps component to MenuList rather than Menu * fixes focus state * updates styles * divides accordion and accordion item readme * removes dead imports * fixes submenu positioning * adds styles for icon alignment * reverts Menu component name * cleans up storybook * dynamically set the menu item icon attribute when icons is true for menu (#26998) * dynamically set the menu item icon attribute when icons is true for menu * move icons check to set items to ensure we update on item change and init * set items should be protected * updates Menu styles * updates icon alignment styles and docs * updates menu docs * adds checkmarks attribute and styles menu items * adds conditional styling for when icons are present * updates Menu and MenuItem docs * updates deltas in docs * updates docs * optimizes styles * optimizes css * optimizes css * optimizes css * reverts json export * working (#27293) * optimizes styles * adds docs to menu.ts * updates menu logic for readability * menu: fixes style syntax error * menu: consolidates styles * menu: updates styling * menu: updates styling * yarn change * menulist, menuitem: changes component name to MenuList * menulist, menuitem, updates readme * menu, menuitem: fixes circular dep * menu, menuitem: alphabetize index.js --------- Co-authored-by: Chris Holt Co-authored-by: Jeff Smith <37851214+eljefe223@users.noreply.github.com> --- ...-e1aaa3a2-771d-4c16-b8d2-a56deb1fdd28.json | 7 + packages/web-components/package.json | 16 +- packages/web-components/src/index.ts | 2 + .../web-components/src/menu-item/README.md | 125 +++++ .../web-components/src/menu-item/define.ts | 4 + .../web-components/src/menu-item/index.ts | 4 + .../src/menu-item/menu-item.definition.ts | 19 + .../src/menu-item/menu-item.styles.ts | 494 +++++++----------- .../src/menu-item/menu-item.template.ts | 17 + .../web-components/src/menu-item/menu-item.ts | 9 + .../web-components/src/menu-list/README.md | 104 ++++ .../web-components/src/menu-list/define.ts | 4 + .../web-components/src/menu-list/index.ts | 4 + .../src/menu-list/menu-list.definition.ts | 19 + .../src/menu-list/menu-list.stories.ts | 289 ++++++++++ .../src/menu-list/menu-list.styles.ts | 29 + .../src/menu-list/menu-list.template.ts | 5 + .../web-components/src/menu-list/menu-list.ts | 43 ++ 18 files changed, 898 insertions(+), 296 deletions(-) create mode 100644 change/@fluentui-web-components-e1aaa3a2-771d-4c16-b8d2-a56deb1fdd28.json create mode 100644 packages/web-components/src/menu-item/README.md create mode 100644 packages/web-components/src/menu-item/define.ts create mode 100644 packages/web-components/src/menu-item/index.ts create mode 100644 packages/web-components/src/menu-item/menu-item.definition.ts create mode 100644 packages/web-components/src/menu-item/menu-item.template.ts create mode 100644 packages/web-components/src/menu-item/menu-item.ts create mode 100644 packages/web-components/src/menu-list/README.md create mode 100644 packages/web-components/src/menu-list/define.ts create mode 100644 packages/web-components/src/menu-list/index.ts create mode 100644 packages/web-components/src/menu-list/menu-list.definition.ts create mode 100644 packages/web-components/src/menu-list/menu-list.stories.ts create mode 100644 packages/web-components/src/menu-list/menu-list.styles.ts create mode 100644 packages/web-components/src/menu-list/menu-list.template.ts create mode 100644 packages/web-components/src/menu-list/menu-list.ts diff --git a/change/@fluentui-web-components-e1aaa3a2-771d-4c16-b8d2-a56deb1fdd28.json b/change/@fluentui-web-components-e1aaa3a2-771d-4c16-b8d2-a56deb1fdd28.json new file mode 100644 index 00000000000000..1772b2b7ad1bf0 --- /dev/null +++ b/change/@fluentui-web-components-e1aaa3a2-771d-4c16-b8d2-a56deb1fdd28.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feat(menu-list): Add menu-list and menu-item web components", + "packageName": "@fluentui/web-components", + "email": "brianbrady@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/web-components/package.json b/packages/web-components/package.json index ecde02e6ede6d6..996a0464aa901d 100644 --- a/packages/web-components/package.json +++ b/packages/web-components/package.json @@ -68,10 +68,18 @@ "types": "./dist/esm/label/define.d.ts", "default": "./dist/esm/label/define.js" }, + "./menu-list": { + "types": "./dist/esm/menu-list/define.d.ts", + "default": "./dist/esm/menu-list/define.js" + }, "./menu-button": { "types": "./dist/esm/menu-button/define.d.ts", "default": "./dist/esm/menu-button/define.js" }, + "./menu-item": { + "types": "./dist/esm/menu-item/define.d.ts", + "default": "./dist/esm/menu-item/define.js" + }, "./progress-bar": { "types": "./dist/esm/progress-bar/define.d.ts", "default": "./dist/esm/progress-bar/define.js" @@ -88,14 +96,14 @@ "types": "./dist/esm/switch/define.d.ts", "default": "./dist/esm/switch/define.js" }, - "./tabs": { - "types": "./dist/esm/tabs/define.d.ts", - "default": "./dist/esm/tabs/define.js" - }, "./tab": { "types": "./dist/esm/tab/define.d.ts", "default": "./dist/esm/tab/define.js" }, + "./tabs": { + "types": "./dist/esm/tabs/define.d.ts", + "default": "./dist/esm/tabs/define.js" + }, "./tab-panel": { "types": "./dist/esm/tab-panel/define.d.ts", "default": "./dist/esm/tab-panel/define.js" diff --git a/packages/web-components/src/index.ts b/packages/web-components/src/index.ts index b006d274be2b79..cfbb551238a382 100644 --- a/packages/web-components/src/index.ts +++ b/packages/web-components/src/index.ts @@ -10,6 +10,8 @@ export * from './divider/index.js'; export * from './image/index.js'; export * from './label/index.js'; export * from './menu-button/index.js'; +export * from './menu-item/index.js'; +export * from './menu-list/index.js'; export * from './progress-bar/index.js'; export * from './slider/index.js'; export * from './spinner/index.js'; diff --git a/packages/web-components/src/menu-item/README.md b/packages/web-components/src/menu-item/README.md new file mode 100644 index 00000000000000..3bba2d88611a99 --- /dev/null +++ b/packages/web-components/src/menu-item/README.md @@ -0,0 +1,125 @@ +# Menu Item + +Menu list item options displayed in a MenuList component. They are invoked when users interact with a button, action, or other control. + +
+ +
+ +**Remaining work items** + +2. Create support for menu item "grouping" +3. Split button variation + +
+
+
+ +## Design Spec + +[Link to Menu Item Design Spec in Figma](https://www.figma.com/file/jFWrkFq61GDdOhPlsz6AtX/Menu?node-id=1528%3A5102&t=XtW4laeEzgVFIl1E-0) + +
+
+
+ +## Engineering Spec + +Fluent WC3 Menu extends from the FAST Menu [FAST Menu Item](https://explore.fast.design/components/fast-menu) and is intended to be as close to the Fluent UI React 9 Menu implementation as possible. However, due to the nature of web components there will not be 100% parity between the two. + +
+ +### Inputs + +- `role` - an enum representing the menu items' role + - `menuitem` + - `menuitemcheckbox` + - `menuitemradio` +- `disabled` - the menu item is disabled +- `checked` - sets the checked value for menuitemcheckbox or menuitemradio items + +### Outputs + +- none + +### Events + +- `click` (event) - event for when the item has been clicked or invoked via keyboard +- `change` (event) - event for when the item has been clicked or invoked via keyboard, and will be prevented if the menu item is disabled +- `expanded-change` (event) - event for when the item has been expanded or collapsed + +### Slots + +- `before` - slot which precedes content +- `default` - slot for the content (the default slot for the item) +- `after` - slot which comes after content +- `submenu` - the slot used to generate a submenu +- `radio-indicator` - slot for radio item selection indicator +- `checkbox-indicator` - slot for the checkbox selection indicator +- `expand-collapse-glyph` - slot for the expand/collapse glyph for nested menus + +### CSS Variables + +- `borderRadiusMedium` +- `colorCompoundBrandForeground1Hover` +- `colorCompoundBrandForeground1Pressed` +- `colorNeutralBackground1` +- `colorNeutralBackground1Hover` +- `colorNeutralBackground1Pressed` +- `colorNeutralBackgroundDisabled` +- `colorNeutralForeground2` +- `colorNeutralForeground2Hover` +- `colorNeutralForeground2Pressed` +- `colorNeutralForeground3` +- `colorNeutralForegroundDisabled` +- `colorNeutralStrokeDisabled` +- `fontFamilyBase` +- `fontSizeBase200` +- `fontSizeBase300` +- `fontWeightRegular` +- `fontWeightSemibold` +- `lineHeightBase200` +- `lineHeightBase300` + +
+
+
+ +## Accessibility + +
+ +**ARIA Attributes** + +| Attribute | Options | Description | +| ------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| aria-checked | boolean | +| aria-disabled | boolean | indicates that the element is perceivable but disabled, so it is not editable or otherwise operable | +| role | `menuitem` \| `menuitemcheckbox` \| `menuitemradio` | an enum representing the menu items' role | + +
+
+ +## Preparation + +
+ +### **Fluent Web Component v3 v.s Fluent React 9** + +Due to the nature of Web Components there will not be 100% parity between component implementation in Fluent UI React v9 and Fluent Web Components v3. + +
+ +**Component, Slot, and Attribute Mapping** +Component, Slot, or Attribute | Fluent React v9 | Fluent Web Components v3 | +---------------------------------| ---------------------| ---------------------------| +Menu | `` | `` | +Menu item |`` | `` | +Menu item with radio | `` | `..` | +Menu item with checkbox | `` | `..` | +Icons | `}>` | `..`
`..`| +Menu group header | `` | ``| + +**Additional Deltas** + +In order for icons to render with appropriate styles the `icons` attribute must be present on the Menu. diff --git a/packages/web-components/src/menu-item/define.ts b/packages/web-components/src/menu-item/define.ts new file mode 100644 index 00000000000000..5601bcf73fa659 --- /dev/null +++ b/packages/web-components/src/menu-item/define.ts @@ -0,0 +1,4 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { definition } from './menu-item.definition.js'; + +definition.define(FluentDesignSystem.registry); diff --git a/packages/web-components/src/menu-item/index.ts b/packages/web-components/src/menu-item/index.ts new file mode 100644 index 00000000000000..9e578159286126 --- /dev/null +++ b/packages/web-components/src/menu-item/index.ts @@ -0,0 +1,4 @@ +export * from './menu-item.js'; +export { template as MenuItemTemplate } from './menu-item.template.js'; +export { styles as MenuItemStyles } from './menu-item.styles.js'; +export { definition as MenuItemDefinition } from './menu-item.definition.js'; diff --git a/packages/web-components/src/menu-item/menu-item.definition.ts b/packages/web-components/src/menu-item/menu-item.definition.ts new file mode 100644 index 00000000000000..a923d901bcefc9 --- /dev/null +++ b/packages/web-components/src/menu-item/menu-item.definition.ts @@ -0,0 +1,19 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { MenuItem } from './menu-item.js'; +import { styles } from './menu-item.styles.js'; +import { template } from './menu-item.template.js'; + +/** + * The Fluent Menu Item Element. Implements {@link @microsoft/fast-foundation#MenuItem }, + * {@link @microsoft/fast-foundation#menuItemTemplate} + * + * + * @public + * @remarks + * HTML Element: + */ +export const definition = MenuItem.compose({ + name: `${FluentDesignSystem.prefix}-menu-item`, + template, + styles, +}); diff --git a/packages/web-components/src/menu-item/menu-item.styles.ts b/packages/web-components/src/menu-item/menu-item.styles.ts index 851fab9348f5a7..a50fb2173d2bc0 100644 --- a/packages/web-components/src/menu-item/menu-item.styles.ts +++ b/packages/web-components/src/menu-item/menu-item.styles.ts @@ -1,293 +1,203 @@ -import { css, ElementStyles } from '@microsoft/fast-element'; +import { css } from '@microsoft/fast-element'; +import { display } from '@microsoft/fast-foundation'; import { - disabledCursor, - display, - ElementDefinitionContext, - focusVisible, - forcedColorsStylesheetBehavior, - MenuItemOptions, -} from '@microsoft/fast-foundation'; -import { SystemColors } from '@microsoft/fast-web-utilities'; -import { DirectionalStyleSheetBehavior, heightNumber } from '../styles/index'; -import { - controlCornerRadius, - disabledOpacity, - neutralFillStealthActive, - neutralFillStealthHover, - neutralForegroundHint, - neutralForegroundRest, - strokeWidth, -} from '../design-tokens'; -import { typeRampBase } from '../styles/patterns/type-ramp'; -import { focusTreatmentBase } from '../styles/focus'; - -export const menuItemStyles: (context: ElementDefinitionContext, definition: MenuItemOptions) => ElementStyles = ( - context: ElementDefinitionContext, - definition: MenuItemOptions, -) => - css` - ${display('grid')} :host { - contain: layout; - overflow: visible; - ${typeRampBase} - box-sizing: border-box; - height: calc(${heightNumber} * 1px); - grid-template-columns: minmax(32px, auto) 1fr minmax(32px, auto); - grid-template-rows: auto; - justify-items: center; - align-items: center; - padding: 0; - white-space: nowrap; - color: ${neutralForegroundRest}; - fill: currentcolor; - cursor: pointer; - border-radius: calc(${controlCornerRadius} * 1px); - border: calc(${strokeWidth} * 1px) solid transparent; - position: relative; - } - - :host(.indent-0) { - grid-template-columns: auto 1fr minmax(32px, auto); - } - - :host(.indent-0) .content { - grid-column: 1; - grid-row: 1; - margin-inline-start: 10px; - } - - :host(.indent-0) .expand-collapse-glyph-container { - grid-column: 5; - grid-row: 1; - } - - :host(.indent-2) { - grid-template-columns: minmax(32px, auto) minmax(32px, auto) 1fr minmax(32px, auto) minmax(32px, auto); - } - - :host(.indent-2) .content { - grid-column: 3; - grid-row: 1; - margin-inline-start: 10px; - } - - :host(.indent-2) .expand-collapse-glyph-container { - grid-column: 5; - grid-row: 1; - } - - :host(.indent-2) .start { - grid-column: 2; - } - - :host(.indent-2) .end { - grid-column: 4; - } - - :host(:${focusVisible}) { - ${focusTreatmentBase} - } - - :host(:not([disabled]):hover) { - background: ${neutralFillStealthHover}; - } - - :host(:not([disabled]):active), - :host(.expanded) { - background: ${neutralFillStealthActive}; - color: ${neutralForegroundRest}; - z-index: 2; - } - - :host([disabled]) { - cursor: ${disabledCursor}; - opacity: ${disabledOpacity}; - } - - .content { - grid-column-start: 2; - justify-self: start; - overflow: hidden; - text-overflow: ellipsis; - } - - .start, - .end { - display: flex; - justify-content: center; - } - - :host(.indent-0[aria-haspopup='menu']) { - display: grid; - grid-template-columns: minmax(32px, auto) auto 1fr minmax(32px, auto) minmax(32px, auto); - align-items: center; - min-height: 32px; - } - - :host(.indent-1[aria-haspopup='menu']), - :host(.indent-1[role='menuitemcheckbox']), - :host(.indent-1[role='menuitemradio']) { - display: grid; - grid-template-columns: minmax(32px, auto) auto 1fr minmax(32px, auto) minmax(32px, auto); - align-items: center; - min-height: 32px; - } - - :host(.indent-2:not([aria-haspopup='menu'])) .end { - grid-column: 5; - } - - :host .input-container, - :host .expand-collapse-glyph-container { - display: none; - } - - :host([aria-haspopup='menu']) .expand-collapse-glyph-container, - :host([role='menuitemcheckbox']) .input-container, - :host([role='menuitemradio']) .input-container { - display: grid; - } - - :host([aria-haspopup='menu']) .content, - :host([role='menuitemcheckbox']) .content, - :host([role='menuitemradio']) .content { - grid-column-start: 3; - } - - :host([aria-haspopup='menu'].indent-0) .content { - grid-column-start: 1; - } - - :host([aria-haspopup='menu']) .end, - :host([role='menuitemcheckbox']) .end, - :host([role='menuitemradio']) .end { - grid-column-start: 4; - } - - :host .expand-collapse, - :host .checkbox, - :host .radio { - display: flex; - align-items: center; - justify-content: center; - position: relative; - box-sizing: border-box; - } - - :host .checkbox-indicator, - :host .radio-indicator, - slot[name='checkbox-indicator'], - slot[name='radio-indicator'] { - display: none; - } - - ::slotted([slot='end']:not(svg)) { - margin-inline-end: 10px; - color: ${neutralForegroundHint}; - } - - :host([aria-checked='true']) .checkbox-indicator, - :host([aria-checked='true']) slot[name='checkbox-indicator'], - :host([aria-checked='true']) .radio-indicator, - :host([aria-checked='true']) slot[name='radio-indicator'] { - display: flex; - } - `.withBehaviors( - forcedColorsStylesheetBehavior( - css` - :host, - ::slotted([slot='end']:not(svg)) { - forced-color-adjust: none; - color: ${SystemColors.ButtonText}; - fill: currentcolor; - } - :host(:not([disabled]):hover) { - background: ${SystemColors.Highlight}; - color: ${SystemColors.HighlightText}; - fill: currentcolor; - } - :host(:hover) .start, - :host(:hover) .end, - :host(:hover)::slotted(svg), - :host(:active) .start, - :host(:active) .end, - :host(:active)::slotted(svg), - :host(:hover) ::slotted([slot='end']:not(svg)), - :host(:${focusVisible}) ::slotted([slot='end']:not(svg)) { - color: ${SystemColors.HighlightText}; - fill: currentcolor; - } - :host(.expanded) { - background: ${SystemColors.Highlight}; - color: ${SystemColors.HighlightText}; - } - :host(:${focusVisible}) { - background: ${SystemColors.Highlight}; - outline-color: ${SystemColors.ButtonText}; - color: ${SystemColors.HighlightText}; - fill: currentcolor; - } - :host([disabled]), - :host([disabled]:hover), - :host([disabled]:hover) .start, - :host([disabled]:hover) .end, - :host([disabled]:hover)::slotted(svg), - :host([disabled]:${focusVisible}) { - background: ${SystemColors.ButtonFace}; - color: ${SystemColors.GrayText}; - fill: currentcolor; - opacity: 1; - } - :host([disabled]:${focusVisible}) { - outline-color: ${SystemColors.GrayText}; - } - :host .expanded-toggle, - :host .checkbox, - :host .radio { - border-color: ${SystemColors.ButtonText}; - background: ${SystemColors.HighlightText}; - } - :host([checked]) .checkbox, - :host([checked]) .radio { - background: ${SystemColors.HighlightText}; - border-color: ${SystemColors.HighlightText}; - } - :host(:hover) .expanded-toggle, - :host(:hover) .checkbox, - :host(:hover) .radio, - :host(:${focusVisible}) .expanded-toggle, - :host(:${focusVisible}) .checkbox, - :host(:${focusVisible}) .radio, - :host([checked]:hover) .checkbox, - :host([checked]:hover) .radio, - :host([checked]:${focusVisible}) .checkbox, - :host([checked]:${focusVisible}) .radio { - border-color: ${SystemColors.HighlightText}; - } - :host([aria-checked='true']) { - background: ${SystemColors.Highlight}; - color: ${SystemColors.HighlightText}; - } - :host([aria-checked='true']) .checkbox-indicator, - :host([aria-checked='true']) ::slotted([slot='checkbox-indicator']), - :host([aria-checked='true']) ::slotted([slot='radio-indicator']) { - fill: ${SystemColors.Highlight}; - } - :host([aria-checked='true']) .radio-indicator { - background: ${SystemColors.Highlight}; - } - `, - ), - new DirectionalStyleSheetBehavior( - css` - .expand-collapse-glyph-container { - transform: rotate(0deg); - } - `, - css` - .expand-collapse-glyph-container { - transform: rotate(180deg); - } - `, - ), - ); + borderRadiusMedium, + colorCompoundBrandForeground1Hover, + colorCompoundBrandForeground1Pressed, + colorNeutralBackground1, + colorNeutralBackground1Hover, + colorNeutralBackground1Selected, + colorNeutralBackgroundDisabled, + colorNeutralForeground2, + colorNeutralForeground2Hover, + colorNeutralForeground2Pressed, + colorNeutralForeground3, + colorNeutralForegroundDisabled, + fontFamilyBase, + fontSizeBase200, + fontSizeBase300, + fontSizeBase500, + fontWeightRegular, + lineHeightBase200, + lineHeightBase300, +} from '../theme/design-tokens.js'; + +/** MenuItem styles + * @public + */ +export const styles = css` + ${display('grid')} + + :host { + grid-template-columns: 20px 20px auto 20px; + align-items: center; + grid-gap: 4px; + height: 32px; + background: ${colorNeutralBackground1}; + font: ${fontWeightRegular} ${fontSizeBase300} / ${lineHeightBase300} ${fontFamilyBase}; + border-radius: ${borderRadiusMedium}; + color: ${colorNeutralForeground2}; + padding: 0 10px; + cursor: pointer; + overflow: visible; + contain: layout; + } + + :host(:hover) { + background: ${colorNeutralBackground1Hover}; + } + + .content { + white-space: nowrap; + flex-grow: 1; + grid-column: auto / span 2; + padding: 0 2px; + } + + .checkbox, + .radio { + display: none; + } + + .input-container, + .expand-collapse-glyph-container, + ::slotted([slot='start']), + ::slotted([slot='end']), + :host([checked]) .checkbox, + :host([checked]) .radio { + display: inline-flex; + justify-content: center; + align-items: center; + color: ${colorNeutralForeground2}; + } + + .expand-collapse-glyph-container, + ::slotted([slot='start']), + ::slotted([slot='end']) { + height: 32px; + font-size: ${fontSizeBase500}; + width: fit-content; + } + + .input-container { + width: 20px; + } + + ::slotted([slot='end']) { + color: ${colorNeutralForeground3}; + font: ${fontWeightRegular} ${fontSizeBase200} / ${lineHeightBase200} ${fontFamilyBase}; + white-space: nowrap; + grid-column: 4 / span 1; + justify-self: flex-end; + } + + .expand-collapse-glyph-container { + grid-column: 4 / span 1; + justify-self: flex-end; + } + + :host(:hover) .input-container, + :host(:hover) .expand-collapse-glyph-container, + :host(:hover) .content { + color: ${colorNeutralForeground2Hover}; + } + + :host([icon]:hover) ::slotted([slot='start']) { + color: ${colorCompoundBrandForeground1Hover}; + } + + :host(:active) { + background-color: ${colorNeutralBackground1Selected}; + } + + :host(:active) .input-container, + :host(:active) .expand-collapse-glyph-container, + :host(:active) .content { + color: ${colorNeutralForeground2Pressed}; + } + + :host(:active) ::slotted([slot='start']) { + color: ${colorCompoundBrandForeground1Pressed}; + } + + :host([disabled]) { + background-color: ${colorNeutralBackgroundDisabled}; + } + + :host([disabled]) .content, + :host([disabled]) .expand-collapse-glyph-container, + :host([disabled]) ::slotted([slot='end']), + :host([disabled]) ::slotted([slot='start']) { + color: ${colorNeutralForegroundDisabled}; + } + + :host([data-indent]) { + display: grid; + } + + :host([data-indent='1']) .content { + grid-column: 2 / span 1; + } + + :host([data-indent='1'][role='menuitemcheckbox']) { + display: grid; + } + + :host([data-indent='2'][aria-haspopup='menu']) ::slotted([slot='end']) { + grid-column: 4 / span 1; + } + + :host([data-indent='2'][aria-haspopup='menu']) .expand-collapse-glyph-container { + grid-column: 5 / span 1; + } + + :host([data-indent='1']) .content { + grid-column: 2 / span 1; + } + + :host([data-indent='1'][role='menuitemcheckbox']) .content, + :host([data-indent='1'][role='menuitemradio']) .content { + grid-column: auto / span 1; + } + + :host([icon]) ::slotted([slot='end']), + :host([data-indent='1']) ::slotted([slot='end']) { + grid-column: 4 / span 1; + justify-self: flex-end; + } + + :host([data-indent='2']) { + display: grid; + grid-template-columns: 20px 20px auto auto; + } + + :host([data-indent='2']) .content { + grid-column: 3 / span 1; + } + + :host([data-indent='2']) .input-container { + grid-column: 1 / span 1; + } + + :host([data-indent='2']) ::slotted([slot='start']) { + grid-column: 2 / span 1; + } + + :host([aria-haspopup='menu']) { + grid-template-columns: 20px auto auto 20px; + } + + :host([data-indent='2'][aria-haspopup='menu']) { + grid-template-columns: 20px 20px auto auto 20px; + } + + :host([aria-haspopup='menu']) ::slotted([slot='end']) { + grid-column: 3 / span 1; + justify-self: flex-end; + } + + :host([data-indent='2'][aria-haspopup='menu']) ::slotted([slot='end']) { + grid-column: 4 / span 1; + justify-self: flex-end; + } +`; diff --git a/packages/web-components/src/menu-item/menu-item.template.ts b/packages/web-components/src/menu-item/menu-item.template.ts new file mode 100644 index 00000000000000..2d0a5a480b2063 --- /dev/null +++ b/packages/web-components/src/menu-item/menu-item.template.ts @@ -0,0 +1,17 @@ +import { ElementViewTemplate } from '@microsoft/fast-element'; +import { html } from '@microsoft/fast-element'; +import { menuItemTemplate } from '@microsoft/fast-foundation'; +import type { MenuItem } from './menu-item.js'; + +const Checkmark16Filled = html.partial( + ``, +); +const chevronRight16Filled = html.partial( + ``, +); + +export const template: ElementViewTemplate = menuItemTemplate({ + checkboxIndicator: Checkmark16Filled, + expandCollapseGlyph: chevronRight16Filled, + radioIndicator: Checkmark16Filled, +}); diff --git a/packages/web-components/src/menu-item/menu-item.ts b/packages/web-components/src/menu-item/menu-item.ts new file mode 100644 index 00000000000000..e472e6bc2208ba --- /dev/null +++ b/packages/web-components/src/menu-item/menu-item.ts @@ -0,0 +1,9 @@ +import { FASTMenuItem } from '@microsoft/fast-foundation'; + +export type MenuItemColumnCount = 0 | 1 | 2; + +/** + * The base class used for constructing a fluent-menu-item custom element + * @public + */ +export class MenuItem extends FASTMenuItem {} diff --git a/packages/web-components/src/menu-list/README.md b/packages/web-components/src/menu-list/README.md new file mode 100644 index 00000000000000..9710c2e2f9ab4b --- /dev/null +++ b/packages/web-components/src/menu-list/README.md @@ -0,0 +1,104 @@ +# MenuList + +The MenuList displays a list of MenuItem options. + +## Design Spec + +[Link to MenuList Design Spec in Figma](https://www.figma.com/file/jFWrkFq61GDdOhPlsz6AtX/Menu?node-id=1528%3A5102&t=XtW4laeEzgVFIl1E-0) + +## Engineering Spec + +Fluent WC3 MenuList extends from the FAST Menu [FAST Menu](https://explore.fast.design/components/fast-menu) and is intended to be as close to the Fluent UI React 9 MenuList implementation as possible. However, due to the nature of web components there will not be 100% parity between the two. + +
+ +### Inputs + +### Outputs + +- none + +### Events + +- none + +### Slots + +- default slot for items + +### CSS Variables + +- `colorNeutralBackground1` +- `colorTransparentStroke` +- `borderRadiusMedium` +- `shadow16` + +
+
+
+ +## Accessibility + +
+ +**ARIA Attributes** +Attribute | Options | Description +--------------|-----------------|------------| +aria-checked | boolean | +aria-disabled | boolean | indicates that the element is perceivable but disabled, so it is not editable or otherwise operable +role | `menuitem` `menuitemcheckbox` `menuitemradio` | an enum representing the menu items' role + +
+
+
+ +## Preparation + +
+ +### **Fluent Web Component v3 v.s Fluent React 9** + +Due to the nature of Web Components there will not be 100% parity between component implementation in Fluent UI React v9 and Fluent Web Components v3. +
+ +**Component, Slot, and Attribute Mapping** +Component, Slot, or Attribute | Fluent React v9 | Fluent Web Components v3 | +--------------------------------|--------------------------------| -----------------------------------------------------| +Menu | `` | `` | +Menu item | `` | `` | +Menu item with radio | `` | `..` | +Menu item with checkbox | `` | `..` | +Icons | `}>` | `..`
`..` | +Aligning Icons | `` | aligns by default | +Aligning Checkboxes | `` | aligns by default | +Menu group header | `` | `` | + +
+ +**Additional Deltas:** + +**Responsiveness** + +The WC3 MenuList component does not currently support responsive styling. + +**Composure** + +Complete FUIR9 Menu composure + +```html + + Item 1 + Item 2 + Item 3 + +``` + +Complete WC3 Menu composure + +```html + + Item 1 + Item 2 + Item 3 + +``` diff --git a/packages/web-components/src/menu-list/define.ts b/packages/web-components/src/menu-list/define.ts new file mode 100644 index 00000000000000..fb0f26fd1ced42 --- /dev/null +++ b/packages/web-components/src/menu-list/define.ts @@ -0,0 +1,4 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { definition } from './menu-list.definition.js'; + +definition.define(FluentDesignSystem.registry); diff --git a/packages/web-components/src/menu-list/index.ts b/packages/web-components/src/menu-list/index.ts new file mode 100644 index 00000000000000..3448cf03ccf197 --- /dev/null +++ b/packages/web-components/src/menu-list/index.ts @@ -0,0 +1,4 @@ +export * from './menu-list.js'; +export { template as MenuListTemplate } from './menu-list.template.js'; +export { styles as MenuListStyles } from './menu-list.styles.js'; +export { definition as MenuListDefinition } from './menu-list.definition.js'; diff --git a/packages/web-components/src/menu-list/menu-list.definition.ts b/packages/web-components/src/menu-list/menu-list.definition.ts new file mode 100644 index 00000000000000..6806c1543c395b --- /dev/null +++ b/packages/web-components/src/menu-list/menu-list.definition.ts @@ -0,0 +1,19 @@ +import { FluentDesignSystem } from '../fluent-design-system.js'; +import { MenuList } from './menu-list.js'; +import { styles } from './menu-list.styles.js'; +import { template } from './menu-list.template.js'; + +/** + * The Fluent MenuList Element. Implements {@link @microsoft/fast-foundation#Menu }, + * {@link @microsoft/fast-foundation#menuTemplate} + * + * + * @public + * @remarks + * HTML Element: + */ +export const definition = MenuList.compose({ + name: `${FluentDesignSystem.prefix}-menu-list`, + template, + styles, +}); diff --git a/packages/web-components/src/menu-list/menu-list.stories.ts b/packages/web-components/src/menu-list/menu-list.stories.ts new file mode 100644 index 00000000000000..98a6d84280e570 --- /dev/null +++ b/packages/web-components/src/menu-list/menu-list.stories.ts @@ -0,0 +1,289 @@ +import { html } from '@microsoft/fast-element'; +import type { Args, Meta } from '@storybook/html'; +import { renderComponent } from '../helpers.stories.js'; +import type { MenuList as FluentMenuList } from './menu-list.js'; +import './define.js'; +import '../menu-item/define.js'; +import '../divider/define.js'; + +type MenuListStoryArgs = Args & FluentMenuList; +type MenuListStoryMeta = Meta; + +const Cut20Filled = html``; + +const Edit20Filled = html``; + +const Folder24Filled = html` + +`; +const Code20Filled = html``; + +const storyTemplate = html` +
+ + x.disabled}> + Item 1 + ${Cut20Filled} + Ctrl+X + + + x.disabled}> + ${Edit20Filled} + Item 2 + Ctrl+E + + + x.disabled}> Open + + + + x.disabled}> + Checkbox 1 + ${Cut20Filled} + + + x.disabled}> + Checkbox 2 + ${Edit20Filled} + + + x.disabled}> Checkbox 3 + + + + x.disabled}> + Radio 1 + ${Cut20Filled} + + + x.disabled}> + Radio 2 + ${Edit20Filled} + + + x.disabled}> Radio 3 + + + + x.disabled}> + ${Folder24Filled} + New + + + File + ${Folder24Filled} + + + Workspace + ${Code20Filled} + + + + File + Create + + + + File + ${Folder24Filled} + + + Workspace + ${Code20Filled} + + + + +
+`; + +export default { + title: 'Components/MenuList', + args: { + disabled: false, + }, + argTypes: { + disabled: { + description: 'Disables Menu item', + table: { + defaultValue: { summary: false }, + }, + control: 'boolean', + defaultValue: false, + }, + }, +} as MenuListStoryMeta; + +export const MenuList = renderComponent(storyTemplate).bind({}); + +export const MenuListWithCheckboxSelection = renderComponent(html` +
+ + Item 1 + Item 2 + Item 3 + +
+`); + +export const MenuListWithRadioSelection = renderComponent(html` +
+ + Item 1 + Item 2 + Item 3 + +
+`); + +export const MenuListWithIcons = renderComponent(html` +
+ + Item 1 + + Item 2 + ${Edit20Filled} + + + ${Edit20Filled} + + Item 3 + + +
+`); + +export const MenuListWithIconsAndSelection = renderComponent(html` +
+ + + Item 1 + ${Cut20Filled} + + + Item 2 + ${Edit20Filled} + + Item 3 + +
+`); + +export const MenuListWithSubmenu = renderComponent(html` +
+ + + Item 1 + + Subitem 1 + Subitem 2 + + + + Item 2 + + Subitem 1 + Subitem 1 + + + Item 3 + +
+`); + +export const MenuListWithSubmenuAndIcons = renderComponent(html` +
+ + + Item 1 + ${Edit20Filled} + + + Subitem 1 + ${Folder24Filled} + + + Subitem 2 + ${Code20Filled} + + + + + Item 2 + + + Subitem 1 + ${Folder24Filled} + + + Subitem 1 + ${Code20Filled} + + + + Item 3 + +
+`); + +export const MenuListAligningWithDivider = renderComponent(html` +
+ + Item 1 + Item 2 + + + Item 3 + Item 4 + +
+`); diff --git a/packages/web-components/src/menu-list/menu-list.styles.ts b/packages/web-components/src/menu-list/menu-list.styles.ts new file mode 100644 index 00000000000000..5b3279233faef1 --- /dev/null +++ b/packages/web-components/src/menu-list/menu-list.styles.ts @@ -0,0 +1,29 @@ +import { css } from '@microsoft/fast-element'; +import { display } from '@microsoft/fast-foundation'; +import { + borderRadiusMedium, + colorNeutralBackground1, + colorTransparentStroke, + shadow16, +} from '../theme/design-tokens.js'; + +/** MenuList styles + * @public + */ +export const styles = css` + ${display('flex')} + + :host { + flex-direction: column; + height: fit-content; + max-width: 300px; + min-width: 160px; + width: auto; + background-color: ${colorNeutralBackground1}; + border: 1px solid ${colorTransparentStroke}; + border-radius: ${borderRadiusMedium}; + box-shadow: ${shadow16}; + padding: 4px; + row-gap: 2px; + } +`; diff --git a/packages/web-components/src/menu-list/menu-list.template.ts b/packages/web-components/src/menu-list/menu-list.template.ts new file mode 100644 index 00000000000000..27d8215c08b7cd --- /dev/null +++ b/packages/web-components/src/menu-list/menu-list.template.ts @@ -0,0 +1,5 @@ +import { ElementViewTemplate } from '@microsoft/fast-element'; +import { menuTemplate } from '@microsoft/fast-foundation'; +import type { MenuList } from './menu-list.js'; + +export const template: ElementViewTemplate = menuTemplate(); diff --git a/packages/web-components/src/menu-list/menu-list.ts b/packages/web-components/src/menu-list/menu-list.ts new file mode 100644 index 00000000000000..ebec294fe40e63 --- /dev/null +++ b/packages/web-components/src/menu-list/menu-list.ts @@ -0,0 +1,43 @@ +import { FASTMenu, MenuItemRole } from '@microsoft/fast-foundation'; +import { MenuItem, MenuItemColumnCount } from '../menu-item/index.js'; + +/** + * The base class used for constructing a fluent-menu-list custom element + * @public + */ + +export class MenuList extends FASTMenu { + protected setItems(): void { + super.setItems(); + + /** + * Set the indent attribute on MenuItem elements based on their + * position in the MenuList. Each MenuItem element has a data-indent attribute that is + * used to set the indent of the element's start slot content. + */ + const filteredMenuListItems = this.menuItems?.filter(this.isMenuItemElement); + + filteredMenuListItems?.forEach((item: HTMLElement, index: number) => { + const indent: MenuItemColumnCount = filteredMenuListItems?.reduce((accum, current) => { + const elementValue = MenuList.elementIndent(current as HTMLElement); + + return Math.max(accum, elementValue as number) as MenuItemColumnCount; + }, 0); + + if (item instanceof MenuItem) { + item.setAttribute('data-indent', `${indent}`); + } + }); + } + + private static elementIndent(el: HTMLElement): MenuItemColumnCount { + const role = el.getAttribute('role'); + const startSlot = el.querySelector('[slot=start]'); + + if (role && role !== MenuItemRole.menuitem) { + return startSlot ? 2 : 1; + } + + return startSlot ? 1 : 0; + } +}