From 5b99fa4cbe1db3a30c4eb829796c073e33915028 Mon Sep 17 00:00:00 2001 From: 321gillian Date: Wed, 27 Nov 2024 14:31:48 +0000 Subject: [PATCH] feat(avatar-group): overflow --- .../beta/gux-avatar-group/example.html | 353 +++++++++++++++++- .../gux-avatar-group-item.tsx | 56 +-- .../gux-avatar-group/gux-avatar-group.scss | 4 + .../gux-avatar-group.service.ts | 86 +++-- .../gux-avatar-group/gux-avatar-group.tsx | 136 ++++++- ...tem.types.ts => gux-avatar-group.types.ts} | 6 + .../gux-avatar-overflow-item.scss | 139 +++++++ .../gux-avatar-overflow-item.tsx | 111 ++++++ .../gux-avatar-overflow-item/readme.md | 51 +++ .../gux-avatar-overflow-item.e2e.ts.snap | 3 + .../gux-avatar-overflow-item.spec.ts.snap | 40 ++ .../tests/gux-avatar-overflow-item.e2e.ts | 95 +++++ .../tests/gux-avatar-overflow-item.spec.ts | 118 ++++++ .../gux-avatar-overflow.scss | 76 ++++ .../gux-avatar-overflow.service.ts | 110 ++++++ .../gux-avatar-overflow.tsx | 236 ++++++++++++ .../gux-avatar-overflow/readme.md | 53 +++ .../gux-avatar-overflow.e2e.ts.snap | 3 + .../gux-avatar-overflow.spec.ts.snap | 231 ++++++++++++ .../tests/gux-avatar-overflow.e2e.ts | 135 +++++++ .../tests/gux-avatar-overflow.spec.ts | 299 +++++++++++++++ .../beta/gux-avatar-group/readme.md | 22 ++ .../gux-avatar-group.e2e.ts.snap | 295 ++++++++++++++- .../gux-avatar-group.spec.ts.snap | 55 +-- .../tests/gux-avatar-group.e2e.ts | 116 ++++-- .../components/stable/gux-toggle/readme.md | 1 + 26 files changed, 2695 insertions(+), 135 deletions(-) rename packages/genesys-spark-components/src/components/beta/gux-avatar-group/{gux-avatar-group-item/gux-avatar-group-item.types.ts => gux-avatar-group.types.ts} (50%) create mode 100644 packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/gux-avatar-overflow-item.scss create mode 100644 packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/gux-avatar-overflow-item.tsx create mode 100644 packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/readme.md create mode 100644 packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/tests/__snapshots__/gux-avatar-overflow-item.e2e.ts.snap create mode 100644 packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/tests/__snapshots__/gux-avatar-overflow-item.spec.ts.snap create mode 100644 packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/tests/gux-avatar-overflow-item.e2e.ts create mode 100644 packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/tests/gux-avatar-overflow-item.spec.ts create mode 100644 packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow.scss create mode 100644 packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow.service.ts create mode 100644 packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow.tsx create mode 100644 packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/readme.md create mode 100644 packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/tests/__snapshots__/gux-avatar-overflow.e2e.ts.snap create mode 100644 packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/tests/__snapshots__/gux-avatar-overflow.spec.ts.snap create mode 100644 packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/tests/gux-avatar-overflow.e2e.ts create mode 100644 packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/tests/gux-avatar-overflow.spec.ts diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/example.html b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/example.html index 70360433ec..27464f1904 100644 --- a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/example.html +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/example.html @@ -1,53 +1,396 @@

gux-avatar-group-beta

+

Summary

+A group that can contain gux-avatar-group-item-beta tags. A tooltip +will be displayed for each group item on focus or hover. + - + Conor Darcy + + +

Properties

+

quantity

