diff --git a/docs/pages/components/menu-item.md b/docs/pages/components/menu-item.md index ef80dfbb0a..5fa0b20729 100644 --- a/docs/pages/components/menu-item.md +++ b/docs/pages/components/menu-item.md @@ -9,7 +9,7 @@ layout: component Option 1 Option 2 - Option 3 + Option 3 Checkbox Disabled @@ -37,7 +37,7 @@ const App = () => ( Option 1 Option 2 - Option 3 + Option 3 Checkbox @@ -60,6 +60,35 @@ const App = () => ( ## Examples +### Loading + +Use the `loading` attribute to make a menu item busy. Clicks will be suppressed until the loading state is removed. + +```html:preview + + Option 1 + Option 2 + Option 3 + +``` + +{% raw %} + +```jsx:react +import SlMenu from '@shoelace-style/shoelace/dist/react/menu'; +import SlMenuItem from '@shoelace-style/shoelace/dist/react/menu-item'; + +const App = () => ( + + Option 1 + Option 2 + Option 3 + +); +``` + +{% endraw %} + ### Disabled Add the `disabled` attribute to disable the menu item so it cannot be selected. diff --git a/src/components/menu-item/menu-item.component.ts b/src/components/menu-item/menu-item.component.ts index ebb92eef7d..b9e3c60ef9 100644 --- a/src/components/menu-item/menu-item.component.ts +++ b/src/components/menu-item/menu-item.component.ts @@ -8,6 +8,7 @@ import { watch } from '../../internal/watch.js'; import ShoelaceElement from '../../internal/shoelace-element.js'; import SlIcon from '../icon/icon.component.js'; import SlPopup from '../popup/popup.component.js'; +import SlSpinner from '../spinner/spinner.component.js'; import styles from './menu-item.styles.js'; import type { CSSResultGroup } from 'lit'; @@ -19,6 +20,7 @@ import type { CSSResultGroup } from 'lit'; * * @dependency sl-icon * @dependency sl-popup + * @dependency sl-spinner * * @slot - The menu item's label. * @slot prefix - Used to prepend an icon or similar element to the menu item. @@ -30,6 +32,7 @@ import type { CSSResultGroup } from 'lit'; * @csspart prefix - The prefix container. * @csspart label - The menu item label. * @csspart suffix - The suffix container. + * @csspart spinner - The spinner that shows when the menu item is in the loading state. * @csspart submenu-icon - The submenu icon, visible only when the menu item has a submenu (not yet implemented). * * @cssproperty [--submenu-offset=-2px] - The distance submenus shift to overlap the parent menu. @@ -38,7 +41,8 @@ export default class SlMenuItem extends ShoelaceElement { static styles: CSSResultGroup = styles; static dependencies = { 'sl-icon': SlIcon, - 'sl-popup': SlPopup + 'sl-popup': SlPopup, + 'sl-spinner': SlSpinner }; private cachedTextLabel: string; @@ -55,6 +59,9 @@ export default class SlMenuItem extends ShoelaceElement { /** A unique value to store in the menu item. This can be used as a way to identify menu items when selected. */ @property() value = ''; + /** Draws the menu item in a loading state. */ + @property({ type: Boolean, reflect: true }) loading = false; + /** Draws the menu item in a disabled state, preventing selection. */ @property({ type: Boolean, reflect: true }) disabled = false; @@ -158,6 +165,7 @@ export default class SlMenuItem extends ShoelaceElement { 'menu-item--rtl': isRtl, 'menu-item--checked': this.checked, 'menu-item--disabled': this.disabled, + 'menu-item--loading': this.loading, 'menu-item--has-submenu': this.isSubmenu(), 'menu-item--submenu-expanded': isSubmenuExpanded })} @@ -179,6 +187,7 @@ export default class SlMenuItem extends ShoelaceElement { ${this.submenuController.renderSubmenu()} + ${this.loading ? html`` : ''} `; } diff --git a/src/components/menu-item/menu-item.styles.ts b/src/components/menu-item/menu-item.styles.ts index 476add6877..c96d129744 100644 --- a/src/components/menu-item/menu-item.styles.ts +++ b/src/components/menu-item/menu-item.styles.ts @@ -139,4 +139,30 @@ export default css` outline-offset: -1px; } } + + /* + * Loading modifier + */ + + .menu-item--loading { + position: relative; + cursor: wait; + } + + .menu-item--loading .menu-item__prefix, + .menu-item--loading .menu-item__label, + .menu-item--loading .menu-item__suffix, + .menu-item--loading .menu-item__check { + visibility: hidden; + } + + .menu-item--loading sl-spinner { + --indicator-color: currentColor; + position: absolute; + font-size: 1em; + height: 1em; + width: 1em; + top: calc(50% - 0.5em); + left: calc(50% - 0.5em); + } `; diff --git a/src/components/menu-item/menu-item.test.ts b/src/components/menu-item/menu-item.test.ts index abee95d814..65676e53b0 100644 --- a/src/components/menu-item/menu-item.test.ts +++ b/src/components/menu-item/menu-item.test.ts @@ -40,6 +40,7 @@ describe('', () => { expect(el.value).to.equal(''); expect(el.disabled).to.be.false; + expect(el.loading).to.equal(false); expect(el.getAttribute('aria-disabled')).to.equal('false'); }); @@ -48,6 +49,13 @@ describe('', () => { expect(el.getAttribute('aria-disabled')).to.equal('true'); }); + describe('when loading', () => { + it('should have a spinner present', async () => { + const el = await fixture(html` Menu Item Label `); + expect(el.shadowRoot!.querySelector('sl-spinner')).to.exist; + }); + }); + it('should return a text label when calling getTextLabel()', async () => { const el = await fixture(html` Test `); expect(el.getTextLabel()).to.equal('Test');