From 7d81ffff0729959aad315a213af69845474c93b5 Mon Sep 17 00:00:00 2001 From: Carlos Verdes Date: Fri, 25 Oct 2024 13:14:16 +0400 Subject: [PATCH 01/17] feat: figma button --- docs/_includes/default.njk | 6 ++++++ docs/assets/scripts/figma-button.js | 24 ++++++++++++++++++++++++ src/components/icon/library.system.ts | 6 ++++++ 3 files changed, 36 insertions(+) create mode 100644 docs/assets/scripts/figma-button.js diff --git a/docs/_includes/default.njk b/docs/_includes/default.njk index 315d11e69a..af6d70c7a9 100644 --- a/docs/_includes/default.njk +++ b/docs/_includes/default.njk @@ -53,6 +53,7 @@ + @@ -80,6 +81,11 @@ + {# Figma #} + + + + {# Theme selector #} diff --git a/docs/assets/scripts/figma-button.js b/docs/assets/scripts/figma-button.js new file mode 100644 index 0000000000..e2611cdf44 --- /dev/null +++ b/docs/assets/scripts/figma-button.js @@ -0,0 +1,24 @@ +window.addEventListener('load', function (event) { + console.log('on load - Figma button, document is ', document.readyState); + + var figmaButton = document.querySelector('[data-figma-button]'); + + if (figmaButton) { + console.log('figma button found', figmaButton.dataset); + + figmaButton.onclick = function () { + let status = figmaButton.dataset.figmaButton; + let icon = figmaButton.querySelector('sl-icon'); + + console.log('figma button clicked with status', status); + + if (status === 'on') { + figmaButton.dataset.figmaButton = 'off'; + icon.name = 'figma-mono'; + } else { + figmaButton.dataset.figmaButton = 'on'; + icon.name = 'figma'; + } + }; + } +}); diff --git a/src/components/icon/library.system.ts b/src/components/icon/library.system.ts index 6654ae5d7c..40fff6cc4f 100644 --- a/src/components/icon/library.system.ts +++ b/src/components/icon/library.system.ts @@ -117,6 +117,12 @@ const icons = { + `, + figma: ` + Figma.logoCreated using Figma + `, + 'figma-mono': ` + ` }; From f7e9688fe0577bb2099b992ced5cc4cf1d6744b3 Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Wed, 30 Oct 2024 10:11:10 -0400 Subject: [PATCH 02/17] update twitter username --- README.md | 4 ++-- docs/pages/index.md | 2 +- docs/pages/resources/community.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8e6eed96d2..1477eb9ba3 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A forward-thinking library of web components. - Built with accessibility in mind ♿️ - Open source 😸 -Designed in New Hampshire by [Cory LaViska](https://twitter.com/claviska). +Designed in New Hampshire by [Cory LaViska](https://twitter.com/cory_laviska). --- @@ -77,6 +77,6 @@ Shoelace is an open source project and contributions are encouraged! If you're i ## License -Shoelace was created by [Cory LaViska](https://twitter.com/claviska) and is available under the terms of the MIT license. +Shoelace was created by [Cory LaViska](https://twitter.com/cory_laviska) and is available under the terms of the MIT license. Whether you're building Shoelace or building something _with_ Shoelace — have fun creating! 🥾 diff --git a/docs/pages/index.md b/docs/pages/index.md index ebcc4598a2..7b1da843d1 100644 --- a/docs/pages/index.md +++ b/docs/pages/index.md @@ -106,7 +106,7 @@ If you need to support IE11 or pre-Chromium Edge, this library isn't for you. Al ## License -Shoelace was created in New Hampshire by [Cory LaViska](https://twitter.com/claviska). It's available under the terms of the [MIT license](https://github.com/shoelace-style/shoelace/blob/next/LICENSE.md). +Shoelace was created in New Hampshire by [Cory LaViska](https://twitter.com/cory_laviska). It's available under the terms of the [MIT license](https://github.com/shoelace-style/shoelace/blob/next/LICENSE.md). ## Attribution diff --git a/docs/pages/resources/community.md b/docs/pages/resources/community.md index 197e688b6f..f0f3838b61 100644 --- a/docs/pages/resources/community.md +++ b/docs/pages/resources/community.md @@ -49,7 +49,7 @@ You can post questions on Stack Overflow using [the "shoelace" tag](https://stac ## Twitter -Follow [@shoelace_style](https://twitter.com/shoelace_style) on Twitter for general updates and announcements about Shoelace. This is a great place to say "hi" or to share something you're working on. You're also welcome to follow [@claviska](https://twitter.com/claviska), the creator, for tweets about web components, web development, and life. +Follow [@shoelace_style](https://twitter.com/shoelace_style) on Twitter for general updates and announcements about Shoelace. This is a great place to say "hi" or to share something you're working on. You're also welcome to follow [@cory_laviska](https://twitter.com/cory_laviska), the creator, for tweets about web components, web development, and life. **Please avoid using Twitter for support questions.** The [discussion forum](https://github.com/shoelace-style/shoelace/discussions) is a much better place to share code snippets, screenshots, and other troubleshooting info. You'll have much better luck there, as more users will have a chance to help you. From 9919e435bb8ddfb6ea1b3906a2207420b0c5d8cc Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Fri, 1 Nov 2024 12:58:12 -0400 Subject: [PATCH 03/17] wait longer for CI --- src/components/select/select.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/select/select.test.ts b/src/components/select/select.test.ts index 114199ae7a..a9ca7a2254 100644 --- a/src/components/select/select.test.ts +++ b/src/components/select/select.test.ts @@ -507,7 +507,7 @@ describe('', () => { option.textContent = 'updated'; - await aTimeout(0); + await aTimeout(250); await option.updateComplete; await el.updateComplete; From bf6aa33a98c70e5195e10efb155a8ceb884c70bc Mon Sep 17 00:00:00 2001 From: MAbuseini Date: Sun, 3 Nov 2024 15:15:25 +0300 Subject: [PATCH 04/17] adding NavItem component --- .../mf-nav-item/mf-nav-item.component.ts | 174 ++++++++++++++++++ .../mf-nav-item/mf-nav-item.styles.ts | 161 ++++++++++++++++ .../mf-nav-item/mf-nav-item.test.ts | 10 + src/components/mf-nav-item/mf-nav-item.ts | 12 ++ 4 files changed, 357 insertions(+) create mode 100644 src/components/mf-nav-item/mf-nav-item.component.ts create mode 100644 src/components/mf-nav-item/mf-nav-item.styles.ts create mode 100644 src/components/mf-nav-item/mf-nav-item.test.ts create mode 100644 src/components/mf-nav-item/mf-nav-item.ts diff --git a/src/components/mf-nav-item/mf-nav-item.component.ts b/src/components/mf-nav-item/mf-nav-item.component.ts new file mode 100644 index 0000000000..1f4ba93ae4 --- /dev/null +++ b/src/components/mf-nav-item/mf-nav-item.component.ts @@ -0,0 +1,174 @@ +import { classMap } from 'lit/directives/class-map.js'; +import { getTextContent, HasSlotController } from '../../internal/slot.js'; +import { html } from 'lit'; +import { LocalizeController } from '../../utilities/localize.js'; +import { property, query } from 'lit/decorators.js'; +import { SubnavController } from './subnav-controller.js'; +import { watch } from '../../internal/watch.js'; +import componentStyles from '../../styles/component.styles.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 './mf-nav-item.styles.js'; +import type { CSSResultGroup } from 'lit'; + +/** + * @summary Navigation items provide options for the user to pick from in a navigation. + * @documentation https://shoelace.style/components/mf-nav-item + * @status experimental + * @since 2.0 + * + * @dependency sl-icon + * @dependency sl-popup + * @dependency sl-spinner + * + * + * @slot label - The Navigation item's label. + * @slot prefix - Used to prepend an icon or similar element to the navigation item. + * @slot suffix - Used to append an icon or similar element to the navigation item. + * @slot subnav - Used to denote a nested navigation. + * + * @csspart base - The component's base wrapper. + * @csspart prefix - The prefix container. + * @csspart label - The navigation item label. + * @csspart suffix - The suffix container. + * @csspart spinner - The spinner that shows when the navigation item is in the loading state. + * @csspart spinner__base - The spinner's base part. + * @csspart sub-navigation-icon - The sub-navigation icon, visible only when the navigation item has a sub-navigation (not yet implemented). + * + * @cssproperty [--sub-navigation-offset=-2px] - The distance sub-navigation shift to overlap the parent navigation. + */ +export default class MfNavItem extends ShoelaceElement { + static styles: CSSResultGroup = [componentStyles, styles]; + static dependencies = { + 'sl-icon': SlIcon, + 'sl-popup': SlPopup, + 'sl-spinner': SlSpinner + }; + + private readonly localize = new LocalizeController(this); + private cachedTextLabel: string; + + @query('slot:not([name])') defaultSlot: HTMLSlotElement; + @query('.nav-item') navItem: HTMLElement; + + /** A unique value to store in the nav item. This can be used as a way to identify nav items when selected. */ + @property() value = ''; + + /** Nav item href value. */ + @property() href = ''; + + /** The type of nav item to render. */ + @property() type = 'normal'; + + /** Draws the nav item in a loading state. */ + @property({ type: Boolean, reflect: true }) loading = false; + + /** Draws the nav item in a disabled state, preventing selection. */ + @property({ type: Boolean, reflect: true }) disabled = false; + + /** Draws the nav item in a highlight state, provides custom class highlight. */ + @property({ type: Boolean, reflect: true }) highlight = false; + + private readonly hasSlotController = new HasSlotController(this, 'subnav'); + private subnavController: SubnavController = new SubnavController(this, this.hasSlotController); + + connectedCallback() { + super.connectedCallback(); + this.addEventListener('click', this.handleHostClick); + this.addEventListener('mouseover', this.handleMouseOver); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener('click', this.handleHostClick); + this.removeEventListener('mouseover', this.handleMouseOver); + } + + private handleDefaultSlotChange() { + const textLabel = this.getTextLabel(); + + // Ignore the first time the label is set + if (typeof this.cachedTextLabel === 'undefined') { + this.cachedTextLabel = textLabel; + return; + } + + // When the label changes, emit a slotchange event so parent controls see it + if (textLabel !== this.cachedTextLabel) { + this.cachedTextLabel = textLabel; + this.emit('slotchange', { bubbles: true, composed: false, cancelable: false }); + } + } + + private handleHostClick = (event: MouseEvent) => { + // Prevent the click event from being emitted when the button is disabled or loading + if (this.disabled) { + event.preventDefault(); + event.stopImmediatePropagation(); + } + }; + + private handleMouseOver = (event: MouseEvent) => { + this.focus(); + event.stopPropagation(); + }; + + @watch('disabled') + handleDisabledChange() { + this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false'); + } + + @watch('type') + handleTypeChange() { + this.setAttribute('role', 'navitem'); + this.removeAttribute('aria-checked'); + } + + /** Returns a text label based on the contents of the nav item's default slot. */ + getTextLabel() { + return getTextContent(this.defaultSlot); + } + + isSubnav() { + return this.hasSlotController.test('subnav'); + } + + render() { + const isRtl = this.localize.dir() === 'rtl'; + const isSubnavExpanded = this.subnavController.isExpanded(); + + return html` + + + + + + + + + + + + ${this.subnavController.renderSubnav()} + ${this.loading ? html` ` : ''} + + `; + } +} diff --git a/src/components/mf-nav-item/mf-nav-item.styles.ts b/src/components/mf-nav-item/mf-nav-item.styles.ts new file mode 100644 index 0000000000..0ff74f84bb --- /dev/null +++ b/src/components/mf-nav-item/mf-nav-item.styles.ts @@ -0,0 +1,161 @@ +import { css } from 'lit'; + +export default css` + :host { + --subnav-offset: -2px; + + display: block; + } + + :host([inert]) { + display: none; + } + + .nav-item { + position: relative; + display: flex; + align-items: stretch; + font-family: var(--sl-font-sans); + font-size: var(--sl-font-size-medium); + font-weight: var(--sl-font-weight-normal); + line-height: var(--sl-line-height-normal); + letter-spacing: var(--sl-letter-spacing-normal); + color: var(--sl-color-neutral-700); + padding: var(--sl-spacing-2x-small) var(--sl-spacing-2x-small); + transition: var(--sl-transition-fast) fill; + user-select: none; + -webkit-user-select: none; + white-space: nowrap; + cursor: pointer; + text-decoration: none; + } + + .nav-item.nav-item--disabled { + outline: none; + opacity: 0.5; + cursor: not-allowed; + } + + .nav-item.nav-item--highlight { + color: var(--sl-color-neutral-900); + } + + .nav-item.nav-item--loading { + outline: none; + cursor: wait; + padding-left: 30px; + } + + .nav-item.nav-item--loading *:not(sl-spinner) { + opacity: 0.5; + } + + .nav-item--loading sl-spinner { + --indicator-color: currentColor; + --track-width: 1px; + position: absolute; + font-size: 0.75em; + top: calc(50% - 0.5em); + left: 0.65rem; + opacity: 1; + } + + .nav-item .nav-item__label { + flex: 1 1 auto; + display: inline-block; + text-overflow: ellipsis; + overflow: hidden; + } + + .nav-item .nav-item__prefix { + flex: 0 0 auto; + display: flex; + align-items: center; + } + + .nav-item .nav-item__prefix::slotted(*) { + margin-inline-end: var(--sl-spacing-x-small); + } + + .nav-item .nav-item__suffix { + flex: 0 0 auto; + display: flex; + align-items: center; + } + + .nav-item .nav-item__suffix::slotted(*) { + margin-inline-start: var(--sl-spacing-x-small); + } + + /* Safe triangle */ + .nav-item--subnav-expanded::after { + content: ''; + position: fixed; + z-index: calc(var(--sl-z-index-dropdown) - 1); + top: 0; + right: 0; + bottom: 0; + left: 0; + clip-path: polygon( + var(--safe-triangle-cursor-x, 0) var(--safe-triangle-cursor-y, 0), + var(--safe-triangle-subnav-start-x, 0) var(--safe-triangle-subnav-start-y, 0), + var(--safe-triangle-subnav-end-x, 0) var(--safe-triangle-subnav-end-y, 0) + ); + } + + :host(:focus-visible) { + outline: none; + } + + :host(:hover:not([aria-disabled='true'], :focus-visible)) .nav-item, + .nav-item--subnav-expanded { + background-color: var(--sl-color-neutral-100); + color: var(--sl-color-neutral-1000); + } + + :host(:focus-visible) .nav-item { + outline: none; + background-color: var(--sl-color-primary-600); + color: var(--sl-color-neutral-0); + opacity: 1; + } + + .nav-item .nav-item__check, + .nav-item .nav-item__chevron { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 1.5em; + visibility: hidden; + } + + .nav-item--checked .nav-item__check, + .nav-item--has-subnav .nav-item__chevron { + visibility: visible; + } + + /* Add elevation and z-index to subnavs */ + sl-popup::part(popup) { + box-shadow: var(--sl-shadow-large); + z-index: var(--sl-z-index-dropdown); + margin-top: var(--subnav-offset); + } + + .nav-item--rtl sl-popup::part(popup) { + margin-top: calc(-1 * var(--subnav-offset)); + } + + @media (forced-colors: active) { + :host(:hover:not([aria-disabled='true'])) .nav-item, + :host(:focus-visible) .nav-item { + outline: dashed 1px SelectedItem; + outline-offset: -1px; + } + } + + ::slotted(sl-menu) { + max-width: var(--auto-size-available-width) !important; + max-height: var(--auto-size-available-height) !important; + } +`; diff --git a/src/components/mf-nav-item/mf-nav-item.test.ts b/src/components/mf-nav-item/mf-nav-item.test.ts new file mode 100644 index 0000000000..10a26dd57b --- /dev/null +++ b/src/components/mf-nav-item/mf-nav-item.test.ts @@ -0,0 +1,10 @@ +import '../../../dist/shoelace.js'; +import { expect, fixture, html } from '@open-wc/testing'; + +describe('', () => { + it('should render a component', async () => { + const el = await fixture(html` `); + + expect(el).to.exist; + }); +}); diff --git a/src/components/mf-nav-item/mf-nav-item.ts b/src/components/mf-nav-item/mf-nav-item.ts new file mode 100644 index 0000000000..0e72989e71 --- /dev/null +++ b/src/components/mf-nav-item/mf-nav-item.ts @@ -0,0 +1,12 @@ +import MfNavItem from './mf-nav-item.component.js'; + +export * from './mf-nav-item.component.js'; +export default MfNavItem; + +MfNavItem.define('mf-nav-item'); + +declare global { + interface HTMLElementTagNameMap { + 'mf-nav-item': MfNavItem; + } +} From 5099c7d05d41b24238deb5d8cccadb03a743ad64 Mon Sep 17 00:00:00 2001 From: MAbuseini Date: Sun, 3 Nov 2024 15:15:54 +0300 Subject: [PATCH 05/17] adding NavSelect event --- src/events/mf-nav-select.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/events/mf-nav-select.ts diff --git a/src/events/mf-nav-select.ts b/src/events/mf-nav-select.ts new file mode 100644 index 0000000000..a3eaf616d6 --- /dev/null +++ b/src/events/mf-nav-select.ts @@ -0,0 +1,9 @@ +import type MfNavItem from '../components/mf-nav-item/mf-nav-item.js'; + +export type MfNavSelectEvent = CustomEvent<{ item: MfNavItem }>; + +declare global { + interface GlobalEventHandlersEventMap { + 'mf-nav-select': MfNavSelectEvent; + } +} From 60c1958fd8bf0305ec9906515db9d7bb3f01e7ee Mon Sep 17 00:00:00 2001 From: MAbuseini Date: Sun, 3 Nov 2024 15:16:18 +0300 Subject: [PATCH 06/17] export nav select event --- src/events/events.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/events/events.ts b/src/events/events.ts index 44e5846b8e..c9e5fd23ff 100644 --- a/src/events/events.ts +++ b/src/events/events.ts @@ -33,3 +33,4 @@ export type { SlSlideChangeEvent } from './sl-slide-change.js'; export type { SlStartEvent } from './sl-start.js'; export type { SlTabHideEvent } from './sl-tab-hide.js'; export type { SlTabShowEvent } from './sl-tab-show.js'; +export type { MfNavSelectEvent } from './mf-nav-select.js'; From ab1715612780c05c4f2c2176f9979b8a4d914a76 Mon Sep 17 00:00:00 2001 From: MAbuseini Date: Sun, 3 Nov 2024 15:16:41 +0300 Subject: [PATCH 07/17] added new sub nav controller --- .../mf-nav-item/subnav-controller.ts | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 src/components/mf-nav-item/subnav-controller.ts diff --git a/src/components/mf-nav-item/subnav-controller.ts b/src/components/mf-nav-item/subnav-controller.ts new file mode 100644 index 0000000000..f9c200ded6 --- /dev/null +++ b/src/components/mf-nav-item/subnav-controller.ts @@ -0,0 +1,286 @@ +import { createRef, ref, type Ref } from 'lit/directives/ref.js'; +import { type HasSlotController } from '../../internal/slot.js'; +import { html } from 'lit'; +import type { ReactiveController, ReactiveControllerHost } from 'lit'; +import type MfNavItem from './mf-nav-item.js'; +import type SlPopup from '../popup/popup.js'; + +/** A reactive controller to manage the registration of event listeners for subnavs. */ +export class SubnavController implements ReactiveController { + private host: ReactiveControllerHost & MfNavItem; + private popupRef: Ref = createRef(); + private enableSubnavTimer = -1; + private isConnected = false; + private isPopupConnected = false; + private skidding = 0; + private readonly hasSlotController: HasSlotController; + private readonly subnavOpenDelay = 100; + + constructor(host: ReactiveControllerHost & MfNavItem, hasSlotController: HasSlotController) { + (this.host = host).addController(this); + this.hasSlotController = hasSlotController; + } + + hostConnected() { + if (this.hasSlotController.test('subnav') && !this.host.disabled) { + this.addListeners(); + } + } + + hostDisconnected() { + this.removeListeners(); + } + + hostUpdated() { + if (this.hasSlotController.test('subnav') && !this.host.disabled) { + this.addListeners(); + this.updateSkidding(); + } else { + this.removeListeners(); + } + } + + private addListeners() { + if (!this.isConnected) { + this.host.addEventListener('mousemove', this.handleMouseMove); + this.host.addEventListener('mouseover', this.handleMouseOver); + this.host.addEventListener('keydown', this.handleKeyDown); + this.host.addEventListener('click', this.handleClick); + this.host.addEventListener('focusout', this.handleFocusOut); + this.isConnected = true; + } + + // The popup does not seem to get wired when the host is + // connected, so manage its listeners separately. + if (!this.isPopupConnected) { + if (this.popupRef.value) { + this.popupRef.value.addEventListener('mouseover', this.handlePopupMouseover); + this.popupRef.value.addEventListener('sl-reposition', this.handlePopupReposition); + this.isPopupConnected = true; + } + } + } + + private removeListeners() { + if (this.isConnected) { + this.host.removeEventListener('mousemove', this.handleMouseMove); + this.host.removeEventListener('mouseover', this.handleMouseOver); + this.host.removeEventListener('keydown', this.handleKeyDown); + this.host.removeEventListener('click', this.handleClick); + this.host.removeEventListener('focusout', this.handleFocusOut); + this.isConnected = false; + } + if (this.isPopupConnected) { + if (this.popupRef.value) { + this.popupRef.value.removeEventListener('mouseover', this.handlePopupMouseover); + this.popupRef.value.removeEventListener('sl-reposition', this.handlePopupReposition); + this.isPopupConnected = false; + } + } + } + + // Set the safe triangle cursor position + private handleMouseMove = (event: MouseEvent) => { + this.host.style.setProperty('--safe-triangle-cursor-x', `${event.clientX}px`); + this.host.style.setProperty('--safe-triangle-cursor-y', `${event.clientY}px`); + }; + + private handleMouseOver = () => { + if (this.hasSlotController.test('subnav')) { + this.enableSubnav(); + } + }; + + private handleSubnavEntry(event: KeyboardEvent) { + // Pass focus to the first nav-item in the subnav. + const subnavSlot: HTMLSlotElement | null = this.host.renderRoot.querySelector("slot[name='subnav']"); + + // Missing slot + if (!subnavSlot) { + console.error('Cannot activate a subnav if no corresponding navitem can be found.', this); + return; + } + + // Navs + let navItems: NodeListOf | null = null; + for (const elt of subnavSlot.assignedElements()) { + navItems = elt.querySelectorAll("mf-nav-item, [role^='navitem']"); + if (navItems.length !== 0) { + break; + } + } + + if (!navItems || navItems.length === 0) { + return; + } + + navItems[0].setAttribute('tabindex', '0'); + for (let i = 1; i !== navItems.length; ++i) { + navItems[i].setAttribute('tabindex', '-1'); + } + + // Open the subnav (if not open), and set focus to first navitem. + if (this.popupRef.value) { + event.preventDefault(); + event.stopPropagation(); + if (this.popupRef.value.active) { + if (navItems[0] instanceof HTMLElement) { + navItems[0].focus(); + } + } else { + this.enableSubnav(false); + this.host.updateComplete.then(() => { + if (navItems![0] instanceof HTMLElement) { + navItems![0].focus(); + } + }); + this.host.requestUpdate(); + } + } + } + + // Focus on the first nav-item of a subnav. + private handleKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case 'Escape': + case 'Tab': + this.disableSubnav(); + break; + case 'ArrowLeft': + // Either focus is currently on the host element or a child + if (event.target !== this.host) { + event.preventDefault(); + event.stopPropagation(); + this.host.focus(); + this.disableSubnav(); + } + break; + case 'ArrowRight': + case 'Enter': + case ' ': + this.handleSubnavEntry(event); + break; + default: + break; + } + }; + + private handleClick = (event: MouseEvent) => { + // Clicking on the item which heads the nav does nothing, otherwise hide subnav and propagate + if (event.target === this.host) { + event.preventDefault(); + event.stopPropagation(); + } else if ( + event.target instanceof Element && + (event.target.tagName === 'mf-nav-item' || event.target.role?.startsWith('navitem')) + ) { + this.disableSubnav(); + } + }; + + // Close this subnav on focus outside of the parent or any descendants. + private handleFocusOut = (event: FocusEvent) => { + if (event.relatedTarget && event.relatedTarget instanceof Element && this.host.contains(event.relatedTarget)) { + return; + } + this.disableSubnav(); + }; + + // Prevent the parent nav-item from getting focus on mouse movement on the subnav + private handlePopupMouseover = (event: MouseEvent) => { + event.stopPropagation(); + }; + + // Set the safe triangle values for the subnav when the position changes + private handlePopupReposition = () => { + const subnavSlot: HTMLSlotElement | null = this.host.renderRoot.querySelector("slot[name='subnav']"); + const nav = subnavSlot?.assignedElements({ flatten: true }).filter(el => el.localName === 'mf-nav')[0]; + const isRtl = getComputedStyle(this.host).direction === 'rtl'; + if (!nav) { + return; + } + + const { left, top, width, height } = nav.getBoundingClientRect(); + + this.host.style.setProperty('--safe-triangle-subnav-start-x', `${isRtl ? left + width : left}px`); + this.host.style.setProperty('--safe-triangle-subnav-start-y', `${top}px`); + this.host.style.setProperty('--safe-triangle-subnav-end-x', `${isRtl ? left + width : left}px`); + this.host.style.setProperty('--safe-triangle-subnav-end-y', `${top + height}px`); + }; + + private setSubnavState(state: boolean) { + if (this.popupRef.value) { + if (this.popupRef.value.active !== state) { + this.popupRef.value.active = state; + this.host.requestUpdate(); + } + } + } + + // Shows the subnav. Supports disabling the opening delay, e.g. for keyboard events that want to set the focus to the + // newly opened nav. + private enableSubnav(delay = true) { + if (delay) { + window.clearTimeout(this.enableSubnavTimer); + this.enableSubnavTimer = window.setTimeout(() => { + this.setSubnavState(true); + }, this.subnavOpenDelay); + } else { + this.setSubnavState(true); + } + } + + private disableSubnav() { + window.clearTimeout(this.enableSubnavTimer); + this.setSubnavState(false); + } + + // Calculate the space the top of a nav takes-up, for aligning the popup nav-item with the activating element. + private updateSkidding(): void { + // .computedStyleMap() not always available. + if (!this.host.parentElement?.computedStyleMap) { + return; + } + const styleMap: StylePropertyMapReadOnly = this.host.parentElement.computedStyleMap(); + const attrs: string[] = ['padding-top', 'border-top-width', 'margin-top']; + + const skidding = attrs.reduce((accumulator, attr) => { + const styleValue: CSSStyleValue = styleMap.get(attr) ?? new CSSUnitValue(0, 'px'); + const unitValue = styleValue instanceof CSSUnitValue ? styleValue : new CSSUnitValue(0, 'px'); + const pxValue = unitValue.to('px'); + return accumulator - pxValue.value; + }, 0); + + this.skidding = skidding; + } + + isExpanded(): boolean { + return this.popupRef.value ? this.popupRef.value.active : false; + } + + renderSubnav() { + // const isRtl = getComputedStyle(this.host).direction === 'rtl'; + + // Always render the slot, but conditionally render the outer + if (!this.isConnected) { + return html` `; + } + + return html` + + + + `; + } +} From 4c7e1a48c071220606f77ceb14c772aa94807a37 Mon Sep 17 00:00:00 2001 From: MAbuseini Date: Sun, 3 Nov 2024 15:17:24 +0300 Subject: [PATCH 08/17] modify nav item and nav components documentation --- docs/pages/components/mf-nav-item.md | 91 +++++++++ docs/pages/components/mf-navigation.md | 270 ++++++++++++++++++++++++- 2 files changed, 356 insertions(+), 5 deletions(-) create mode 100644 docs/pages/components/mf-nav-item.md diff --git a/docs/pages/components/mf-nav-item.md b/docs/pages/components/mf-nav-item.md new file mode 100644 index 0000000000..1b00be9a18 --- /dev/null +++ b/docs/pages/components/mf-nav-item.md @@ -0,0 +1,91 @@ +--- +meta: + title: Mf Nav Item + description: +layout: component +--- + +```html:preview + + Option 1 + Highlighted + Disabled + Loading + + Prefix Icon + + + + Suffix Icon + + + +``` + +{% raw %} + +## Examples + +### Prefix & Suffix + +Add content to the start and end of nav items using the `prefix` and `suffix` slots. + +```html:preview + + + + Home + + + + + Messages + 12 + + + + + Settings + + +``` + +{% endraw %} + +### Disabled + +Add the `disabled` attribute to disable the nav item so it cannot be selected. + +```html:preview + + Option 1 + Option 2 + Option 3 + +``` + +### Loading + +Use the `loading` attribute to indicate that a nav item is busy. Like a disabled nav item, clicks will be suppressed until the loading state is removed. + +```html:preview + + Option 1 + Option 2 + Option 3 + +``` + +### Highlight + +Use the `highlight` attribute to indicate that a nav item is highlighted with custom style. + +```html:preview + + Option 1 + Option 2 + Option 3 + +``` + +[component-metadata:mf-nav-item] diff --git a/docs/pages/components/mf-navigation.md b/docs/pages/components/mf-navigation.md index 7e6671b0c1..c171c7ea48 100644 --- a/docs/pages/components/mf-navigation.md +++ b/docs/pages/components/mf-navigation.md @@ -5,19 +5,279 @@ meta: layout: component --- +You can use [Nav items](/components/mf-nav-item) to compose a navigation. Navigation support keyboard interactions, including type-to-select an option. + ```html:preview - - logo - + Home + Contact Us + Privacy + Products ``` +{% raw %} + +```jsx:react +import MfNavigation from '@shoelace-style/shoelace/dist/react/MfNavigation'; +import MfNavItem from '@shoelace-style/shoelace/dist/react/nav-item'; + +const App = () => ( + + Undo + Redo + Cut + Copy + Paste + Delete + +); +``` + +{% endraw %} + ## Examples -### First Example +### SubNavigations -TODO +To create a subnav, nest an `` in any [nav item](/components/mf-nav-item). + +```html:preview +
+ + + Men + + Sale + New In + Designers + clothing + Shoes + + + + + Women + + Sale + New In + Designers + clothing + Shoes + + + + + Home & Gifts + + Sale + New In + Designers + clothing + Shoes + + + + +
+``` + +{% raw %} + +```jsx:react +import SlDivider from '@shoelace-style/shoelace/dist/react/divider'; +import MfNavigation from '@shoelace-style/shoelace/dist/react/MfNavigation'; +import MfNavItem from '@shoelace-style/shoelace/dist/react/nav-item'; + +const App = () => ( + + Undo + Redo + + Cut + Copy + Paste + + + Find + + Find… + Find Next + Find Previous + + + + Transformations + + Make uppercase + Make lowercase + Capitalize + + + +); +``` + +:::warning +As a UX best practice, avoid using more than one level of subnavs when possible. +::: + +{% endraw %} + +### 3 level SubNavigation + +To create a subnav, nest an `` in any [nav item](/components/mf-nav-item). + +```html:preview +
+ + + Men + + Sale + New In + + Adidas + Nike + NB + item + item + + + Designers + clothing + Shoes + + + + + Women + + Sale + New In + Designers + clothing + Shoes + + + + + Home & Gifts + + Sale + New In + Designers + clothing + Shoes + + + + +
+``` + +{% raw %} + +```jsx:react +import SlDivider from '@shoelace-style/shoelace/dist/react/divider'; +import MfNavigation from '@shoelace-style/shoelace/dist/react/MfNavigation'; +import MfNavItem from '@shoelace-style/shoelace/dist/react/nav-item'; + +const App = () => ( + + Undo + Redo + + Cut + Copy + Paste + + + Find + + Find… + Find Next + Find Previous + + + + Transformations + + Make uppercase + Make lowercase + Capitalize + + + +); +``` + +{% endraw %} + +### Vertical Sub-Navigation + +To create a subnav, nest an `` in any [nav item](/components/mf-nav-item). + +```html:preview +
+ + + Neighbourhoods + + Serra + Cilia + + + Contact Us + Download Brochure + + Language + + AR + EN + + + +
+``` + +{% raw %} + +```jsx:react +import SlDivider from '@shoelace-style/shoelace/dist/react/divider'; +import MfNavigation from '@shoelace-style/shoelace/dist/react/MfNavigation'; +import MfNavItem from '@shoelace-style/shoelace/dist/react/nav-item'; + +const App = () => ( + + Undo + Redo + + Cut + Copy + Paste + + + Find + + Find… + Find Next + Find Previous + + + + Transformations + + Make uppercase + Make lowercase + Capitalize + + + +); +``` + +{% endraw %} ### Second Example From 1e1f9db27c80c74648bbc8093c2e98f2ab219914 Mon Sep 17 00:00:00 2001 From: MAbuseini Date: Sun, 3 Nov 2024 15:18:39 +0300 Subject: [PATCH 09/17] updated navigation components --- .../mf-navigation/mf-navigation.component.ts | 169 ++++++++++++++++-- .../mf-navigation/mf-navigation.styles.ts | 17 +- 2 files changed, 166 insertions(+), 20 deletions(-) diff --git a/src/components/mf-navigation/mf-navigation.component.ts b/src/components/mf-navigation/mf-navigation.component.ts index d64d52e05c..ee0706837d 100644 --- a/src/components/mf-navigation/mf-navigation.component.ts +++ b/src/components/mf-navigation/mf-navigation.component.ts @@ -1,13 +1,16 @@ import { classMap } from 'lit/directives/class-map.js'; -import { HasSlotController } from '../../internal/slot.js'; import { html } from 'lit'; import { LocalizeController } from '../../utilities/localize.js'; -import { property } from 'lit/decorators.js'; -import { watch } from '../../internal/watch.js'; +import { property, query } from 'lit/decorators.js'; import componentStyles from '../../styles/component.styles.js'; import ShoelaceElement from '../../internal/shoelace-element.js'; import styles from './mf-navigation.styles.js'; import type { CSSResultGroup } from 'lit'; +import type MfNavItem from '../mf-nav-item/mf-nav-item.component.js'; + +export interface NavSelectEventDetail { + item: MfNavItem; +} /** * @summary Short summary of the component's intended use. @@ -25,39 +28,167 @@ import type { CSSResultGroup } from 'lit'; * @csspart base - The component's base wrapper. * * @cssproperty --example - An example CSS custom property. + * + * @event {{ item: MfNavItem }} mf-nav-select - Emitted when a nav item is selected. */ export default class MfNavigation extends ShoelaceElement { static styles: CSSResultGroup = [componentStyles, styles]; + @query('slot') defaultSlot: HTMLSlotElement; private readonly localize = new LocalizeController(this); + /** provides a vertical class for the navigation */ + @property({ type: Boolean, reflect: true }) vertical = false; + + connectedCallback() { + super.connectedCallback(); + this.setAttribute('role', 'nav'); + } + + private handleClick(event: MouseEvent) { + const navItemTypes = ['navitem']; + + const composedPath = event.composedPath(); + const target = composedPath.find((el: Element) => navItemTypes.includes(el?.getAttribute?.('role') || '')); + + if (!target) return; + + const closestNav = composedPath.find((el: Element) => el?.getAttribute?.('role') === 'nav'); + const clickHasSubnav = closestNav !== this; + + // Make sure we're the navigation thats supposed to be handling the click event. + if (clickHasSubnav) return; + + // This isn't true. But we use it for TypeScript checks below. + const item = target as MfNavItem; + + this.emit('mf-nav-select', { detail: { item } }); + } + + private handleKeyDown(event: KeyboardEvent) { + // Make a selection when pressing enter or space + if (event.key === 'Enter' || event.key === ' ') { + const item = this.getCurrentItem(); + event.preventDefault(); + event.stopPropagation(); + + // Simulate a click to support @click handlers on nav items that also work with the keyboard + item?.click(); + } + + // Move the selection when pressing down or up + else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { + const items = this.getAllItems(); + const activeItem = this.getCurrentItem(); + let index = activeItem ? items.indexOf(activeItem) : 0; + + if (items.length > 0) { + event.preventDefault(); + event.stopPropagation(); + + if (event.key === 'ArrowDown') { + index++; + } else if (event.key === 'ArrowUp') { + index--; + } else if (event.key === 'Home') { + index = 0; + } else if (event.key === 'End') { + index = items.length - 1; + } - /** An example attribute. */ - @property() attr = 'example'; + if (index < 0) { + index = items.length - 1; + } + if (index > items.length - 1) { + index = 0; + } - @watch('example') - handleExampleChange() { - // do something + this.setCurrentItem(items[index]); + items[index].focus(); + } + } } - private readonly hasSlotController = new HasSlotController(this, 'action'); + + private handleMouseDown(event: MouseEvent) { + const target = event.target as HTMLElement; + + if (this.isNavItem(target)) { + this.setCurrentItem(target as MfNavItem); + } + } + + private handleSlotChange() { + const items = this.getAllItems(); + + // Reset the roving tab index when the slotted items change + if (items.length > 0) { + this.setCurrentItem(items[0]); + } + } + + private isNavItem(item: HTMLElement) { + return item.tagName.toLowerCase() === 'mf-nav-item' || ['navitem'].includes(item.getAttribute('role') ?? ''); + } + + /** @internal Gets all slotted nav items, ignoring dividers, headers, and other elements. */ + getAllItems() { + return [...this.defaultSlot.assignedElements({ flatten: true })].filter((el: HTMLElement) => { + if (el.inert || !this.isNavItem(el)) { + return false; + } + return true; + }) as MfNavItem[]; + } + + /** + * @internal Gets the current nav item, which is the nav item that has `tabindex="0"` within the roving tab index. + * The nav item may or may not have focus, but for keyboard interaction purposes it's considered the "active" item. + */ + getCurrentItem() { + return this.getAllItems().find(i => i.getAttribute('tabindex') === '0'); + } + + /** + * @internal Sets the current nav item to the specified element. This sets `tabindex="0"` on the target element and + * `tabindex="-1"` to all other items. This method must be called prior to setting focus on a nav item. + */ + setCurrentItem(item: MfNavItem) { + const items = this.getAllItems(); + + // Update tab indexes + items.forEach(i => { + i.setAttribute('tabindex', i === item ? '0' : '-1'); + }); + } + + // private readonly localize = new LocalizeController(this); + + // /** An example attribute. */ + // @property() attr = 'example'; + + // @watch('example') + // handleExampleChange() { + // // do something + // } + // private readonly hasSlotController = new HasSlotController(this, 'action'); render() { const isRtl = this.localize.dir() === 'rtl'; return html` -
- -
+ + `; } } diff --git a/src/components/mf-navigation/mf-navigation.styles.ts b/src/components/mf-navigation/mf-navigation.styles.ts index 10882d124c..8f981d94b1 100644 --- a/src/components/mf-navigation/mf-navigation.styles.ts +++ b/src/components/mf-navigation/mf-navigation.styles.ts @@ -2,10 +2,25 @@ import { css } from 'lit'; export default css` :host { - display: block; + position: relative; + background: var(--mf-navigation-background-color); + border: solid var(--mf-navigation-border-width) var(--mf-navigation-border-color); + border-radius: var(--mf-navigation-radius-medium); + padding: var(--mf-navigation-spacing-x-small) 0; + overflow: auto; + overscroll-behavior: none; + } + + ::slotted(sl-divider) { + --spacing: var(--mf-navigation-spacing-x-small); } .navigation { display: flex; border-radius: 0; + flex-direction: row; + } + + .navigation.navigation--vertical { + flex-direction: column; } `; From 7bde33fa4293da308e36c5ef1849f31d48143adc Mon Sep 17 00:00:00 2001 From: MAbuseini Date: Sun, 3 Nov 2024 15:19:02 +0300 Subject: [PATCH 10/17] added comment to be fixed later --- docs/_includes/component.njk | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/_includes/component.njk b/docs/_includes/component.njk index e377e6926f..626164f8e1 100644 --- a/docs/_includes/component.njk +++ b/docs/_includes/component.njk @@ -1,6 +1,7 @@ {% extends "default.njk" %} {# Find the component based on the `tag` front matter #} +{# the below line should be updated to not add the "SL-" in the header #} {% set component = getComponent('sl-' + page.fileSlug) %} {% block content %} From f555cb71dcd7ccd19c8fc99174bd3b7e9c235d50 Mon Sep 17 00:00:00 2001 From: MAbuseini Date: Sun, 3 Nov 2024 15:20:14 +0300 Subject: [PATCH 11/17] export mf nav item component --- src/shoelace.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shoelace.ts b/src/shoelace.ts index f65291a69d..d728d76fd3 100644 --- a/src/shoelace.ts +++ b/src/shoelace.ts @@ -58,6 +58,7 @@ export { default as SlTree } from './components/tree/tree.js'; export { default as SlTreeItem } from './components/tree-item/tree-item.js'; export { default as SlVisuallyHidden } from './components/visually-hidden/visually-hidden.js'; export { default as MfNavigation } from './components/mf-navigation/mf-navigation.js'; +export { default as MfNavItem } from './components/mf-nav-item/mf-nav-item.js'; /* plop:component */ // Utilities From a3cbf90083f2de0fa753a3a658964a65fac76a60 Mon Sep 17 00:00:00 2001 From: MAbuseini Date: Sun, 3 Nov 2024 15:23:19 +0300 Subject: [PATCH 12/17] update autoloader to include MF components --- src/shoelace-autoloader.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/shoelace-autoloader.ts b/src/shoelace-autoloader.ts index 4b42c45231..5048fa62b9 100644 --- a/src/shoelace-autoloader.ts +++ b/src/shoelace-autoloader.ts @@ -15,10 +15,11 @@ const observer = new MutationObserver(mutations => { */ export async function discover(root: Element | ShadowRoot) { const rootTagName = root instanceof Element ? root.tagName.toLowerCase() : ''; - const rootIsShoelaceElement = rootTagName?.startsWith('sl-'); + const regex = new RegExp(`^(sl-)\\w+|^(mf-)\\w+`, 'i'); + const rootIsShoelaceElement = rootTagName?.match(regex); const tags = [...root.querySelectorAll(':not(:defined)')] .map(el => el.tagName.toLowerCase()) - .filter(tag => tag.startsWith('sl-')); + .filter(tag => tag.match(regex)); // If the root element is an undefined Shoelace component, add it to the list if (rootIsShoelaceElement && !customElements.get(rootTagName)) { From 7ae4f816b5567918107d279243bad4796fda5772 Mon Sep 17 00:00:00 2001 From: abu-seini-maf <107029616+abu-seini-maf@users.noreply.github.com> Date: Tue, 5 Nov 2024 10:39:43 +0300 Subject: [PATCH 13/17] Update mf-nav-item.styles.ts --- src/components/mf-nav-item/mf-nav-item.styles.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/mf-nav-item/mf-nav-item.styles.ts b/src/components/mf-nav-item/mf-nav-item.styles.ts index 0ff74f84bb..199f5b8653 100644 --- a/src/components/mf-nav-item/mf-nav-item.styles.ts +++ b/src/components/mf-nav-item/mf-nav-item.styles.ts @@ -21,7 +21,9 @@ export default css` line-height: var(--sl-line-height-normal); letter-spacing: var(--sl-letter-spacing-normal); color: var(--sl-color-neutral-700); - padding: var(--sl-spacing-2x-small) var(--sl-spacing-2x-small); + padding: var(--nav-spacing-inset-stretch, --nav-spacing-inset) var(--nav-spacing-inset-squish, --nav-spacing-inset); + margin-right: var(--nav-spacing-inline); + margin-bottom: var(--nav-spacing-stack); transition: var(--sl-transition-fast) fill; user-select: none; -webkit-user-select: none; @@ -141,7 +143,12 @@ export default css` z-index: var(--sl-z-index-dropdown); margin-top: var(--subnav-offset); } - + + .nav-item--rtl { + margin-left: var(--nav-spacing-inline); + margin-right: 0px !important; + } + .nav-item--rtl sl-popup::part(popup) { margin-top: calc(-1 * var(--subnav-offset)); } From 6e07199addf6db28fa7e76d73b37be86be7f05cf Mon Sep 17 00:00:00 2001 From: MAbuseini Date: Sun, 10 Nov 2024 14:34:21 +0300 Subject: [PATCH 14/17] header components and adjustments --- docs/pages/components/mf-action-item.md | 52 ++++ docs/pages/components/mf-actions.md | 98 ++++++ docs/pages/components/mf-header.md | 278 +++++++++++++++++ docs/pages/components/mf-logo.md | 22 ++ docs/pages/components/mf-navigation.md | 20 +- .../mf-action-item.component.ts | 169 ++++++++++ .../mf-action-item/mf-action-item.styles.ts | 172 +++++++++++ .../mf-action-item/mf-action-item.test.ts | 10 + .../mf-action-item/mf-action-item.ts | 12 + .../mf-action-item/submenu-controller.ts | 288 ++++++++++++++++++ .../mf-actions/mf-actions.component.ts | 163 ++++++++++ .../mf-actions/mf-actions.styles.ts | 25 ++ src/components/mf-actions/mf-actions.test.ts | 10 + src/components/mf-actions/mf-actions.ts | 12 + .../mf-header/mf-header.component.ts | 55 ++++ src/components/mf-header/mf-header.styles.ts | 20 ++ src/components/mf-header/mf-header.test.ts | 10 + src/components/mf-header/mf-header.ts | 12 + src/components/mf-logo/mf-logo.component.ts | 52 ++++ src/components/mf-logo/mf-logo.styles.ts | 17 ++ src/components/mf-logo/mf-logo.test.ts | 10 + src/components/mf-logo/mf-logo.ts | 12 + .../mf-nav-item/mf-nav-item.component.ts | 10 +- .../mf-nav-item/mf-nav-item.styles.ts | 18 +- .../mf-nav-item/subnav-controller.ts | 14 +- .../mf-navigation/mf-navigation.component.ts | 10 +- .../mf-navigation/mf-navigation.styles.ts | 7 +- src/events/events.ts | 1 + src/events/mf-action-select.ts | 9 + src/shoelace.ts | 4 + 30 files changed, 1567 insertions(+), 25 deletions(-) create mode 100644 docs/pages/components/mf-action-item.md create mode 100644 docs/pages/components/mf-actions.md create mode 100644 docs/pages/components/mf-header.md create mode 100644 docs/pages/components/mf-logo.md create mode 100644 src/components/mf-action-item/mf-action-item.component.ts create mode 100644 src/components/mf-action-item/mf-action-item.styles.ts create mode 100644 src/components/mf-action-item/mf-action-item.test.ts create mode 100644 src/components/mf-action-item/mf-action-item.ts create mode 100644 src/components/mf-action-item/submenu-controller.ts create mode 100644 src/components/mf-actions/mf-actions.component.ts create mode 100644 src/components/mf-actions/mf-actions.styles.ts create mode 100644 src/components/mf-actions/mf-actions.test.ts create mode 100644 src/components/mf-actions/mf-actions.ts create mode 100644 src/components/mf-header/mf-header.component.ts create mode 100644 src/components/mf-header/mf-header.styles.ts create mode 100644 src/components/mf-header/mf-header.test.ts create mode 100644 src/components/mf-header/mf-header.ts create mode 100644 src/components/mf-logo/mf-logo.component.ts create mode 100644 src/components/mf-logo/mf-logo.styles.ts create mode 100644 src/components/mf-logo/mf-logo.test.ts create mode 100644 src/components/mf-logo/mf-logo.ts create mode 100644 src/events/mf-action-select.ts diff --git a/docs/pages/components/mf-action-item.md b/docs/pages/components/mf-action-item.md new file mode 100644 index 0000000000..3dd733a4eb --- /dev/null +++ b/docs/pages/components/mf-action-item.md @@ -0,0 +1,52 @@ +--- +meta: + title: Mf Action Item + description: +layout: component +--- + +```html:preview + + Option 1 + Disabled + Loading + + + + + Suffix Icon + + + +``` + +## Examples + +### Language Selector + +```html:preview + + + AR + +``` + +### Notification + +```html:preview + + + + + 12 + + +``` + +### Notification + +```html:preview + +``` + +[component-metadata:mf-action-item] diff --git a/docs/pages/components/mf-actions.md b/docs/pages/components/mf-actions.md new file mode 100644 index 0000000000..3c0b871931 --- /dev/null +++ b/docs/pages/components/mf-actions.md @@ -0,0 +1,98 @@ +--- +meta: + title: Mf Nav Actions + description: +layout: component +--- + +```html:preview + + Download Brochure + + + AR + + + + AR + + + + EN + + + + + + + +``` + +## Examples + +### Simple Actions + +```html:preview +
+ + Download Brochure + + AR + + +
+``` + +### Second Example + +```html:preview +
+ + + + AR + + + + UAE + + + + KSA + + + + AR + + + EN + + + + + + + +

+ LOG IN OR REGISTER NOW +
+One account for all Majid Al Futtaim brands +
+Log in with your existing Majid Al Futtaim account or register now for a seamless experience and to redeem SHARE points. +

+ Login + Registration + +
+
+ + + + + + +
+
+``` + +[component-metadata:mf-nav-actions] diff --git a/docs/pages/components/mf-header.md b/docs/pages/components/mf-header.md new file mode 100644 index 0000000000..7659642dc2 --- /dev/null +++ b/docs/pages/components/mf-header.md @@ -0,0 +1,278 @@ +--- +meta: + title: Mf Header + description: +layout: component +--- + +```html:preview + + + + + + + Neighbourhoods + + Serra + Cilia + + + Contact Us + Download Brochure + + Language + + AR + EN + + + + +``` + +## Examples + +### First Example + +```html:preview + + + + + + + Neighbourhoods + + Serra + Cilia + + + Contact Us + Download Brochure + + Language + + AR + EN + + + + +``` + +### Second Example + +```html:preview + + + + + + + + AR + + + + UAE + + + + KSA + + + + AR + + + EN + + + + + + + +

+ LOG IN OR REGISTER NOW +
+ One account for all Majid Al Futtaim brands +
+ Log in with your existing Majid Al Futtaim account or register now for a seamless + experience and to redeem SHARE points. +

+ Login + Registration + +
+
+ + + + + + +
+ + + Men + + Sale + New In + + Adidas + Nike + NB + item + item + + + Designers + clothing + Shoes + + + + + Women + + Sale + New In + Designers + clothing + Shoes + + + + + Home & Gifts + + Sale + New In + Designers + clothing + Shoes + + + + + +
+``` + +### Third Example + +```html:preview + + + + Men + + Sale + New In + + Adidas + Nike + NB + item + item + + + Designers + clothing + Shoes + + + + + Women + + Sale + New In + Designers + clothing + Shoes + + + + + Home & Gifts + + Sale + New In + Designers + clothing + Shoes + + + + + + + + + + + + AR + + + + UAE + + + + KSA + + + + AR + + + EN + + + + + + + +

+ LOG IN OR REGISTER NOW +
+ One account for all Majid Al Futtaim brands +
+ Log in with your existing Majid Al Futtaim account or register now for a seamless + experience and to redeem SHARE points. +

+ Login + Registration + +
+
+ + + + + + +
+ + +
+``` + +TODO + +[component-metadata:mf-header] diff --git a/docs/pages/components/mf-logo.md b/docs/pages/components/mf-logo.md new file mode 100644 index 0000000000..f9250b8484 --- /dev/null +++ b/docs/pages/components/mf-logo.md @@ -0,0 +1,22 @@ +--- +meta: + title: Mf Logo + description: +layout: component +--- + +```html:preview + +``` + +## Examples + +### First Example + +TODO + +### Second Example + +TODO + +[component-metadata:mf-logo] diff --git a/docs/pages/components/mf-navigation.md b/docs/pages/components/mf-navigation.md index c171c7ea48..d11cfaa675 100644 --- a/docs/pages/components/mf-navigation.md +++ b/docs/pages/components/mf-navigation.md @@ -45,7 +45,7 @@ To create a subnav, nest an `` in any [nav item](/c ```html:preview
- + Men Sale @@ -130,13 +130,13 @@ To create a subnav, nest an `` in any [nav item](/c ```html:preview
- - + + Men - + Sale - New In - + New In + Adidas Nike NB @@ -150,9 +150,9 @@ To create a subnav, nest an `` in any [nav item](/c - + Women - + Sale New In Designers @@ -161,7 +161,7 @@ To create a subnav, nest an `` in any [nav item](/c - + Home & Gifts Sale @@ -220,7 +220,7 @@ To create a subnav, nest an `` in a ```html:preview
- + Neighbourhoods diff --git a/src/components/mf-action-item/mf-action-item.component.ts b/src/components/mf-action-item/mf-action-item.component.ts new file mode 100644 index 0000000000..a4f37f1761 --- /dev/null +++ b/src/components/mf-action-item/mf-action-item.component.ts @@ -0,0 +1,169 @@ +import { classMap } from 'lit/directives/class-map.js'; +import { getTextContent, HasSlotController } from '../../internal/slot.js'; +import { html } from 'lit'; +import { LocalizeController } from '../../utilities/localize.js'; +import { property, query } from 'lit/decorators.js'; +import { SubmenuController } from './submenu-controller.js'; +import { watch } from '../../internal/watch.js'; +import componentStyles from '../../styles/component.styles.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 './mf-action-item.styles.js'; +import type { CSSResultGroup } from 'lit'; + +/** + * @summary Short summary of the component's intended use. + * @documentation https://shoelace.style/components/mf-action-item + * @status experimental + * @since 2.0 + * + * @dependency sl-icon + * @dependency sl-popup + * @dependency sl-spinner + * @dependency sl-badge + * + * + * @slot - The action item's label. + * @slot icon - Used to add an icon or similar element to the action item. + * @slot prefix - Used to prepend an icon or similar element to the action item. + * @slot suffix - Used to append an icon or similar element to the action item. + * @slot submenu - Used to denote a nested menu. + * + * @csspart base - The component's base wrapper. + * @csspart icon - The icon container. + * @csspart prefix - The prefix container. + * @csspart label - The action item label. + * @csspart suffix - The suffix container. + * @csspart spinner - The spinner that shows when the action item is in the loading state. + * @csspart spinner__base - The spinner's base part. + * @csspart submenu-icon - The submenu icon, visible only when the action item has a submenu (not yet implemented). + * + * + * @cssproperty [--submenu-offset=-2px] - The distance submenus shift to overlap the parent menu. + */ +export default class MfActionItem extends ShoelaceElement { + static styles: CSSResultGroup = [componentStyles, styles]; + static dependencies = { + 'sl-icon': SlIcon, + 'sl-popup': SlPopup, + 'sl-spinner': SlSpinner + }; + + private cachedTextLabel: string; + private readonly localize = new LocalizeController(this); + + @query('slot:not([name])') defaultSlot: HTMLSlotElement; + @query('.action-item') actionItem: HTMLElement; + + /** A unique value to store in the action item. This can be used as a way to identify action items when selected. */ + @property() value = ''; + + /** A URL to store in the action item. This can be used as a way to redirect action items when clicked. */ + @property() href = ''; + + /** Draws the action item in a loading state. */ + @property({ type: Boolean, reflect: true }) loading = false; + + /** Draws the action item in a disabled state, preventing selection. */ + @property({ type: Boolean, reflect: true }) disabled = false; + + /** Hides the action item Chevron even if submenu is available. */ + @property({ type: Boolean, reflect: true }) hideChevron = false; + + private readonly hasSlotController = new HasSlotController(this, 'submenu'); + private submenuController: SubmenuController = new SubmenuController(this, this.hasSlotController); + + connectedCallback() { + super.connectedCallback(); + this.addEventListener('click', this.handleHostClick); + this.addEventListener('mouseover', this.handleMouseOver); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener('click', this.handleHostClick); + this.removeEventListener('mouseover', this.handleMouseOver); + } + + private handleDefaultSlotChange() { + const textLabel = this.getTextLabel(); + + // Ignore the first time the label is set + if (typeof this.cachedTextLabel === 'undefined') { + this.cachedTextLabel = textLabel; + return; + } + + // When the label changes, emit a slotchange event so parent controls see it + if (textLabel !== this.cachedTextLabel) { + this.cachedTextLabel = textLabel; + this.emit('slotchange', { bubbles: true, composed: false, cancelable: false }); + } + } + + private handleHostClick = (event: MouseEvent) => { + // Prevent the click event from being emitted when the button is disabled or loading + if (this.disabled) { + event.preventDefault(); + event.stopImmediatePropagation(); + } + }; + + private handleMouseOver = (event: MouseEvent) => { + this.focus(); + event.stopPropagation(); + }; + + @watch('disabled') + handleDisabledChange() { + this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false'); + } + + /** Returns a text label based on the contents of the action item's default slot. */ + getTextLabel() { + return getTextContent(this.defaultSlot); + } + + isSubmenu() { + return this.hasSlotController.test('submenu'); + } + + render() { + const isRtl = this.localize.dir() === 'rtl'; + const isSubmenuExpanded = this.submenuController.isExpanded(); + + return html` + + + + + + + + + + + + + ${this.submenuController.renderSubmenu()} + ${this.loading ? html` ` : ''} +
`; + } +} diff --git a/src/components/mf-action-item/mf-action-item.styles.ts b/src/components/mf-action-item/mf-action-item.styles.ts new file mode 100644 index 0000000000..02251f3958 --- /dev/null +++ b/src/components/mf-action-item/mf-action-item.styles.ts @@ -0,0 +1,172 @@ +import { css } from 'lit'; + +export default css` + :host { + --submenu-offset: -2px; + + display: block; + } + + :host([inert]) { + display: none; + } + + .action-item { + position: relative; + display: flex; + align-items: stretch; + font-family: var(--sl-font-sans); + font-size: var(--sl-font-size-medium); + font-weight: var(--sl-font-weight-normal); + line-height: var(--sl-line-height-normal); + letter-spacing: var(--sl-letter-spacing-normal); + color: var(--sl-color-neutral-700); + padding: var(--sl-spacing-2x-small) var(--sl-spacing-2x-small); + transition: var(--sl-transition-fast) fill; + user-select: none; + -webkit-user-select: none; + white-space: nowrap; + cursor: pointer; + text-decoration: none; + // width: max-content; + } + + .action-item.action-item--disabled { + outline: none; + opacity: 0.5; + cursor: not-allowed; + } + + .action-item.action-item--loading { + outline: none; + cursor: wait; + } + + .action-item.action-item--loading *:not(sl-spinner) { + opacity: 0.5; + } + + .action-item--loading sl-spinner { + --indicator-color: currentColor; + --track-width: 1px; + position: absolute; + font-size: 0.75em; + top: calc(50% - 0.5em); + left: 0.65rem; + opacity: 1; + } + + .action-item .action-item__label { + flex: 1 1 auto; + display: inline-block; + text-overflow: ellipsis; + overflow: hidden; + } + + .action-item .action-item__icon { + flex: 0 0 auto; + display: flex; + align-items: center; + font-size: 32px; + } + + .action-item .action-item__icon::slotted(*) { + margin-inline-end: 0; + } + + .action-item .action-item__prefix { + flex: 0 0 auto; + display: flex; + align-items: center; + } + + .action-item .action-item__prefix::slotted(*) { + margin-inline-end: var(--sl-spacing-x-small); + } + + .action-item .action-item__suffix { + flex: 0 0 auto; + display: flex; + align-items: center; + } + + .action-item .action-item__suffix::slotted(*) { + margin-inline-start: var(--sl-spacing-x-small); + } + + /* Safe triangle */ + .action-item--submenu-expanded::after { + content: ''; + position: fixed; + z-index: calc(var(--sl-z-index-dropdown) - 1); + top: 0; + right: 0; + bottom: 0; + left: 0; + clip-path: polygon( + var(--safe-triangle-cursor-x, 0) var(--safe-triangle-cursor-y, 0), + var(--safe-triangle-submenu-start-x, 0) var(--safe-triangle-submenu-start-y, 0), + var(--safe-triangle-submenu-end-x, 0) var(--safe-triangle-submenu-end-y, 0) + ); + } + + :host(:focus-visible) { + outline: none; + } + + :host(:hover:not([aria-disabled='true'], :focus-visible)) .action-item, + .action-item--submenu-expanded { + background-color: var(--sl-color-neutral-100); + color: var(--sl-color-neutral-1000); + } + + :host(:focus-visible) .action-item { + outline: none; + background-color: var(--sl-color-primary-600); + color: var(--sl-color-neutral-0); + opacity: 1; + } + + .action-item .action-item__chevron { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 1.5em; + visibility: hidden; + display: none; + } + + .action-item--has-submenu .action-item__chevron { + visibility: visible; + display: inline; + } + + .action-item--hide-submenu-chevron .action-item__chevron { + display: none; + } + + /* Add elevation and z-index to submenus */ + sl-popup::part(popup) { + box-shadow: var(--sl-shadow-large); + z-index: var(--sl-z-index-dropdown); + margin-left: var(--submenu-offset); + } + + .action-item--rtl sl-popup::part(popup) { + margin-left: calc(-1 * var(--submenu-offset)); + } + + @media (forced-colors: active) { + :host(:hover:not([aria-disabled='true'])) .action-item, + :host(:focus-visible) .action-item { + outline: dashed 1px SelectedItem; + outline-offset: -1px; + } + } + + ::slotted(sl-menu) { + max-width: var(--auto-size-available-width) !important; + max-height: var(--auto-size-available-height) !important; + } +`; diff --git a/src/components/mf-action-item/mf-action-item.test.ts b/src/components/mf-action-item/mf-action-item.test.ts new file mode 100644 index 0000000000..835c9cc093 --- /dev/null +++ b/src/components/mf-action-item/mf-action-item.test.ts @@ -0,0 +1,10 @@ +import '../../../dist/shoelace.js'; +import { expect, fixture, html } from '@open-wc/testing'; + +describe('', () => { + it('should render a component', async () => { + const el = await fixture(html` `); + + expect(el).to.exist; + }); +}); diff --git a/src/components/mf-action-item/mf-action-item.ts b/src/components/mf-action-item/mf-action-item.ts new file mode 100644 index 0000000000..96a56e0e02 --- /dev/null +++ b/src/components/mf-action-item/mf-action-item.ts @@ -0,0 +1,12 @@ +import MfActionItem from './mf-action-item.component.js'; + +export * from './mf-action-item.component.js'; +export default MfActionItem; + +MfActionItem.define('mf-action-item'); + +declare global { + interface HTMLElementTagNameMap { + 'mf-action-item': MfActionItem; + } +} diff --git a/src/components/mf-action-item/submenu-controller.ts b/src/components/mf-action-item/submenu-controller.ts new file mode 100644 index 0000000000..30476eecc5 --- /dev/null +++ b/src/components/mf-action-item/submenu-controller.ts @@ -0,0 +1,288 @@ +import { createRef, ref, type Ref } from 'lit/directives/ref.js'; +import { type HasSlotController } from '../../internal/slot.js'; +import { html } from 'lit'; +import type { ReactiveController, ReactiveControllerHost } from 'lit'; +import type MfActionItem from './mf-action-item.js'; +import type SlPopup from '../popup/popup.js'; + +/** A reactive controller to manage the registration of event listeners for submenus. */ +export class SubmenuController implements ReactiveController { + private host: ReactiveControllerHost & MfActionItem; + private popupRef: Ref = createRef(); + private enableSubmenuTimer = -1; + private isConnected = false; + private isPopupConnected = false; + private skidding = 0; + private readonly hasSlotController: HasSlotController; + private readonly submenuOpenDelay = 100; + + constructor(host: ReactiveControllerHost & MfActionItem, hasSlotController: HasSlotController) { + (this.host = host).addController(this); + this.hasSlotController = hasSlotController; + } + + hostConnected() { + if (this.hasSlotController.test('submenu') && !this.host.disabled) { + this.addListeners(); + } + } + + hostDisconnected() { + this.removeListeners(); + } + + hostUpdated() { + if (this.hasSlotController.test('submenu') && !this.host.disabled) { + this.addListeners(); + this.updateSkidding(); + } else { + this.removeListeners(); + } + } + + private addListeners() { + if (!this.isConnected) { + this.host.addEventListener('mousemove', this.handleMouseMove); + this.host.addEventListener('mouseover', this.handleMouseOver); + this.host.addEventListener('keydown', this.handleKeyDown); + this.host.addEventListener('click', this.handleClick); + this.host.addEventListener('focusout', this.handleFocusOut); + this.isConnected = true; + } + + // The popup does not seem to get wired when the host is + // connected, so manage its listeners separately. + if (!this.isPopupConnected) { + if (this.popupRef.value) { + this.popupRef.value.addEventListener('mouseover', this.handlePopupMouseover); + this.popupRef.value.addEventListener('sl-reposition', this.handlePopupReposition); + this.isPopupConnected = true; + } + } + } + + private removeListeners() { + if (this.isConnected) { + this.host.removeEventListener('mousemove', this.handleMouseMove); + this.host.removeEventListener('mouseover', this.handleMouseOver); + this.host.removeEventListener('keydown', this.handleKeyDown); + this.host.removeEventListener('click', this.handleClick); + this.host.removeEventListener('focusout', this.handleFocusOut); + this.isConnected = false; + } + if (this.isPopupConnected) { + if (this.popupRef.value) { + this.popupRef.value.removeEventListener('mouseover', this.handlePopupMouseover); + this.popupRef.value.removeEventListener('sl-reposition', this.handlePopupReposition); + this.isPopupConnected = false; + } + } + } + + // Set the safe triangle cursor position + private handleMouseMove = (event: MouseEvent) => { + this.host.style.setProperty('--safe-triangle-cursor-x', `${event.clientX}px`); + this.host.style.setProperty('--safe-triangle-cursor-y', `${event.clientY}px`); + }; + + private handleMouseOver = () => { + if (this.hasSlotController.test('submenu')) { + this.enableSubmenu(); + } + }; + + private handleSubmenuEntry(event: KeyboardEvent) { + // Pass focus to the first menu-item in the submenu. + const submenuSlot: HTMLSlotElement | null = this.host.renderRoot.querySelector("slot[name='submenu']"); + + // Missing slot + if (!submenuSlot) { + console.error('Cannot activate a submenu if no corresponding menuitem can be found.', this); + return; + } + + // Menus + let menuItems: NodeListOf | null = null; + for (const elt of submenuSlot.assignedElements()) { + menuItems = elt.querySelectorAll("mf-action-item, [role^='actionitem']"); + if (menuItems.length !== 0) { + break; + } + } + + if (!menuItems || menuItems.length === 0) { + return; + } + + menuItems[0].setAttribute('tabindex', '0'); + for (let i = 1; i !== menuItems.length; ++i) { + menuItems[i].setAttribute('tabindex', '-1'); + } + + // Open the submenu (if not open), and set focus to first menuitem. + if (this.popupRef.value) { + event.preventDefault(); + event.stopPropagation(); + if (this.popupRef.value.active) { + if (menuItems[0] instanceof HTMLElement) { + menuItems[0].focus(); + } + } else { + this.enableSubmenu(false); + this.host.updateComplete.then(() => { + if (menuItems![0] instanceof HTMLElement) { + menuItems![0].focus(); + } + }); + this.host.requestUpdate(); + } + } + } + + // Focus on the first menu-item of a submenu. + private handleKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case 'Escape': + case 'Tab': + this.disableSubmenu(); + break; + case 'ArrowLeft': + // Either focus is currently on the host element or a child + if (event.target !== this.host) { + event.preventDefault(); + event.stopPropagation(); + this.host.focus(); + this.disableSubmenu(); + } + break; + case 'ArrowRight': + case 'Enter': + case ' ': + this.handleSubmenuEntry(event); + break; + default: + break; + } + }; + + private handleClick = (event: MouseEvent) => { + // Clicking on the item which heads the menu does nothing, otherwise hide submenu and propagate + if (event.target === this.host) { + event.preventDefault(); + event.stopPropagation(); + } else if ( + event.target instanceof Element && + (event.target.tagName === 'sl-menu-item' || event.target.role?.startsWith('menuitem')) + ) { + this.disableSubmenu(); + } + }; + + // Close this submenu on focus outside of the parent or any descendants. + private handleFocusOut = (event: FocusEvent) => { + if (event.relatedTarget && event.relatedTarget instanceof Element && this.host.contains(event.relatedTarget)) { + return; + } + this.disableSubmenu(); + }; + + // Prevent the parent menu-item from getting focus on mouse movement on the submenu + private handlePopupMouseover = (event: MouseEvent) => { + event.stopPropagation(); + }; + + // Set the safe triangle values for the submenu when the position changes + private handlePopupReposition = () => { + const submenuSlot: HTMLSlotElement | null = this.host.renderRoot.querySelector("slot[name='submenu']"); + const menu = submenuSlot?.assignedElements({ flatten: true }).filter(el => el.localName === 'sl-menu')[0]; + const isRtl = getComputedStyle(this.host).direction === 'rtl'; + if (!menu) { + return; + } + + const { left, top, width, height } = menu.getBoundingClientRect(); + + this.host.style.setProperty('--safe-triangle-submenu-start-x', `${isRtl ? left + width : left}px`); + this.host.style.setProperty('--safe-triangle-submenu-start-y', `${top}px`); + this.host.style.setProperty('--safe-triangle-submenu-end-x', `${isRtl ? left + width : left}px`); + this.host.style.setProperty('--safe-triangle-submenu-end-y', `${top + height}px`); + }; + + private setSubmenuState(state: boolean) { + if (this.popupRef.value) { + if (this.popupRef.value.active !== state) { + this.popupRef.value.active = state; + this.host.requestUpdate(); + } + } + } + + // Shows the submenu. Supports disabling the opening delay, e.g. for keyboard events that want to set the focus to the + // newly opened menu. + private enableSubmenu(delay = true) { + if (delay) { + window.clearTimeout(this.enableSubmenuTimer); + this.enableSubmenuTimer = window.setTimeout(() => { + this.setSubmenuState(true); + }, this.submenuOpenDelay); + } else { + this.setSubmenuState(true); + } + } + + private disableSubmenu() { + window.clearTimeout(this.enableSubmenuTimer); + this.setSubmenuState(false); + } + + // Calculate the space the top of a menu takes-up, for aligning the popup menu-item with the activating element. + private updateSkidding(): void { + // .computedStyleMap() not always available. + if (!this.host.parentElement?.computedStyleMap) { + return; + } + const styleMap: StylePropertyMapReadOnly = this.host.parentElement.computedStyleMap(); + const attrs: string[] = ['padding-top', 'border-top-width', 'margin-top']; + + const skidding = attrs.reduce((accumulator, attr) => { + const styleValue: CSSStyleValue = styleMap.get(attr) ?? new CSSUnitValue(0, 'px'); + const unitValue = styleValue instanceof CSSUnitValue ? styleValue : new CSSUnitValue(0, 'px'); + const pxValue = unitValue.to('px'); + return accumulator - pxValue.value; + }, 0); + + this.skidding = skidding; + } + + isExpanded(): boolean { + return this.popupRef.value ? this.popupRef.value.active : false; + } + + renderSubmenu() { + // const isRtl = getComputedStyle(this.host).direction === 'rtl'; + + // Always render the slot, but conditionally render the outer + if (!this.isConnected) { + return html` `; + } + + return html` + + + + `; + } +} diff --git a/src/components/mf-actions/mf-actions.component.ts b/src/components/mf-actions/mf-actions.component.ts new file mode 100644 index 0000000000..2561c4ca04 --- /dev/null +++ b/src/components/mf-actions/mf-actions.component.ts @@ -0,0 +1,163 @@ +import { html } from 'lit'; +import { query } from 'lit/decorators.js'; +import componentStyles from '../../styles/component.styles.js'; +import ShoelaceElement from '../../internal/shoelace-element.js'; +import styles from './mf-actions.styles.js'; +import type { CSSResultGroup } from 'lit'; +import type MfActionItem from '../mf-action-item/mf-action-item.component.js'; +export interface ActionSelectEventDetail { + item: MfActionItem; +} + +/** + * @summary Short summary of the component's intended use. + * @documentation https://shoelace.style/components/mf-actions + * @status experimental + * @since 2.0 + * + * @dependency sl-example + * + * @slot - The Actions's content, including Action items and dividers. + * + * @event {{ item: MFActionItem }} Mf-action-select - Emitted when a Action item is selected. + * + * @todo add event for action click + * @todo Support for multiple components as slots such as: action label, . + */ +export default class MfActions extends ShoelaceElement { + static styles: CSSResultGroup = [componentStyles, styles]; + + @query('slot') defaultSlot: HTMLSlotElement; + + connectedCallback() { + super.connectedCallback(); + this.setAttribute('role', 'menu'); + } + + private handleClick(event: MouseEvent) { + const actionItemTypes = ['actionitem']; + + const composedPath = event.composedPath(); + const target = composedPath.find((el: Element) => actionItemTypes.includes(el?.getAttribute?.('role') || '')); + + if (!target) return; + + const closestMenu = composedPath.find((el: Element) => el?.getAttribute?.('role') === 'menu'); + const clickHasSubmenu = closestMenu !== this; + + // Make sure we're the menu thats supposed to be handling the click event. + if (clickHasSubmenu) return; + + // This isn't true. But we use it for TypeScript checks below. + const item = target as MfActionItem; + + this.emit('mf-action-select', { detail: { item } }); + } + + private handleKeyDown(event: KeyboardEvent) { + // Make a selection when pressing enter or space + if (event.key === 'Enter' || event.key === ' ') { + const item = this.getCurrentItem(); + event.preventDefault(); + event.stopPropagation(); + + // Simulate a click to support @click handlers on menu items that also work with the keyboard + item?.click(); + } + + // Move the selection when pressing down or up + else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { + const items = this.getAllItems(); + const activeItem = this.getCurrentItem(); + let index = activeItem ? items.indexOf(activeItem) : 0; + + if (items.length > 0) { + event.preventDefault(); + event.stopPropagation(); + + if (event.key === 'ArrowDown') { + index++; + } else if (event.key === 'ArrowUp') { + index--; + } else if (event.key === 'Home') { + index = 0; + } else if (event.key === 'End') { + index = items.length - 1; + } + + if (index < 0) { + index = items.length - 1; + } + if (index > items.length - 1) { + index = 0; + } + + this.setCurrentItem(items[index]); + items[index].focus(); + } + } + } + + private handleMouseDown(event: MouseEvent) { + const target = event.target as HTMLElement; + + if (this.isActionItem(target)) { + this.setCurrentItem(target as MfActionItem); + } + } + + private handleSlotChange() { + const items = this.getAllItems(); + + // Reset the roving tab index when the slotted items change + if (items.length > 0) { + this.setCurrentItem(items[0]); + } + } + + private isActionItem(item: HTMLElement) { + return item.tagName.toLowerCase() === 'mf-action-item' || ['actionitem'].includes(item.getAttribute('role') ?? ''); + } + + /** @internal Gets all slotted action items, ignoring dividers, headers, and other elements. */ + getAllItems() { + return [...this.defaultSlot.assignedElements({ flatten: true })].filter((el: HTMLElement) => { + if (el.inert || !this.isActionItem(el)) { + return false; + } + return true; + }) as MfActionItem[]; + } + + /** + * @internal Gets the current action item, which is the action item that has `tabindex="0"` within the roving tab index. + * The action item may or may not have focus, but for keyboard interaction purposes it's considered the "active" item. + */ + getCurrentItem() { + return this.getAllItems().find(i => i.getAttribute('tabindex') === '0'); + } + + /** + * @internal Sets the current action item to the specified element. This sets `tabindex="0"` on the target element and + * `tabindex="-1"` to all other items. This method must be called prior to setting focus on a action item. + */ + setCurrentItem(item: MfActionItem) { + const items = this.getAllItems(); + + // Update tab indexes + items.forEach(i => { + i.setAttribute('tabindex', i === item ? '0' : '-1'); + }); + } + + render() { + return html` + + `; + } +} diff --git a/src/components/mf-actions/mf-actions.styles.ts b/src/components/mf-actions/mf-actions.styles.ts new file mode 100644 index 0000000000..3aa22e7603 --- /dev/null +++ b/src/components/mf-actions/mf-actions.styles.ts @@ -0,0 +1,25 @@ +import { css } from 'lit'; + +export default css` + :host { + display: flex; + position: relative; + background: var(--sl-panel-background-color); + border: solid var(--sl-panel-border-width) var(--sl-panel-border-color); + border-radius: var(--sl-border-radius-medium); + padding: var(--sl-spacing-x-small) 0; + overflow: auto; + overscroll-behavior: none; + flex-direction: row; + justify-content: space-evenly; + align-items: baseline; + } + + ::slotted(sl-divider) { + --spacing: var(--sl-spacing-x-small); + } + + :host-context(mf-action-item) { + flex-direction: column; + } +`; diff --git a/src/components/mf-actions/mf-actions.test.ts b/src/components/mf-actions/mf-actions.test.ts new file mode 100644 index 0000000000..a4969b3e7e --- /dev/null +++ b/src/components/mf-actions/mf-actions.test.ts @@ -0,0 +1,10 @@ +import '../../../dist/shoelace.js'; +import { expect, fixture, html } from '@open-wc/testing'; + +describe('', () => { + it('should render a component', async () => { + const el = await fixture(html` `); + + expect(el).to.exist; + }); +}); diff --git a/src/components/mf-actions/mf-actions.ts b/src/components/mf-actions/mf-actions.ts new file mode 100644 index 0000000000..53c78527ad --- /dev/null +++ b/src/components/mf-actions/mf-actions.ts @@ -0,0 +1,12 @@ +import MfActions from './mf-actions.component.js'; + +export * from './mf-actions.component.js'; +export default MfActions; + +MfActions.define('mf-actions'); + +declare global { + interface HTMLElementTagNameMap { + 'mf-actions': MfActions; + } +} diff --git a/src/components/mf-header/mf-header.component.ts b/src/components/mf-header/mf-header.component.ts new file mode 100644 index 0000000000..10bae5fb67 --- /dev/null +++ b/src/components/mf-header/mf-header.component.ts @@ -0,0 +1,55 @@ +import { classMap } from 'lit/directives/class-map.js'; +import { html } from 'lit'; +import { LocalizeController } from '../../utilities/localize.js'; +import { property } from 'lit/decorators.js'; +import { watch } from '../../internal/watch.js'; +import componentStyles from '../../styles/component.styles.js'; +import ShoelaceElement from '../../internal/shoelace-element.js'; +import styles from './mf-header.styles.js'; +import type { CSSResultGroup } from 'lit'; + +/** + * @summary Short summary of the component's intended use. + * @documentation https://shoelace.style/components/mf-header + * @status experimental + * @since 2.0 + * + * @dependency sl-example + * + * + * @slot - The default slot. + * @slot example - An example slot. + * + * @csspart base - The component's base wrapper. + * + * @cssproperty --example - An example CSS custom property. + * + */ +export default class MfHeader extends ShoelaceElement { + static styles: CSSResultGroup = [componentStyles, styles]; + + private readonly localize = new LocalizeController(this); + + /** An example attribute. */ + @property() attr = 'example'; + + @watch('example') + handleExampleChange() { + // do something + } + + render() { + const isRtl = this.localize.dir() === 'rtl'; + + return html` +
+ +
+ `; + } +} diff --git a/src/components/mf-header/mf-header.styles.ts b/src/components/mf-header/mf-header.styles.ts new file mode 100644 index 0000000000..bd82185809 --- /dev/null +++ b/src/components/mf-header/mf-header.styles.ts @@ -0,0 +1,20 @@ +import { css } from 'lit'; + +export default css` + :host { + display: block; + position: relative; + } + header { + display: block; + width: 100%; + } + + header slot { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + } +`; diff --git a/src/components/mf-header/mf-header.test.ts b/src/components/mf-header/mf-header.test.ts new file mode 100644 index 0000000000..6a84315201 --- /dev/null +++ b/src/components/mf-header/mf-header.test.ts @@ -0,0 +1,10 @@ +import '../../../dist/shoelace.js'; +import { expect, fixture, html } from '@open-wc/testing'; + +describe('', () => { + it('should render a component', async () => { + const el = await fixture(html` `); + + expect(el).to.exist; + }); +}); diff --git a/src/components/mf-header/mf-header.ts b/src/components/mf-header/mf-header.ts new file mode 100644 index 0000000000..beb03f7e75 --- /dev/null +++ b/src/components/mf-header/mf-header.ts @@ -0,0 +1,12 @@ +import MfHeader from './mf-header.component.js'; + +export * from './mf-header.component.js'; +export default MfHeader; + +MfHeader.define('mf-header'); + +declare global { + interface HTMLElementTagNameMap { + 'mf-header': MfHeader; + } +} diff --git a/src/components/mf-logo/mf-logo.component.ts b/src/components/mf-logo/mf-logo.component.ts new file mode 100644 index 0000000000..32979122ab --- /dev/null +++ b/src/components/mf-logo/mf-logo.component.ts @@ -0,0 +1,52 @@ +import { classMap } from 'lit/directives/class-map.js'; +import { html } from 'lit'; +import { LocalizeController } from '../../utilities/localize.js'; +import { property } from 'lit/decorators.js'; +import componentStyles from '../../styles/component.styles.js'; +import ShoelaceElement from '../../internal/shoelace-element.js'; +import styles from './mf-logo.styles.js'; +import type { CSSResultGroup } from 'lit'; + +/** + * @summary Short summary of the component's intended use. + * @documentation https://shoelace.style/components/mf-logo + * @status experimental + * @since 2.0 + * + * @dependency sl-example + * + * + * @slot - The default slot. + * + * @csspart base - The component's base wrapper. + * + * @cssproperty --example - An example CSS custom property. + */ +export default class MfLogo extends ShoelaceElement { + static styles: CSSResultGroup = [componentStyles, styles]; + + private readonly localize = new LocalizeController(this); + + /** Logo href value. */ + @property() href = '/'; + + /** Logo src value. */ + @property() src = ''; + + /** Logo Alt value. */ + @property() alt = ''; + + render() { + const isRtl = this.localize.dir() === 'rtl'; + return html`
+ ${this.alt} + `; + } +} diff --git a/src/components/mf-logo/mf-logo.styles.ts b/src/components/mf-logo/mf-logo.styles.ts new file mode 100644 index 0000000000..8ebe8964c4 --- /dev/null +++ b/src/components/mf-logo/mf-logo.styles.ts @@ -0,0 +1,17 @@ +import { css } from 'lit'; + +export default css` + :host { + display: block; + } + :host a { + display: block; + text-decoration: none; + } + + a img { + display: block; + width: 100%; + height: 100%; + } +`; diff --git a/src/components/mf-logo/mf-logo.test.ts b/src/components/mf-logo/mf-logo.test.ts new file mode 100644 index 0000000000..1bf1f06cbb --- /dev/null +++ b/src/components/mf-logo/mf-logo.test.ts @@ -0,0 +1,10 @@ +import '../../../dist/shoelace.js'; +import { expect, fixture, html } from '@open-wc/testing'; + +describe('', () => { + it('should render a component', async () => { + const el = await fixture(html` `); + + expect(el).to.exist; + }); +}); diff --git a/src/components/mf-logo/mf-logo.ts b/src/components/mf-logo/mf-logo.ts new file mode 100644 index 0000000000..843e8f214f --- /dev/null +++ b/src/components/mf-logo/mf-logo.ts @@ -0,0 +1,12 @@ +import MfLogo from './mf-logo.component.js'; + +export * from './mf-logo.component.js'; +export default MfLogo; + +MfLogo.define('mf-logo'); + +declare global { + interface HTMLElementTagNameMap { + 'mf-logo': MfLogo; + } +} diff --git a/src/components/mf-nav-item/mf-nav-item.component.ts b/src/components/mf-nav-item/mf-nav-item.component.ts index 1f4ba93ae4..3f72568297 100644 --- a/src/components/mf-nav-item/mf-nav-item.component.ts +++ b/src/components/mf-nav-item/mf-nav-item.component.ts @@ -71,6 +71,12 @@ export default class MfNavItem extends ShoelaceElement { /** Draws the nav item in a highlight state, provides custom class highlight. */ @property({ type: Boolean, reflect: true }) highlight = false; + /** Draws the nav item navigation in a full width state, provides custom class wide. */ + @property({ type: Boolean, reflect: true }) wide = false; + + /** Hides the action item Chevron even if submenu is available. */ + @property({ type: Boolean, reflect: true }) hideChevron = false; + private readonly hasSlotController = new HasSlotController(this, 'subnav'); private subnavController: SubnavController = new SubnavController(this, this.hasSlotController); @@ -145,12 +151,14 @@ export default class MfNavItem extends ShoelaceElement { part="base" class=${classMap({ 'nav-item': true, + 'nav-item--wide': this.wide, 'nav-item--rtl': isRtl, 'nav-item--disabled': this.disabled, 'nav-item--loading': this.loading, 'nav-item--highlight': this.highlight, 'nav-item--has-subnav': this.isSubnav(), - 'nav-item--subnav-expanded': isSubnavExpanded + 'nav-item--subnav-expanded': isSubnavExpanded, + 'nav-item--hide-subnav-chevron': this.hideChevron })} ?aria-haspopup="${this.isSubnav()}" ?aria-expanded="${isSubnavExpanded ? true : false}" diff --git a/src/components/mf-nav-item/mf-nav-item.styles.ts b/src/components/mf-nav-item/mf-nav-item.styles.ts index 199f5b8653..f3fabeae58 100644 --- a/src/components/mf-nav-item/mf-nav-item.styles.ts +++ b/src/components/mf-nav-item/mf-nav-item.styles.ts @@ -12,7 +12,7 @@ export default css` } .nav-item { - position: relative; + // position: relative; display: flex; align-items: stretch; font-family: var(--sl-font-sans); @@ -132,23 +132,26 @@ export default css` visibility: hidden; } - .nav-item--checked .nav-item__check, .nav-item--has-subnav .nav-item__chevron { visibility: visible; } + .nav-item--hide-subnav-chevron .nav-item__chevron { + display: none; + } + /* Add elevation and z-index to subnavs */ sl-popup::part(popup) { box-shadow: var(--sl-shadow-large); z-index: var(--sl-z-index-dropdown); margin-top: var(--subnav-offset); } - + .nav-item--rtl { margin-left: var(--nav-spacing-inline); margin-right: 0px !important; } - + .nav-item--rtl sl-popup::part(popup) { margin-top: calc(-1 * var(--subnav-offset)); } @@ -161,8 +164,13 @@ export default css` } } - ::slotted(sl-menu) { + ::slotted(mf-navigation) { max-width: var(--auto-size-available-width) !important; max-height: var(--auto-size-available-height) !important; } + + .nav-item--wide sl-popup::part(popup) { + left: 0; + width: 100%; + } `; diff --git a/src/components/mf-nav-item/subnav-controller.ts b/src/components/mf-nav-item/subnav-controller.ts index f9c200ded6..0e364e4a94 100644 --- a/src/components/mf-nav-item/subnav-controller.ts +++ b/src/components/mf-nav-item/subnav-controller.ts @@ -258,6 +258,10 @@ export class SubnavController implements ReactiveController { return this.popupRef.value ? this.popupRef.value.active : false; } + isWide(): boolean { + return this.host.wide ? this.host.wide : false; + } + renderSubnav() { // const isRtl = getComputedStyle(this.host).direction === 'rtl'; @@ -269,15 +273,15 @@ export class SubnavController implements ReactiveController { return html` diff --git a/src/components/mf-navigation/mf-navigation.component.ts b/src/components/mf-navigation/mf-navigation.component.ts index ee0706837d..2dffb6d873 100644 --- a/src/components/mf-navigation/mf-navigation.component.ts +++ b/src/components/mf-navigation/mf-navigation.component.ts @@ -18,7 +18,7 @@ export interface NavSelectEventDetail { * @status experimental * @since 2.0 * - * @dependency sl-example + * @dependency mf-nav-item * * * @@ -27,7 +27,6 @@ export interface NavSelectEventDetail { * * @csspart base - The component's base wrapper. * - * @cssproperty --example - An example CSS custom property. * * @event {{ item: MfNavItem }} mf-nav-select - Emitted when a nav item is selected. */ @@ -36,9 +35,13 @@ export default class MfNavigation extends ShoelaceElement { @query('slot') defaultSlot: HTMLSlotElement; private readonly localize = new LocalizeController(this); + /** provides a vertical class for the navigation */ @property({ type: Boolean, reflect: true }) vertical = false; + /** provides a wide class for the navigation */ + @property({ type: Boolean, reflect: true }) wide = false; + connectedCallback() { super.connectedCallback(); this.setAttribute('role', 'nav'); @@ -179,7 +182,8 @@ export default class MfNavigation extends ShoelaceElement { class=${classMap({ navigation: true, 'navigation--rtl': isRtl, - 'navigation--vertical': this.vertical + 'navigation--vertical': this.vertical, + 'navigation--wide': this.wide })} > ; + +declare global { + interface GlobalEventHandlersEventMap { + 'mf-action-select': MfActionSelectEvent; + } +} diff --git a/src/shoelace.ts b/src/shoelace.ts index 81e23d970b..82849bb85c 100644 --- a/src/shoelace.ts +++ b/src/shoelace.ts @@ -60,6 +60,10 @@ export { default as SlVisuallyHidden } from './components/visually-hidden/visual export { default as MfNavigation } from './components/mf-navigation/mf-navigation.js'; export { default as MfNavItem } from './components/mf-nav-item/mf-nav-item.js'; +export { default as MfHeader } from './components/mf-header/mf-header.js'; +export { default as MfLogo } from './components/mf-logo/mf-logo.js'; +export { default as MfNavActions } from './components/mf-actions/mf-actions.js'; +export { default as MfActionItem } from './components/mf-action-item/mf-action-item.js'; /* plop:component */ // Utilities From bc428b337f97de808017e6228dbda11fd27fb033 Mon Sep 17 00:00:00 2001 From: MAbuseini Date: Mon, 11 Nov 2024 09:07:22 +0300 Subject: [PATCH 15/17] updated based on PR comments --- docs/assets/scripts/figma-button.js | 24 -- docs/pages/components/mf-header.md | 420 +++++++++++++------------- src/components/icon/library.system.ts | 6 - 3 files changed, 206 insertions(+), 244 deletions(-) delete mode 100644 docs/assets/scripts/figma-button.js diff --git a/docs/assets/scripts/figma-button.js b/docs/assets/scripts/figma-button.js deleted file mode 100644 index e2611cdf44..0000000000 --- a/docs/assets/scripts/figma-button.js +++ /dev/null @@ -1,24 +0,0 @@ -window.addEventListener('load', function (event) { - console.log('on load - Figma button, document is ', document.readyState); - - var figmaButton = document.querySelector('[data-figma-button]'); - - if (figmaButton) { - console.log('figma button found', figmaButton.dataset); - - figmaButton.onclick = function () { - let status = figmaButton.dataset.figmaButton; - let icon = figmaButton.querySelector('sl-icon'); - - console.log('figma button clicked with status', status); - - if (status === 'on') { - figmaButton.dataset.figmaButton = 'off'; - icon.name = 'figma-mono'; - } else { - figmaButton.dataset.figmaButton = 'on'; - icon.name = 'figma'; - } - }; - } -}); diff --git a/docs/pages/components/mf-header.md b/docs/pages/components/mf-header.md index 7659642dc2..c0f88fc414 100644 --- a/docs/pages/components/mf-header.md +++ b/docs/pages/components/mf-header.md @@ -36,241 +36,233 @@ layout: component ### First Example ```html:preview - - - - - - - Neighbourhoods - - Serra - Cilia - - - Contact Us - Download Brochure - - Language - - AR - EN - - - - + + + + + Neighbourhoods + + Serra + Cilia + + + Contact Us + Download Brochure + + Language + + AR + EN + + + + ``` ### Second Example ```html:preview - + + + + + + AR + + + + UAE + + + + KSA + + + + AR + + + EN + + + + + + + +

+ LOG IN OR REGISTER NOW +
+ One account for all Majid Al Futtaim brands +
+ Log in with your existing Majid Al Futtaim account or register now for a seamless + experience and to redeem SHARE points. +

+ Login + Registration +
+
+ + + + + + +
+ + + Men + + Sale + New In + + Adidas + Nike + NB + item + item + + + Designers + clothing + Shoes + + + + Women + + Sale + New In + Designers + clothing + Shoes + + - - - - AR - - - - UAE - - - - KSA - - - - AR - - - EN - - - - - - - -

- LOG IN OR REGISTER NOW -
- One account for all Majid Al Futtaim brands -
- Log in with your existing Majid Al Futtaim account or register now for a seamless - experience and to redeem SHARE points. -

- Login - Registration - -
-
- - - - - - -
- - - Men - - Sale - New In - - Adidas - Nike - NB - item - item - - - Designers - clothing - Shoes - - - - - Women - - Sale - New In - Designers - clothing - Shoes - - - - - Home & Gifts - - Sale - New In - Designers - clothing - Shoes - - - - - -
+ + Home & Gifts + + Sale + New In + Designers + clothing + Shoes + + +
+ ``` ### Third Example ```html:preview - - - Men - - Sale - New In - - Adidas - Nike - NB - item - item - - - Designers - clothing - Shoes - - + + + Men + + Sale + New In + + Adidas + Nike + NB + item + item + + + Designers + clothing + Shoes + + - - Women - - Sale - New In - Designers - clothing - Shoes - - + + Women + + Sale + New In + Designers + clothing + Shoes + + - - Home & Gifts - - Sale - New In - Designers - clothing - Shoes - - - - - - + + Home & Gifts + + Sale + New In + Designers + clothing + Shoes + + + - - - - AR - - - - UAE - - - - KSA - - - - AR - - - EN - - - - - - - -

- LOG IN OR REGISTER NOW -
- One account for all Majid Al Futtaim brands -
- Log in with your existing Majid Al Futtaim account or register now for a seamless - experience and to redeem SHARE points. -

- Login - Registration - -
-
- - - - - - -
+ -
+ + + + AR + + + + UAE + + + + KSA + + + + AR + + + EN + + + + + + + +

+ LOG IN OR REGISTER NOW +
+ One account for all Majid Al Futtaim brands +
+ Log in with your existing Majid Al Futtaim account or register now for a seamless + experience and to redeem SHARE points. +

+ Login + Registration + +
+
+ + + + + + +
+ ``` TODO diff --git a/src/components/icon/library.system.ts b/src/components/icon/library.system.ts index 40fff6cc4f..6654ae5d7c 100644 --- a/src/components/icon/library.system.ts +++ b/src/components/icon/library.system.ts @@ -117,12 +117,6 @@ const icons = { - `, - figma: ` - Figma.logoCreated using Figma - `, - 'figma-mono': ` - ` }; From 02697d23afb5553b7b7f1bad008863f08b098df4 Mon Sep 17 00:00:00 2001 From: MAbuseini Date: Mon, 11 Nov 2024 09:12:43 +0300 Subject: [PATCH 16/17] updated based on PR comments --- docs/_includes/default.njk | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/_includes/default.njk b/docs/_includes/default.njk index af6d70c7a9..6263759fbd 100644 --- a/docs/_includes/default.njk +++ b/docs/_includes/default.njk @@ -53,7 +53,6 @@ - From 43b672e85ef9da07de475f791844726332b14753 Mon Sep 17 00:00:00 2001 From: MAbuseini Date: Mon, 11 Nov 2024 09:34:57 +0300 Subject: [PATCH 17/17] updated based on PR comments --- docs/_includes/default.njk | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/_includes/default.njk b/docs/_includes/default.njk index 6263759fbd..315d11e69a 100644 --- a/docs/_includes/default.njk +++ b/docs/_includes/default.njk @@ -80,11 +80,6 @@ - {# Figma #} - - - - {# Theme selector #}