+The quantity property indicates the number of group items that will be displayed +outside of the overflow menu. The minimum is 1 and the maximum is 7 (7 is also +the default amount). + + + + Conor Darcy + + + + + + + + + Thomas Underwood + + + + + + + Conor Darcy + + + + + + + + + Thomas Underwood + + + + + + + Conor Darcy + + + + + + + + + Thomas Underwood + + + + + + + Conor Darcy + + + + + + + + + Thomas Underwood + + + + + + + Conor Darcy + + + + + + + + + Thomas Underwood + + + + + + + Conor Darcy + + + + + + + + + Thomas Underwood + + + + + Conor Darcy + + + + + + + + + + Thomas Underwood + diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group-item/gux-avatar-group-item.tsx b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group-item/gux-avatar-group-item.tsx index 2cd06548af..bd8cc959b9 100644 --- a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group-item/gux-avatar-group-item.tsx +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group-item/gux-avatar-group-item.tsx @@ -5,11 +5,12 @@ import { Prop, Element, Listen, - Method + Method, + Host } from '@stencil/core'; import { trackComponent } from '@utils/tracking/usage'; import { logWarn } from '@utils/error/log-error'; -import { GuxAvatarAccent } from './gux-avatar-group-item.types'; +import { GuxAvatarAccent } from '../gux-avatar-group.types'; import { groupKeyboardNavigation } from '../gux-avatar-group.service'; import { generateInitials } from '@utils/string/generate-initials'; @@ -104,32 +105,33 @@ export class GuxAvatarGroupItem { render(): JSX.Element { return ( - + + + + + + ) as JSX.Element; } } diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group.scss b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group.scss index b3ef492177..199444b41d 100644 --- a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group.scss +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group.scss @@ -10,3 +10,7 @@ outline: none; } } + +::slotted(gux-avatar-group-item-beta.gux-hidden) { + display: none; +} diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group.service.ts b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group.service.ts index 5c301e7dbd..b73bf75ef7 100644 --- a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group.service.ts +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group.service.ts @@ -1,3 +1,6 @@ +import { getClosestElement } from '@utils/dom/get-closest-element'; +import { GuxAvatarGroupChild } from './gux-avatar-group.types'; + export function groupKeyboardNavigation( event: KeyboardEvent, currentElement: Element @@ -34,7 +37,7 @@ export function groupKeyboardNavigation( } export function resetFocusableSibling(element: Element) { - const focusableSibling = getSiblings(element).find((sibling: Element) => { + const focusableSibling = getGroupItems(element).find((sibling: Element) => { const button = getGroupItemButton(sibling); return button && button.tabIndex !== -1; }); @@ -51,30 +54,37 @@ export function setFocusTarget(element: Element): void { function focusFirstSibling(currentElement: Element): void { const firstFocusableElement = getFirstFocusableElement( currentElement - ) as HTMLGuxAvatarGroupItemBetaElement; + ) as GuxAvatarGroupChild; if (firstFocusableElement) { void firstFocusableElement.guxFocus(); - void setItemTabIndex(firstFocusableElement, 0); void resetFocusableSibling(firstFocusableElement); + void setItemTabIndex(firstFocusableElement, 0); } } function focusLastSibling(currentElement: Element): void { const lastFocusableElement = getLastFocusableElement( currentElement - ) as HTMLGuxAvatarGroupItemBetaElement; + ) as GuxAvatarGroupChild; if (lastFocusableElement) { void lastFocusableElement.guxFocus(); - void setItemTabIndex(lastFocusableElement, 0); void resetFocusableSibling(lastFocusableElement); + void setItemTabIndex(lastFocusableElement, 0); } } function focusPreviousSiblingLoop(currentElement: Element): void { - const previousFocusableElement = - currentElement.previousElementSibling as HTMLGuxAvatarGroupItemBetaElement; + const groupItems = getGroupItems(currentElement); + const currentElementIndex = groupItems.findIndex( + (item: Element) => item === currentElement + ); + + const previousIndex = (currentElementIndex - 1) % groupItems.length; + const previousFocusableElement = groupItems[ + previousIndex + ] as GuxAvatarGroupChild; setItemTabIndex(currentElement, -1); @@ -87,51 +97,56 @@ function focusPreviousSiblingLoop(currentElement: Element): void { } function focusNextSiblingLoop(currentElement: Element): void { - const nextFocusableElement = - currentElement.nextElementSibling as HTMLGuxAvatarGroupItemBetaElement; + const groupItems = getGroupItems(currentElement); + const currentElementIndex = groupItems.findIndex( + (item: Element) => item === currentElement + ); + + const nextIndex = (currentElementIndex + 1) % groupItems.length; + if (nextIndex === 0) { + focusFirstSibling(currentElement); + } + const nextFocusableElement = groupItems[nextIndex] as GuxAvatarGroupChild; + setItemTabIndex(currentElement, -1); - if (nextFocusableElement) { + if (nextFocusableElement !== null) { void nextFocusableElement.guxFocus(); void setItemTabIndex(nextFocusableElement, 0); - } else { - focusFirstSibling(currentElement); } } function getFirstFocusableElement(currentElement: Element): Element { - let firstFocusableElement = currentElement; - - while (firstFocusableElement.previousElementSibling !== null) { - firstFocusableElement = firstFocusableElement.previousElementSibling; - } - - return firstFocusableElement; + return getGroupItems(currentElement)[0]; } function getLastFocusableElement(currentElement: Element): Element { - let lastFocusableElement = currentElement; - - while (lastFocusableElement.nextElementSibling !== null) { - lastFocusableElement = lastFocusableElement.nextElementSibling; - } - - return lastFocusableElement; + const groupItems = getGroupItems(currentElement); + return groupItems[groupItems.length - 1]; } function getGroupItemButton(element: Element): HTMLButtonElement { return element.shadowRoot?.querySelector('button') as HTMLButtonElement; } -function getSiblings(element: Element): Element[] { - const siblings = Array.from(element.parentElement.children); - - // Early return for performance when there are no siblings - if (siblings.length <= 1) { - return []; +function getGroupItems(element: Element): Element[] { + const group = getClosestElement( + 'gux-avatar-group-beta', + element as HTMLElement + ) as Element; + + const slottedItems = Array.from( + group.querySelectorAll('gux-avatar-group-item-beta') + ).filter(child => !isHidden(child)) as GuxAvatarGroupChild[]; + + const overflow = group.shadowRoot.querySelector( + 'gux-avatar-overflow-beta' + ) as HTMLGuxAvatarOverflowBetaElement; + if (overflow) { + slottedItems.push(overflow); } - return siblings.filter(child => child !== element); + return slottedItems as GuxAvatarGroupChild[]; } function setItemTabIndex(element: Element, newIndex: number) { @@ -139,6 +154,9 @@ function setItemTabIndex(element: Element, newIndex: number) { if (button) { button.tabIndex = newIndex; } else { - console.log('No button found in the gux-avatar-group-item element'); + console.log('No button found in the element'); } } +function isHidden(element: Element): boolean { + return element.classList.contains('gux-hidden'); +} diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group.tsx b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group.tsx index 2f5d681e35..2ae597331c 100644 --- a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group.tsx +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group.tsx @@ -1,10 +1,22 @@ -import { Component, h, JSX, Element, Host, Listen } from '@stencil/core'; +import { Component, h, JSX, Element, Host, Listen, Prop } from '@stencil/core'; import { trackComponent } from '@utils/tracking/usage'; import { logWarn } from '@utils/error/log-error'; import { setFocusTarget, resetFocusableSibling } from './gux-avatar-group.service'; +import { + GuxAvatarAccent, + GuxAvatarGroupQuantity +} from './gux-avatar-group.types'; + +interface ProcessedItem { + img?: HTMLImageElement | null; + name: string; + accent: GuxAvatarAccent; + isOverflow: boolean; + groupItem: HTMLGuxAvatarGroupItemBetaElement; +} /** * @slot - slot for gux-avatar-group-item components @@ -15,43 +27,75 @@ import { shadow: { delegatesFocus: true } }) export class GuxAvatarGroup { + private processedGroupItems: ProcessedItem[] = []; + @Element() root: HTMLElement; + @Prop() + quantity: GuxAvatarGroupQuantity = 7; + async componentWillLoad(): Promise { trackComponent(this.root); - if (this.root.querySelectorAll('gux-avatar-group-item-beta').length === 0) { - logWarn( - this.root, - 'gux-avatar-group-beta: No gux-avatar-group-item-beta tags found in slot. Please add gux-avatar-group-item-beta tags to the slot.' - ); - } } componentDidLoad(): void { this.setInitialFocusTarget(); } - private setInitialFocusTarget(): void { - const groupItems = Array.from( + componentWillRender(): void { + this.processGroupItems(); + this.hideOverflowGroupItems(); + } + + @Listen('mouseover') + onMouseOver(event: MouseEvent): void { + this.hideCurrentTooltip(event); + } + + private getGroupItems(): HTMLGuxAvatarGroupItemBetaElement[] { + return Array.from( this.root.children ) as HTMLGuxAvatarGroupItemBetaElement[]; + } + + private setInitialFocusTarget(): void { + const groupItems = this.getGroupItems(); + if (groupItems.length > 0) { const firstGroupItem = groupItems[0] as Element; setFocusTarget(firstGroupItem); } } - @Listen('mouseover') - onMouseOver(event: MouseEvent): void { - this.hideCurrentTooltip(event); + private processGroupItems(): void { + const groupItems = this.getGroupItems(); + this.validateChildElements(groupItems); + + this.processedGroupItems = groupItems.map((item, index) => { + return { + img: item.querySelector('img') ?? null, + name: item?.name ?? '', + accent: item?.accent ?? null, + isOverflow: index >= this.quantity, + groupItem: item + } as ProcessedItem; + }); + } + + private hideOverflowGroupItems(): void { + const overflowItems = this.processedGroupItems.filter( + item => item.isOverflow + ); + + overflowItems.forEach(item => { + item.groupItem.classList.add('gux-hidden'); + }); } private hideCurrentTooltip(event: Event) { const target = event.target as HTMLGuxAvatarGroupItemBetaElement; - const groupItems = Array.from( - this.root.children - ) as HTMLGuxAvatarGroupItemBetaElement[]; + const groupItems = this.getGroupItems(); const focusedChild = groupItems.find(child => child.matches(':focus-within') @@ -64,14 +108,72 @@ export class GuxAvatarGroup { private handleClick(event: MouseEvent) { const clickedElement = event.target as HTMLGuxAvatarGroupItemBetaElement; - resetFocusableSibling(clickedElement); - setFocusTarget(clickedElement); + + if ( + clickedElement.tagName === 'GUX-AVATAR-GROUP-ITEM-BETA' && + !clickedElement.classList.contains('gux-hidden') + ) { + resetFocusableSibling(clickedElement); + setFocusTarget(clickedElement); + } + } + + private validateChildElements(childElements: HTMLElement[]) { + if (childElements.length === 0) { + logWarn( + this.root, + 'gux-avatar-group-beta: No child elements detected. Please add some gux-avatar-item-beta tags to slot' + ); + } + + const validTagNames = ['GUX-AVATAR-GROUP-ITEM-BETA']; + const invalidElements = childElements.some( + el => !validTagNames.includes(el.tagName) + ); + + if (invalidElements) { + logWarn( + this.root, + 'gux-avatar-group-beta: Invalid child element detected. All child elements must be either buttons, anchor tags or gux-avatar-beta components' + ); + } + } + + private renderOverflowMenu(): JSX.Element | null { + if (this.root.children.length > this.quantity) { + const overflowItems = this.processedGroupItems.filter( + item => item.isOverflow + ); + + return ( + + { + overflowItems.map(item => { + return ( + item.groupItem.click()} + > + {item.img ? ( + {item.img?.alt} + ) : null} + + ); + }) as JSX.Element[] + } + + ) as JSX.Element; + } else { + return null; + } } render(): JSX.Element { return ( + {this.renderOverflowMenu()} ) as JSX.Element; } diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group-item/gux-avatar-group-item.types.ts b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group.types.ts similarity index 50% rename from packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group-item/gux-avatar-group-item.types.ts rename to packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group.types.ts index 9e6640f4bf..5e0dde8c6f 100644 --- a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group-item/gux-avatar-group-item.types.ts +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-group.types.ts @@ -15,3 +15,9 @@ export type GuxAvatarAccent = | '10' | '11' | '12'; + +export type GuxAvatarGroupQuantity = 1 | 2 | 3 | 4 | 5 | 6 | 7; + +export type GuxAvatarGroupChild = + | HTMLGuxAvatarGroupItemBetaElement + | HTMLGuxAvatarOverflowBetaElement; diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/gux-avatar-overflow-item.scss b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/gux-avatar-overflow-item.scss new file mode 100644 index 0000000000..24e6fcdcc7 --- /dev/null +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/gux-avatar-overflow-item.scss @@ -0,0 +1,139 @@ +@use '~genesys-spark/dist/scss/focus.scss'; + +::slotted(img) { + inline-size: 100%; + block-size: 100%; + object-fit: cover; +} + +button { + all: unset; + box-sizing: border-box; + display: flex; + gap: var(--gse-ui-menu-option-gap); + align-items: center; + inline-size: 100%; + min-block-size: var(--gse-ui-menu-option-height); + padding: var(--gse-ui-menu-option-padding); + font-family: var(--gse-ui-menu-option-label-default-text-fontFamily); + font-size: var(--gse-ui-menu-option-label-default-text-fontSize); + font-weight: var(--gse-ui-menu-option-label-default-text-fontWeight); + line-height: var(--gse-ui-menu-option-label-default-text-lineHeight); + color: var(--gse-ui-menu-option-label-foregroundColor); + word-wrap: break-word; + cursor: pointer; + background-color: var(--gse-ui-menu-option-default-backgroundColor); + border: none; + outline: none; + outline-offset: calc(var(--gse-ui-menu-option-focus-border-width) * -1); + + &:focus-visible { + border-radius: var(--gse-semantic-focusOutline-sm-borderRadius); + outline: var(--gse-ui-menu-option-focus-border-width) + var(--gse-ui-menu-option-focus-border-style) + var(--gse-ui-menu-option-focus-border-color); + } + + &:hover { + background: var(--gse-ui-menu-option-hover-backgroundColor); + } + + &:active { + font-family: var(--gse-ui-menu-option-label-active-text-fontFamily); + font-size: var(--gse-ui-menu-option-label-active-text-fontSize); + font-weight: var(--gse-ui-menu-option-label-active-text-fontWeight); + line-height: var(--gse-ui-menu-option-label-active-text-lineHeight); + background: var(--gse-ui-menu-option-selected-backgroundColor); + } + + .gux-avatar { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + inline-size: var(--gse-ui-avatar-xsmall-content-size); + block-size: var(--gse-ui-avatar-xsmall-content-size); + padding: 0; + margin: 0; + overflow: hidden; + color: var(--gse-ui-avatar-media-initialsForeground-inverse); + cursor: pointer; + background: none; + background-color: var(--gse-ui-avatar-media-initialsBackground-default); + border: none; + border-radius: 50%; + + .gux-avatar-initials { + font-family: var(--gse-ui-avatar-small-initials-fontFamily); + font-size: var(--gse-ui-avatar-xsmall-initials-fontSize); + font-weight: var(--gse-ui-avatar-xsmall-initials-fontWeight); + line-height: var(--gse-ui-avatar-small-initials-lineHeight); + text-transform: uppercase; + } + + &.gux-accent-1 { + color: var(--gse-ui-avatar-media-initialsForeground-inverse); + background-color: var(--gse-ui-avatar-media-initialsBackground-accent1); + } + + &.gux-accent-2 { + color: var(--gse-ui-avatar-media-initialsForeground-default); + background-color: var(--gse-ui-avatar-media-initialsBackground-accent2); + } + + &.gux-accent-3 { + color: var(--gse-ui-avatar-media-initialsForeground-inverse); + background-color: var(--gse-ui-avatar-media-initialsBackground-accent3); + } + + &.gux-accent-4 { + color: var(--gse-ui-avatar-media-initialsForeground-inverse); + background-color: var(--gse-ui-avatar-media-initialsBackground-accent4); + } + + &.gux-accent-5 { + color: var(--gse-ui-avatar-media-initialsForeground-default); + background-color: var(--gse-ui-avatar-media-initialsBackground-accent5); + } + + &.gux-accent-6 { + color: var(--gse-ui-avatar-media-initialsForeground-default); + background-color: var(--gse-ui-avatar-media-initialsBackground-accent6); + } + + &.gux-accent-7 { + color: var(--gse-ui-avatar-media-initialsForeground-inverse); + background-color: var(--gse-ui-avatar-media-initialsBackground-accent7); + } + + &.gux-accent-8 { + color: var(--gse-ui-avatar-media-initialsForeground-default); + background-color: var(--gse-ui-avatar-media-initialsBackground-accent8); + } + + &.gux-accent-9 { + color: var(--gse-ui-avatar-media-initialsForeground-default); + background-color: var(--gse-ui-avatar-media-initialsBackground-accent9); + } + + &.gux-accent-10 { + color: var(--gse-ui-avatar-media-initialsForeground-default); + background-color: var(--gse-ui-avatar-media-initialsBackground-accent10); + } + + &.gux-accent-11 { + color: var(--gse-ui-avatar-media-initialsForeground-default); + background-color: var(--gse-ui-avatar-media-initialsBackground-accent11); + } + + &.gux-accent-12 { + color: var(--gse-ui-avatar-media-initialsForeground-inverse); + background-color: var(--gse-ui-avatar-media-initialsBackground-accent12); + } + + &.gux-accent-inherit { + color: inherit; + background-color: inherit; + } + } +} diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/gux-avatar-overflow-item.tsx b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/gux-avatar-overflow-item.tsx new file mode 100644 index 0000000000..0496840717 --- /dev/null +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/gux-avatar-overflow-item.tsx @@ -0,0 +1,111 @@ +import { + Component, + h, + JSX, + Prop, + Element, + Method, + Listen, + Host +} from '@stencil/core'; +import { trackComponent } from '@utils/tracking/usage'; +import { logWarn } from '@utils/error/log-error'; +import { GuxAvatarAccent } from '../../gux-avatar-group.types'; +import { generateInitials } from '@utils/string/generate-initials'; +import { overflowNavigation } from '../gux-avatar-overflow.service'; + +/** + * @slot image - Avatar photo. + */ + +@Component({ + styleUrl: 'gux-avatar-overflow-item.scss', + tag: 'gux-avatar-overflow-item-beta', + shadow: true +}) +export class GuxAvatarOverflowItem { + private buttonElement: HTMLButtonElement; + + @Element() + root: HTMLElement; + + @Prop() + name: string; + + /** + * Manually sets avatar accent + */ + @Prop() + accent: GuxAvatarAccent = 'auto'; + + async componentWillLoad(): Promise { + trackComponent(this.root); + } + + componentDidLoad() { + this.validatingInputs(); + } + + @Listen('keydown') + onKeydown(event: KeyboardEvent): void { + overflowNavigation(event, this.root); + } + + /* + * Focus button element + */ + @Method() + // eslint-disable-next-line @typescript-eslint/require-await + async guxFocus(): Promise { + this.buttonElement.focus(); + } + + private getAccent(): string { + if (this.accent !== 'auto') { + return `gux-accent-${this.accent}`; + } + const hashedName = this.name + ?.split('') + .reduce((acc, char) => acc + char.charCodeAt(0), 0); + const hashedNameAccent = (hashedName % 12).toString(); + return `gux-accent-${hashedNameAccent}`; + } + + private validatingInputs(): void { + const avatarImage = this.root.querySelector('img'); + if (!this.name) { + logWarn(this.root, 'Name prop is required'); + } + + if (avatarImage && !avatarImage.getAttribute('alt')) { + logWarn(this.root, 'Alt attribute is required for slotted image.'); + } + } + + render(): JSX.Element { + return ( + + + + ) as JSX.Element; + } +} diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/readme.md b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/readme.md new file mode 100644 index 0000000000..1f5dfd14e1 --- /dev/null +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/readme.md @@ -0,0 +1,51 @@ +# gux-avatar-overflow-item-beta + + + + + + +## Properties + +| Property | Attribute | Description | Type | Default | +| -------- | --------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `accent` | `accent` | Manually sets avatar accent | `"0" \| "1" \| "10" \| "11" \| "12" \| "2" \| "3" \| "4" \| "5" \| "6" \| "7" \| "8" \| "9" \| "auto" \| "default" \| "inherit"` | `'auto'` | +| `name` | `name` | | `string` | `undefined` | + + +## Methods + +### `guxFocus() => Promise` + + + +#### Returns + +Type: `Promise` + + + + +## Slots + +| Slot | Description | +| --------- | ------------- | +| `"image"` | Avatar photo. | + + +## Dependencies + +### Used by + + - [gux-avatar-group-beta](../..) + +### Graph +```mermaid +graph TD; + gux-avatar-group-beta --> gux-avatar-overflow-item-beta + style gux-avatar-overflow-item-beta fill:#f9f,stroke:#333,stroke-width:4px +``` + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/tests/__snapshots__/gux-avatar-overflow-item.e2e.ts.snap b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/tests/__snapshots__/gux-avatar-overflow-item.e2e.ts.snap new file mode 100644 index 0000000000..d60fd153ab --- /dev/null +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/tests/__snapshots__/gux-avatar-overflow-item.e2e.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`gux-avatar-overflow-item-beta #render renders with name 1`] = `""`; diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/tests/__snapshots__/gux-avatar-overflow-item.spec.ts.snap b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/tests/__snapshots__/gux-avatar-overflow-item.spec.ts.snap new file mode 100644 index 0000000000..396919189c --- /dev/null +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/tests/__snapshots__/gux-avatar-overflow-item.spec.ts.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`gux-avatar-overflow-item-beta Render should render with name 1`] = ` + + + +`; + +exports[`gux-avatar-overflow-item-beta Render should render with slotted image 1`] = ` + + + John Doe + +`; diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/tests/gux-avatar-overflow-item.e2e.ts b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/tests/gux-avatar-overflow-item.e2e.ts new file mode 100644 index 0000000000..6dad4ccf41 --- /dev/null +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/tests/gux-avatar-overflow-item.e2e.ts @@ -0,0 +1,95 @@ +import { E2EPage } from '@stencil/core/testing'; +import { + newSparkE2EPage, + a11yCheck +} from '../../../../../../test/e2eTestUtils'; + +describe('gux-avatar-overflow-item-beta', () => { + describe('#render', () => { + it('renders with name', async () => { + const html = ` +
+ + +
+ `; + const page = await newSparkE2EPage({ html }); + const element = await page.find('gux-avatar-overflow-item-beta'); + + await a11yCheck(page); + expect(element.outerHTML).toMatchSnapshot(); + }); + }); + + describe('#interactions', () => { + it('should focus on button when guxFocus method is called', async () => { + const html = ` +
+ + + +
+ `; + const page = await newSparkE2EPage({ html }); + const element = await page.find('gux-avatar-overflow-item-beta'); + + expect(document.activeElement).toBeFalsy(); + + await element.callMethod('guxFocus'); + await page.waitForChanges(); + + expect(await page.evaluate(() => document.activeElement.tagName)).toBe( + 'GUX-AVATAR-OVERFLOW-ITEM-BETA' + ); + }); + + it('should handle keyboard navigation', async () => { + const getActiveElementLabel = async (page: E2EPage): Promise => { + return page.evaluate(() => { + const activeElement = + document.activeElement?.shadowRoot?.querySelector( + 'button' + ) as HTMLElement; + return activeElement.getAttribute('aria-label'); + }); + }; + + const html = ` +
+ + + + +
+ `; + const page = await newSparkE2EPage({ html }); + const firstButton = await page.find('pierce/button'); + + await firstButton.focus(); + await page.keyboard.press('ArrowDown'); + await page.waitForChanges(); + + expect(await getActiveElementLabel(page)).toBe('Jane Smith'); + + await page.keyboard.press('ArrowDown'); + await page.waitForChanges(); + + expect(await getActiveElementLabel(page)).toBe('John Doe'); + + await page.keyboard.press('ArrowUp'); + await page.waitForChanges(); + + expect(await getActiveElementLabel(page)).toBe('Jane Smith'); + + await page.keyboard.press('Home'); + await page.waitForChanges(); + + expect(await getActiveElementLabel(page)).toBe('John Doe'); + + await page.keyboard.press('End'); + await page.waitForChanges(); + + expect(await getActiveElementLabel(page)).toBe('Jane Smith'); + }); + }); +}); diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/tests/gux-avatar-overflow-item.spec.ts b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/tests/gux-avatar-overflow-item.spec.ts new file mode 100644 index 0000000000..05f18931a8 --- /dev/null +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow-item/tests/gux-avatar-overflow-item.spec.ts @@ -0,0 +1,118 @@ +import { newSpecPage } from '@stencil/core/testing'; +import { GuxAvatarOverflowItem } from '../gux-avatar-overflow-item'; + +const components = [GuxAvatarOverflowItem]; +describe('gux-avatar-overflow-item-beta', () => { + let component: GuxAvatarOverflowItem; + + beforeEach(async () => { + const page = await newSpecPage({ + components, + html: `` + }); + component = page.rootInstance; + }); + + it('should build', () => { + expect(component).toBeTruthy(); + }); + + describe('validatingInputs', () => { + it('should log warning when name is not provided', async () => { + const logWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + await newSpecPage({ + components, + html: `` + }); + + expect(logWarnSpy).toHaveBeenCalled(); + logWarnSpy.mockRestore(); + }); + + it('should log warning when image has no alt attribute', async () => { + const logWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + await newSpecPage({ + components, + html: ` + + + + ` + }); + + expect(logWarnSpy).toHaveBeenCalled(); + logWarnSpy.mockRestore(); + }); + }); + + describe('guxFocus', () => { + it('should focus the button element', async () => { + const page = await newSpecPage({ + components, + html: `` + }); + const instance = page.rootInstance; + const focusSpy = jest.spyOn(instance.buttonElement, 'focus'); + + await instance.guxFocus(); + expect(focusSpy).toHaveBeenCalled(); + }); + }); + + describe('Render', () => { + it('should render with name', async () => { + const page = await newSpecPage({ + components, + html: `` + }); + + expect(page.root).toMatchSnapshot(); + }); + + it('should render with slotted image', async () => { + const page = await newSpecPage({ + components, + html: ` + + John Doe + + ` + }); + + expect(page.root).toMatchSnapshot(); + }); + + it('should render with initials when no image is provided', async () => { + const page = await newSpecPage({ + components, + html: `` + }); + + const initialsElement = page.root.shadowRoot.querySelector( + '.gux-avatar-initials' + ); + expect(initialsElement.textContent).toBe('JD'); + }); + }); + + describe('Events', () => { + it('should handle keydown event', async () => { + const page = await newSpecPage({ + components, + html: `` + }); + const instance = page.rootInstance; + const event = new KeyboardEvent('keydown', { key: 'ArrowDown' }); + + // Mock overflowNavigation function + const overflowNavigationSpy = jest.fn(); + instance.onKeydown(event); + + expect(overflowNavigationSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow.scss b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow.scss new file mode 100644 index 0000000000..330afa9c63 --- /dev/null +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow.scss @@ -0,0 +1,76 @@ +@use '~genesys-spark/dist/scss/focus.scss'; +@use '~genesys-spark/dist/scss/mixins.scss'; + +:host { + display: block; + margin-inline-end: var(--gse-ui-avatar-groupSet-gap); +} + +.gux-avatar-overflow { + position: relative; + box-sizing: border-box; + inline-size: 100%; + padding: 0; + margin: 0; + line-height: 0px; + cursor: pointer; + background: none; + border: none; + border-radius: 50%; + + &:focus-visible { + @include focus.gux-focus-ring; + } + + &:hover, + &:focus { + z-index: var(--gse-semantic-zIndex-showFocus); + } + + .gux-avatar-overflow-wrapper { + position: relative; + display: flex; + + .gux-avatar-overflow-content { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + inline-size: var(--gse-ui-avatar-small-content-size); + block-size: var(--gse-ui-avatar-small-content-size); + overflow: hidden; + font-family: var(--gse-ui-avatar-small-initials-fontFamily); + font-size: var(--gse-ui-avatar-small-initials-fontSize); + font-weight: var(--gse-ui-avatar-small-initials-fontWeight); + line-height: var(--gse-core-lineHeight-matchFontSize); + color: var(--gse-ui-avatar-media-initialsForeground-default); + background-color: var( + --gse-ui-avatar-media-initialsBackground-overflowCount + ); + border-radius: 50%; + } + } +} + +.gux-menu-wrapper { + position: fixed; + inset-block-start: 0; + inset-inline-start: 0; + z-index: var(--gse-semantic-zIndex-popup); + flex-direction: column; + block-size: 100px; // TODO: Replace with token COMUI-3374 + padding: var(--gse-ui-menu-padding); + margin: 0; + overflow-y: scroll; + visibility: hidden; + background-color: var(--gse-ui-menu-backgroundColor); + border: none; + // TODO: Replace with tokens COMUI-3374 + border: 1px solid #c6c8ce; + border-radius: var(--gse-ui-menu-borderRadius); + box-shadow: 0 2px 4px 1px rgb(35 57 92 / 10%); + + &.gux-shown { + visibility: visible; + } +} diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow.service.ts b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow.service.ts new file mode 100644 index 0000000000..59c2bc7a6f --- /dev/null +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow.service.ts @@ -0,0 +1,110 @@ +export const hideDelay = 250; + +export function overflowNavigation( + event: KeyboardEvent, + currentElement: Element +): void { + switch (event.key) { + case 'ArrowUp': + event.stopPropagation(); + event.preventDefault(); + + focusPreviousSiblingLoop(currentElement); + break; + + case 'ArrowDown': + event.stopPropagation(); + event.preventDefault(); + + focusNextSiblingLoop(currentElement); + break; + + case 'ArrowRight': + event.preventDefault(); + event.stopPropagation(); + + break; + + case 'ArrowLeft': + event.preventDefault(); + event.stopPropagation(); + + break; + + case 'Home': + event.stopPropagation(); + event.preventDefault(); + + focusFirstSibling(currentElement); + break; + + case 'End': + event.stopPropagation(); + event.preventDefault(); + + focusLastSibling(currentElement); + break; + } +} + +function focusFirstSibling(currentElement: Element): void { + const firstFocusableElement = getFirstFocusableElement( + currentElement + ) as HTMLGuxAvatarOverflowItemBetaElement; + + if (firstFocusableElement) { + void firstFocusableElement.guxFocus(); + } +} + +function focusLastSibling(currentElement: Element): void { + const lastFocusableElement = getLastFocusableElement( + currentElement + ) as HTMLGuxAvatarOverflowItemBetaElement; + + if (lastFocusableElement) { + void lastFocusableElement.guxFocus(); + } +} + +function focusPreviousSiblingLoop(currentElement: Element): void { + const previousFocusableElement = + currentElement.previousElementSibling as HTMLGuxAvatarOverflowItemBetaElement; + + if (previousFocusableElement) { + void previousFocusableElement.guxFocus(); + } else { + focusLastSibling(currentElement); + } +} + +function focusNextSiblingLoop(currentElement: Element): void { + const nextFocusableElement = + currentElement.nextElementSibling as HTMLGuxAvatarOverflowItemBetaElement; + + if (nextFocusableElement) { + void nextFocusableElement.guxFocus(); + } else { + focusFirstSibling(currentElement); + } +} + +function getFirstFocusableElement(currentElement: Element): Element { + let firstFocusableElement = currentElement; + + while (firstFocusableElement.previousElementSibling !== null) { + firstFocusableElement = firstFocusableElement.previousElementSibling; + } + + return firstFocusableElement; +} + +function getLastFocusableElement(currentElement: Element): Element { + let lastFocusableElement = currentElement; + + while (lastFocusableElement.nextElementSibling !== null) { + lastFocusableElement = lastFocusableElement.nextElementSibling; + } + + return lastFocusableElement; +} diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow.tsx b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow.tsx new file mode 100644 index 0000000000..f5b8db2d4b --- /dev/null +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/gux-avatar-overflow.tsx @@ -0,0 +1,236 @@ +import { + Component, + h, + JSX, + Element, + State, + Method, + Host, + Listen +} from '@stencil/core'; + +import { autoUpdate, computePosition, offset } from '@floating-ui/dom'; + +import { OnClickOutside } from '@utils/decorator/on-click-outside'; +import { trackComponent } from '@utils/tracking/usage'; +import { logWarn } from '@utils/error/log-error'; +import { groupKeyboardNavigation } from '../gux-avatar-group.service'; +import { afterNextRenderTimeout } from '@utils/dom/after-next-render'; + +/** + * @slot - a number of gux-avatar-overflow-items + */ +@Component({ + styleUrl: 'gux-avatar-overflow.scss', + tag: 'gux-avatar-overflow-beta', + shadow: true +}) +export class GuxAvatarOverflow { + private overflowButtonElement: HTMLButtonElement; + private menuElement: HTMLDivElement; + private cleanupUpdatePosition: ReturnType; + private hideDelayTimeout: ReturnType; + private focusDelayTimeout: ReturnType; + private delayTime: number = 250; + + @Element() root: HTMLElement; + + @State() count: number = 0; + + @State() expanded: boolean = false; + + async componentWillLoad(): Promise { + trackComponent(this.root); + } + + componentDidLoad(): void { + if (this.expanded) { + this.runUpdatePosition(); + } + } + + componentDidUpdate(): void { + if (this.expanded) { + this.runUpdatePosition(); + } else if (this.cleanupUpdatePosition) { + this.cleanupUpdatePosition(); + } + } + + disconnectedCallback(): void { + clearTimeout(this.focusDelayTimeout); + clearTimeout(this.hideDelayTimeout); + if (this.cleanupUpdatePosition) { + this.cleanupUpdatePosition(); + } + } + + @OnClickOutside({ triggerEvents: 'mousedown' }) + onClickOutside(): void { + this.expanded = false; + } + + @Listen('keydown') + onKeydown(event: KeyboardEvent): void { + groupKeyboardNavigation(event, this.root); + + switch (event.key) { + case 'Escape': { + this.hide(); + + const target = event.target as Element; + if (target.tagName === 'GUX-AVATAR-OVERFLOW-ITEM-BETA') { + this.focusDelayTimeout = afterNextRenderTimeout(() => { + this.guxFocus(); + }); + } + break; + } + case 'Tab': + this.hide(); + break; + } + } + + @Listen('click') + onClick(e: MouseEvent): void { + e.stopPropagation(); + + const target = e.target as HTMLElement; + if (target.tagName === 'GUX-AVATAR-OVERFLOW-ITEM-BETA') { + // Reset scroll on menu when clicked to avoid scroll jump when reopened + this.menuElement.scrollTop = 0; + this.expanded = false; + } + } + + @Method() + // eslint-disable-next-line @typescript-eslint/require-await + async guxFocus(): Promise { + this.overflowButtonElement?.focus(); + } + + @Method() + // eslint-disable-next-line @typescript-eslint/require-await + async guxClose(): Promise { + this.hide(); + } + + private runUpdatePosition(): void { + if (this.root.isConnected) { + this.cleanupUpdatePosition = autoUpdate( + this.overflowButtonElement, + this.menuElement, + () => this.updatePosition(), + { + ancestorScroll: true, + elementResize: true, + animationFrame: true, + ancestorResize: true + } + ); + } else { + this.disconnectedCallback(); + } + } + + private updatePosition(): void { + if (this.root) { + void computePosition(this.overflowButtonElement, this.menuElement, { + placement: 'bottom-start', + strategy: 'fixed', + middleware: [ + offset({ + mainAxis: 4, + crossAxis: 4 + }) + ] + }).then(({ x, y }) => { + Object.assign(this.menuElement.style, { + left: `${x}px`, + top: `${y}px` + }); + }); + } + } + + private getCount() { + const menuItems = Array.from( + this.root.children + ) as HTMLGuxAvatarOverflowItemBetaElement[]; + if ( + menuItems.some(item => item.tagName !== 'GUX-AVATAR-OVERFLOW-ITEM-BETA') + ) { + logWarn( + this.root, + 'Only gux-avatar-overflow-item-beta elements are allowed as children.' + ); + } + if (menuItems) { + return menuItems.length; + } + } + + private toggleOverflowMenu(): void { + if (!this.expanded) { + this.show(); + } else { + this.hide(); + } + } + + private show(): void { + clearTimeout(this.hideDelayTimeout); + this.expanded = true; + this.hideDelayTimeout = afterNextRenderTimeout(() => { + this.focusOnMenu(); + }); + } + + private hide(): void { + if (this.expanded) { + this.hideDelayTimeout = setTimeout(() => { + this.expanded = false; + }, this.delayTime); + } + } + + private focusOnMenu(): void { + const overflowItems = Array.from(this.root.children); + + const nextFocusableElement = + overflowItems[0] as HTMLGuxAvatarOverflowItemBetaElement; + + void nextFocusableElement.guxFocus(); + } + + render(): JSX.Element { + return ( + + +
(this.menuElement = el)} + > + +
+
+ ) as JSX.Element; + } +} diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/readme.md b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/readme.md new file mode 100644 index 0000000000..897646a57d --- /dev/null +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/readme.md @@ -0,0 +1,53 @@ +# gux-avatar-overflow-beta + + + + + + +## Methods + +### `guxClose() => Promise` + + + +#### Returns + +Type: `Promise` + + + +### `guxFocus() => Promise` + + + +#### Returns + +Type: `Promise` + + + + +## Slots + +| Slot | Description | +| ---- | ------------------------------------- | +| | a number of gux-avatar-overflow-items | + + +## Dependencies + +### Used by + + - [gux-avatar-group-beta](..) + +### Graph +```mermaid +graph TD; + gux-avatar-group-beta --> gux-avatar-overflow-beta + style gux-avatar-overflow-beta fill:#f9f,stroke:#333,stroke-width:4px +``` + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/tests/__snapshots__/gux-avatar-overflow.e2e.ts.snap b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/tests/__snapshots__/gux-avatar-overflow.e2e.ts.snap new file mode 100644 index 0000000000..6330427e88 --- /dev/null +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/tests/__snapshots__/gux-avatar-overflow.e2e.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`gux-avatar-overflow #render should render component as expected 1`] = `" "`; diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/tests/__snapshots__/gux-avatar-overflow.spec.ts.snap b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/tests/__snapshots__/gux-avatar-overflow.spec.ts.snap new file mode 100644 index 0000000000..4a25154cda --- /dev/null +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/tests/__snapshots__/gux-avatar-overflow.spec.ts.snap @@ -0,0 +1,231 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`gux-avatar-overflow-beta #render should render 0 avatars 1`] = ` + + + +`; + +exports[`gux-avatar-overflow-beta #render should render 1 avatar 1`] = ` + + + + + John Smith + + +`; + +exports[`gux-avatar-overflow-beta #render should render 2 avatars 1`] = ` + + + + + John Smith + + + + Jane Smith + + +`; + +exports[`gux-avatar-overflow-beta #render should render 3 avatars 1`] = ` + + + + + John Smith + + + + Jane Smith + + + + John Doe + + +`; + +exports[`gux-avatar-overflow-beta should build 1`] = ` + + + + + John Smith + + + + Jane Smith + + + + John Doe + + +`; diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/tests/gux-avatar-overflow.e2e.ts b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/tests/gux-avatar-overflow.e2e.ts new file mode 100644 index 0000000000..05c3727938 --- /dev/null +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/tests/gux-avatar-overflow.e2e.ts @@ -0,0 +1,135 @@ +import { newSparkE2EPage, a11yCheck } from '../../../../../test/e2eTestUtils'; + +describe('gux-avatar-overflow', () => { + describe('#render', () => { + it('should render component as expected', async () => { + const html = ` + + + + + + + + + + + `; + const page = await newSparkE2EPage({ html }); + const element = await page.find('gux-avatar-overflow-beta'); + + await a11yCheck(page); + expect(element.outerHTML).toMatchSnapshot(); + }); + }); + + describe('interactions', () => { + it('should open and close menu on button click', async () => { + const html = ` + + + + + `; + const page = await newSparkE2EPage({ html }); + const button = await page.find('pierce/button'); + + expect(await button.getAttribute('aria-expanded')).toBe('false'); + + await button.click(); + await page.waitForChanges(); + await page.waitForTimeout(500); + expect(await button.getAttribute('aria-expanded')).toBe('true'); + + await button.click(); + await page.waitForTimeout(500); + await page.waitForChanges(); + expect(await button.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should focus on first item when menu opens', async () => { + const html = ` + + + + + + + + + `; + + const page = await newSparkE2EPage({ html }); + const button = await page.find('pierce/button'); + + const activeElementNameBefore = await page.evaluate(() => { + const activeElement = + document.activeElement as HTMLGuxAvatarOverflowItemBetaElement; + return activeElement.name; + }); + expect(activeElementNameBefore).not.toBe('John Smith'); + + await button.click(); + await page.waitForChanges(); + + const activeElementNameAfter = await page.evaluate(() => { + const activeElement = + document.activeElement as HTMLGuxAvatarOverflowItemBetaElement; + return activeElement.name; + }); + + expect(activeElementNameAfter).toBe('John Smith'); + }); + + it('should handle close on escape key', async () => { + const html = ` + + + + + + + `; + const page = await newSparkE2EPage({ html }); + const button = await page.find('pierce/button'); + + // Open menu + await button.click(); + await page.waitForTimeout(500); + await page.waitForChanges(); + expect(await button.getAttribute('aria-haspopup')).toBe('true'); + expect(await button.getAttribute('aria-expanded')).toBe('true'); + + // Press Escape to close menu + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + await page.waitForChanges(); + expect(await button.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should handle close on tab key', async () => { + const html = ` + + + + + + + `; + const page = await newSparkE2EPage({ html }); + const button = await page.find('pierce/button'); + + // Open menu + await button.click(); + await page.waitForTimeout(500); + await page.waitForChanges(); + expect(await button.getAttribute('aria-expanded')).toBe('true'); + + // Press Tab to close menu + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + await page.waitForChanges(); + expect(await button.getAttribute('aria-expanded')).toBe('false'); + }); + }); +}); diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/tests/gux-avatar-overflow.spec.ts b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/tests/gux-avatar-overflow.spec.ts new file mode 100644 index 0000000000..3e16b3da83 --- /dev/null +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/gux-avatar-overflow/tests/gux-avatar-overflow.spec.ts @@ -0,0 +1,299 @@ +jest.mock('../../../../../utils/error/log-error.ts', () => ({ + __esModule: true, + logWarn: jest.fn() +})); + +import { newSpecPage } from '@stencil/core/testing'; +import { GuxAvatarOverflow } from '../gux-avatar-overflow'; +import { GuxAvatarOverflowItem } from '../gux-avatar-overflow-item/gux-avatar-overflow-item'; + +const components = [GuxAvatarOverflow, GuxAvatarOverflowItem]; + +describe('gux-avatar-overflow-beta', () => { + beforeAll(() => { + global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; + }); + + afterAll(() => { + delete global.ResizeObserver; + jest.clearAllTimers(); + }); + + it('should build', async () => { + const html = ` + + + John Smith + + + Jane Smith + + + John Doe + + + `; + const page = await newSpecPage({ components, html, language: 'en' }); + + expect(page.rootInstance).toBeInstanceOf(GuxAvatarOverflow); + expect(page.root).toMatchSnapshot(); + }); + + describe('#render', () => { + [ + { + description: 'should render 3 avatars', + html: ` + + + John Smith + + + Jane Smith + + + John Doe + + + `, + expectedAvatarCount: 3 + }, + { + description: 'should render 2 avatars', + html: ` + + + John Smith + + + Jane Smith + + + `, + expectedAvatarCount: 2 + }, + { + description: 'should render 1 avatar', + html: ` + + + John Smith + + + `, + expectedAvatarCount: 1 + }, + { + description: 'should render 0 avatars', + html: ` + + + `, + expectedAvatarCount: 0 + } + ].forEach(({ description, html, expectedAvatarCount }) => { + it(description, async () => { + const page = await newSpecPage({ components, html, language: 'en' }); + + const avatarOverflowItems = page.root.querySelectorAll( + 'gux-avatar-overflow-item-beta' + ); + expect(avatarOverflowItems.length).toBe(expectedAvatarCount); + expect(page.root).toMatchSnapshot(); + }); + }); + }); + + describe('interactions', () => { + beforeEach(() => { + jest.useFakeTimers({ legacyFakeTimers: true }); + }); + + it('should toggle menu on button click', async () => { + const page = await newSpecPage({ + components, + html: ` + + Item 1 + + ` + }); + + const component = page.rootInstance; + const button = page.root.shadowRoot.querySelector('button'); + + expect(component.expanded).toBe(false); + + button.click(); + await page.waitForChanges(); + expect(component.expanded).toBe(true); + + button.click(); + jest.advanceTimersByTime(300); + await page.waitForChanges(); + expect(component.expanded).toBe(false); + }); + + it('should close menu on Escape key', async () => { + const page = await newSpecPage({ + components, + html: ` + + Item 1 + + ` + }); + + const component = page.rootInstance; + component.expanded = true; + await page.waitForChanges(); + + page.root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + jest.advanceTimersByTime(300); + await page.waitForChanges(); + expect(component.expanded).toBe(false); + }); + + it('should close menu on Tab key', async () => { + const page = await newSpecPage({ + components, + html: ` + + Item 1 + + ` + }); + + const component = page.rootInstance; + component.expanded = true; + await page.waitForChanges(); + + page.root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); + jest.advanceTimersByTime(300); + await page.waitForChanges(); + expect(component.expanded).toBe(false); + }); + + it('should close menu on Tab key', async () => { + const page = await newSpecPage({ + components, + html: ` + + Item 1 + + ` + }); + + const component = page.rootInstance; + component.expanded = true; + await page.waitForChanges(); + + page.root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); + jest.advanceTimersByTime(300); + await page.waitForChanges(); + expect(component.expanded).toBe(false); + }); + + it('should close menu on click of a slotted overflow-item', async () => { + const page = await newSpecPage({ + components, + html: ` + + Item 1 + + ` + }); + + const component = page.rootInstance; + component.expanded = true; + await page.waitForChanges(); + + const overflowItem = page.root.querySelector( + 'gux-avatar-overflow-item-beta' + ); + overflowItem.click(); + jest.advanceTimersByTime(300); + await page.waitForChanges(); + expect(component.expanded).toBe(false); + }); + + it('should navigate between menu items using keyboard', async () => { + const page = await newSpecPage({ + components, + html: ` + + Item 1 + Item 2 + + ` + }); + + const component = page.rootInstance; + component.expanded = true; + await page.waitForChanges(); + + const items = page.root.querySelectorAll('gux-avatar-overflow-item-beta'); + const firstItem = items[0] as HTMLGuxAvatarOverflowItemBetaElement; + const secondItem = items[1] as HTMLGuxAvatarOverflowItemBetaElement; + + const firstItemMockFocus = jest.fn(); + const secondItemMockFocus = jest.fn(); + + Object.defineProperty(firstItem, 'guxFocus', { + value: firstItemMockFocus, + writable: true + }); + + Object.defineProperty(secondItem, 'guxFocus', { + value: secondItemMockFocus, + writable: true + }); + + // Press down arrow key - should focus second item + firstItem.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }) + ); + await page.waitForChanges(); + expect(secondItemMockFocus).toHaveBeenCalled(); + + // Press up arrow key - should focus first item again + secondItem.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }) + ); + await page.waitForChanges(); + expect(firstItemMockFocus).toHaveBeenCalledTimes(1); + + // press up arrow on first item should focus last item + firstItem.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }) + ); + await page.waitForChanges(); + expect(secondItemMockFocus).toHaveBeenCalledTimes(2); + + // press down arrow on last item should focus first item + secondItem.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }) + ); + await page.waitForChanges(); + expect(firstItemMockFocus).toHaveBeenCalledTimes(2); + + // press end key on first item should focus last item + firstItem.dispatchEvent( + new KeyboardEvent('keydown', { key: 'End', bubbles: true }) + ); + await page.waitForChanges(); + expect(secondItemMockFocus).toHaveBeenCalledTimes(3); + + // press home key on last item should focus first item + secondItem.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Home', bubbles: true }) + ); + await page.waitForChanges(); + expect(firstItemMockFocus).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/readme.md b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/readme.md index 9c40ad6a20..e1c7764e4b 100644 --- a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/readme.md +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/readme.md @@ -5,6 +5,13 @@ +## Properties + +| Property | Attribute | Description | Type | Default | +| ---------- | ---------- | ----------- | --------------------------------- | ------- | +| `quantity` | `quantity` | | `1 \| 2 \| 3 \| 4 \| 5 \| 6 \| 7` | `7` | + + ## Slots | Slot | Description | @@ -12,6 +19,21 @@ | | slot for gux-avatar-group-item components | +## Dependencies + +### Depends on + +- [gux-avatar-overflow-beta](gux-avatar-overflow) +- [gux-avatar-overflow-item-beta](./gux-avatar-overflow/gux-avatar-overflow-item) + +### Graph +```mermaid +graph TD; + gux-avatar-group-beta --> gux-avatar-overflow-beta + gux-avatar-group-beta --> gux-avatar-overflow-item-beta + style gux-avatar-group-beta fill:#f9f,stroke:#333,stroke-width:4px +``` + ---------------------------------------------- *Built with [StencilJS](https://stenciljs.com/)* diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/tests/__snapshots__/gux-avatar-group.e2e.ts.snap b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/tests/__snapshots__/gux-avatar-group.e2e.ts.snap index d93e9ad934..6b7f170cbb 100644 --- a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/tests/__snapshots__/gux-avatar-group.e2e.ts.snap +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/tests/__snapshots__/gux-avatar-group.e2e.ts.snap @@ -1,3 +1,296 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`gux-avatar-group #render should render component as expected 1`] = `" "`; +exports[`gux-avatar-group #interaction should handles tabindex on navigation as expected with overflow 1`] = ` +Page { + "_e2eClose": [Function], + "_e2eElements": [ + + + + + +, + + +, + , + + +, + , + + + +, + , + ], + "_e2eEventIds": 0, + "_e2eEvents": Map {}, + "_e2eGoto": [Function], + "close": [Function], + "compareScreenshot": [Function], + "debugger": [Function], + "emitter": { + "all": Map { + "console" => [ + [Function], + ], + "pageerror" => [ + [Function], + ], + "requestfailed" => [ + [Function], + ], + "request" => [ + [Function], + ], + }, + "emit": [Function], + "off": [Function], + "on": [Function], + }, + "eventsMap": Map { + "console" => [ + [Function], + ], + "pageerror" => [ + [Function], + ], + "requestfailed" => [ + [Function], + ], + "request" => [ + [Function], + ], + }, + "find": [Function], + "findAll": [Function], + "getDiagnostics": [Function], + "goto": [Function], + "setContent": [Function], + "spyOnEvent": [Function], + "waitForChanges": [Function], + "waitForEvent": [Function], +} +`; + +exports[`gux-avatar-group #interaction should handles tabindex on navigation as expected without overflow 1`] = ` +Page { + "_e2eClose": [Function], + "_e2eElements": [ + + + + + +, + + +, + , + + +, + , + + +, + , + ], + "_e2eEventIds": 0, + "_e2eEvents": Map {}, + "_e2eGoto": [Function], + "close": [Function], + "compareScreenshot": [Function], + "debugger": [Function], + "emitter": { + "all": Map { + "console" => [ + [Function], + ], + "pageerror" => [ + [Function], + ], + "requestfailed" => [ + [Function], + ], + "request" => [ + [Function], + ], + }, + "emit": [Function], + "off": [Function], + "on": [Function], + }, + "eventsMap": Map { + "console" => [ + [Function], + ], + "pageerror" => [ + [Function], + ], + "requestfailed" => [ + [Function], + ], + "request" => [ + [Function], + ], + }, + "find": [Function], + "findAll": [Function], + "getDiagnostics": [Function], + "goto": [Function], + "setContent": [Function], + "spyOnEvent": [Function], + "waitForChanges": [Function], + "waitForEvent": [Function], +} +`; + +exports[`gux-avatar-group #render should render component as expected 1`] = `" "`; diff --git a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/tests/__snapshots__/gux-avatar-group.spec.ts.snap b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/tests/__snapshots__/gux-avatar-group.spec.ts.snap index ac12ac8fca..186cb62cff 100644 --- a/packages/genesys-spark-components/src/components/beta/gux-avatar-group/tests/__snapshots__/gux-avatar-group.spec.ts.snap +++ b/packages/genesys-spark-components/src/components/beta/gux-avatar-group/tests/__snapshots__/gux-avatar-group.spec.ts.snap @@ -4,10 +4,17 @@ exports[`gux-avatar-group render 1`] = ` - + - + - + - + - + - + - + - + - + - + - + - +