diff --git a/.changeset/sharp-chefs-relate.md b/.changeset/sharp-chefs-relate.md new file mode 100644 index 0000000000..773c515e72 --- /dev/null +++ b/.changeset/sharp-chefs-relate.md @@ -0,0 +1,5 @@ +--- +"@justeattakeaway/pie-list": minor +--- + +the initial list item changes to inmplement a basic list item diff --git a/apps/pie-storybook/.storybook/backgrounds.ts b/apps/pie-storybook/.storybook/backgrounds.ts index 14a2af469e..34cfcab03e 100644 --- a/apps/pie-storybook/.storybook/backgrounds.ts +++ b/apps/pie-storybook/.storybook/backgrounds.ts @@ -34,4 +34,4 @@ const CUSTOM_BACKGROUNDS : StoryBackgrounds = { ] }; -export default CUSTOM_BACKGROUNDS; \ No newline at end of file +export default CUSTOM_BACKGROUNDS; diff --git a/apps/pie-storybook/stories/pie-list.stories.ts b/apps/pie-storybook/stories/pie-list.stories.ts index 22863ab736..b3e1fcd539 100644 --- a/apps/pie-storybook/stories/pie-list.stories.ts +++ b/apps/pie-storybook/stories/pie-list.stories.ts @@ -1,34 +1,435 @@ import { html } from 'lit'; -import { type Meta } from '@storybook/web-components'; +import { type Meta, type StoryObj } from '@storybook/web-components'; +import { repeat } from 'lit/directives/repeat.js'; +import { action } from '@storybook/addon-actions'; import '@justeattakeaway/pie-list'; -import { type ListProps } from '@justeattakeaway/pie-list'; - -import { createStory } from '../utilities'; +import '@justeattakeaway/pie-list/dist/pie-list-item'; +import '@justeattakeaway/pie-link'; +import '@justeattakeaway/pie-radio'; +import '@justeattakeaway/pie-checkbox'; +import '@justeattakeaway/pie-icons-webc/dist/IconPlusCircle.js'; -type ListStoryMeta = Meta; +import { type ListProps } from '@justeattakeaway/pie-list'; -const defaultArgs: ListProps = {}; +// Default props +const defaultProps: ListProps = { + variant: 'default', + interactive: false, + dividers: false, + optimizeThreshold: 20, +}; -const listStoryMeta: ListStoryMeta = { +// Main story definition +const meta: Meta = { title: 'List', component: 'pie-list', - argTypes: {}, - args: defaultArgs, + tags: ['autodocs'], + argTypes: { + variant: { + description: 'Specifies the layout variant of the list.', + options: ['default', 'compact'], + control: { type: 'select' }, + }, + interactive: { + description: 'Whether the list has interactive items that respond to user interaction.', + control: { type: 'boolean' }, + }, + dividers: { + description: 'Whether to show dividers between list items.', + control: { type: 'boolean' }, + }, + optimizeThreshold: { + description: 'The threshold of items at which the component automatically switches to using the repeat directive for better performance.', + control: { type: 'number' }, + }, + }, + args: defaultProps, +}; + +export default meta; +type Story = StoryObj; + +//---------------------------------------------------------------------- +// 1. VARIANTS +//---------------------------------------------------------------------- + +export const DefaultListItem: Story = { + name: 'List Item - Default', parameters: { - design: { - type: 'figma', - url: '', + backgrounds: { + default: 'background-subtle', }, }, + render: () => html` +
+ + Primary text + +
+ `, }; -export default listStoryMeta; +export const CompactListItem: Story = { + name: 'List Item - Compact', + parameters: { + backgrounds: { + default: 'background-subtle', + }, + }, + render: () => html` +
+ + Primary text + +
+ `, +}; -// TODO: remove the eslint-disable rule when props are added -// eslint-disable-next-line no-empty-pattern -const Template = ({}: ListProps) => html` - -`; +export const SecondaryTextListItem: Story = { + name: 'List Item - Secondary Text', + parameters: { + backgrounds: { + default: 'background-subtle', + }, + }, + render: () => html` +
+ + Primary text + +
+ `, +}; + +export const MetaTextListItem: Story = { + name: 'List Item - Meta Text', + parameters: { + backgrounds: { + default: 'background-subtle', + }, + }, + render: () => html` +
+ + + meta text + + +
+ `, +}; + +export const IconLeadingListItem: Story = { + name: 'List Item - Icon Leading', + parameters: { + backgrounds: { + default: 'background-subtle', + }, + }, + render: () => html` +
+ + + + + +
+ `, +}; + +export const ThumbnailLeadingListItem: Story = { + name: 'List Item - Thumbnail Leading', + parameters: { + backgrounds: { + default: 'background-subtle', + }, + }, + render: () => html` +
+ + + placeholder + + +
+ `, +}; + +export const RadioButtonLeadingListItem: Story = { + name: 'List Item - Radio Button Leading', + parameters: { + backgrounds: { + default: 'background-subtle', + }, + }, + render: () => html` +
+ + + + + +
+ `, +}; + +export const CheckboxLeadingListItem: Story = { + name: 'List Item - Checkbox Leading', + parameters: { + backgrounds: { + default: 'background-subtle', + }, + }, + render: () => html` +
+ + + + + +
+ `, +}; + +export const IconLeadingListItemSecondaryText: Story = { + name: 'List Item - Icon Leading - Secondary Text', + parameters: { + backgrounds: { + default: 'background-subtle', + }, + }, + render: () => html` +
+ + + + + +
+ `, +}; -export const Default = createStory(Template, defaultArgs)(); +export const ThumbnailLeadingListItemSecondaryText: Story = { + name: 'List Item - Thumbnail Leading - Secondary Text', + parameters: { + backgrounds: { + default: 'background-subtle', + }, + }, + render: () => html` +
+ + + placeholder + + +
+ `, +}; + +//---------------------------------------------------------------------- +// 2. INTERACTIVITY (INTERACTIVE/NON-INTERACTIVE) +//---------------------------------------------------------------------- + +export const NonInteractiveList: Story = { + name: 'Non-Interactive List', + parameters: { + backgrounds: { + default: 'background-subtle', + }, + }, + render: () => html` +
+ + Primary text + Primary text + Primary text + +
+ `, +}; + +export const InteractiveList: Story = { + name: 'Interactive List', + parameters: { + backgrounds: { + default: 'background-subtle', + }, + }, + render: () => html` +
+ { + // The listItem is already provided in the event detail + action('list-item-click')({ + itemText: e.detail.item.textContent?.trim(), + event: e.detail.originalEvent, + }); + }}> + Primary text + Primary text + Primary text + +
+ `, +}; + +//---------------------------------------------------------------------- +// 3. DIVIDERS +//---------------------------------------------------------------------- + +export const WithoutDividers: Story = { + name: 'List Without Dividers', + parameters: { + backgrounds: { + default: 'background-subtle', + }, + }, + render: () => html` +
+ + Primary text + Primary text + Primary text + +
+ `, +}; + +export const WithDividers: Story = { + name: 'List With Dividers', + parameters: { + backgrounds: { + default: 'background-subtle', + }, + }, + render: () => html` +
+ + Primary text + Primary text + Primary text + +
+ `, +}; + +//---------------------------------------------------------------------- +// 4. COMBINATIONS +//---------------------------------------------------------------------- + +export const CompactWithDividers: Story = { + name: '(Compact) List With Dividers', + parameters: { + backgrounds: { + default: 'background-subtle', + }, + }, + render: () => html` +
+ + List Item 1 + List Item 2 + List Item 3 + +
+ `, +}; + +export const InteractiveCompact: Story = { + name: 'Interactive Compact', + parameters: { + backgrounds: { + default: 'background-subtle', + }, + }, + render: () => html` +
+

Interactive compact list

+ + List Item 1 + List Item 2 + List Item 3 + +
+ `, +}; + +export const InteractiveWithDividers: Story = { + render: () => html` +
+

Interactive list with dividers

+ + List Item 1 + List Item 2 + List Item 3 + +
+ `, +}; + +export const FullFeatured: Story = { + name: 'All Features (Interactive + Compact + Dividers)', + render: () => html` +
+

Compact interactive list with dividers

+ + List Item 1 + List Item 2 + List Item 3 + +
+ `, +}; + +export const LongTextContent: Story = { + render: () => html` +
+

List with long text content

+ + This is a list item with very long content that should wrap to the next line when it reaches the edge of its container. + Another list item with long content to verify consistent spacing and alignment with the PIE design system guidelines and principles. + A third list item with more reasonable length. + +
+ `, +}; + +export const EmptyList: Story = { + render: () => html` +
+

Empty list

+ +
+ `, +}; + +//---------------------------------------------------------------------- +// 6. PERFORMANCE EXAMPLE +//---------------------------------------------------------------------- + +export const ManyItems: Story = { + render: () => { + const items = Array.from({ length: 25 }, (_, i) => ({ + id: `item-${i}`, + text: `List Item ${i + 1}`, + })); + + return html` +
+

List with many items using repeat directive

+
+ + ${repeat( + items, + (item) => item.id, + (item) => html`${item.text}`, + )} + +
+
+ `; + }, +}; diff --git a/packages/components/pie-list/pie-list.test.stories.ts b/packages/components/pie-list/pie-list.test.stories.ts deleted file mode 100644 index 44c518c184..0000000000 --- a/packages/components/pie-list/pie-list.test.stories.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { html } from 'lit'; -import { type Meta } from '@storybook/web-components'; - -import '@justeattakeaway/pie-list'; -import { type ListProps } from '@justeattakeaway/pie-list'; - -import { createStory } from '../../utilities'; - -type ListStoryMeta = Meta; - -const defaultArgs: ListProps = {}; - -const listStoryMeta: ListStoryMeta = { - title: 'List', - component: 'pie-list', - argTypes: {}, - args: defaultArgs, - parameters: { - design: { - type: 'figma', - url: '', - }, - }, -}; - -export default listStoryMeta; - -// TODO: remove the eslint-disable rule when props are added -// eslint-disable-next-line no-empty-pattern -const Template = ({}: ListProps) => html` - -`; - -export const Default = createStory(Template, defaultArgs)(); diff --git a/packages/components/pie-list/src/defs.ts b/packages/components/pie-list/src/defs.ts index 3119266820..63a99fd9e9 100644 --- a/packages/components/pie-list/src/defs.ts +++ b/packages/components/pie-list/src/defs.ts @@ -1,3 +1,22 @@ -// TODO - please remove the eslint disable comment below when you add props to this interface -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface ListProps {} +export interface ListProps { + /** + * Specifies the list variant + */ + variant?: 'default' | 'compact'; + + /** + * Whether the list has interactive items + */ + interactive?: boolean; + + /** + * Whether to show dividers between list items + */ + dividers?: boolean; + + /** + * The threshold of items at which the component automatically switches to using + * the repeat directive for better performance. Default is 20. + */ + optimizeThreshold?: number; +} diff --git a/packages/components/pie-list/src/index.ts b/packages/components/pie-list/src/index.ts index df8f039051..57d0c27f87 100644 --- a/packages/components/pie-list/src/index.ts +++ b/packages/components/pie-list/src/index.ts @@ -1,9 +1,13 @@ -import { html, unsafeCSS } from 'lit'; +import { html, unsafeCSS, isServer } from 'lit'; import { PieElement } from '@justeattakeaway/pie-webc-core/src/internals/PieElement'; +import { property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; import { RtlMixin, defineCustomElement } from '@justeattakeaway/pie-webc-core'; import styles from './list.scss?inline'; import { type ListProps } from './defs'; +import { RovingTabindexMixin } from './mixins/roving-tabindex-mixin.ts'; // Valid values available to consumers export * from './defs'; @@ -12,16 +16,190 @@ const componentSelector = 'pie-list'; /** * @tagname pie-list + * + * PIE List component for displaying collections of related items. */ -export class PieList extends RtlMixin(PieElement) implements ListProps { +export class PieList extends RovingTabindexMixin(RtlMixin(PieElement)) implements ListProps { + @property({ type: String, reflect: true }) + public variant: 'default' | 'compact' = 'default'; + + @property({ type: Boolean, reflect: true }) + public interactive = false; + + @property({ type: Boolean, reflect: true }) + public dividers = false; + + @property({ type: Number }) + public optimizeThreshold = 20; + + // The label used by assistive technologies to identify this list + @property({ type: String }) + public ariaLabel: string | null = null; + + // ID of an element that labels this list + @property({ type: String }) + public ariaLabelledby: string | null = null; + + @state() + private _slottedItems: Element[] = []; + + @state() + private _useRepeat = false; + + @state() + private _eventDelegationSetup = false; + + // Unique ID for maintaining consistent accessibility references + private _listId = `pie-list-${Math.round(Math.random() * 1000000)}`; + + connectedCallback () { + super.connectedCallback(); + + this.setAttribute('role', 'none'); + + this.rovingTabindexEnabled = this.interactive; + this.rovingTabindexSelector = 'li, pie-list-item'; + + // Only set up event listeners in browser environments + if (!isServer) { + this._handleSlotChange = this._handleSlotChange.bind(this); + } + + // Set up event delegation once + if (!isServer && !this._eventDelegationSetup) { + this._setupEventDelegation(); + this._eventDelegationSetup = true; + } + } + + disconnectedCallback () { + super.disconnectedCallback(); + + // Skip cleanup in SSR environment + if (isServer) return; + + // Remove event listeners + const slot = this.shadowRoot?.querySelector('slot'); + if (slot) { + slot.removeEventListener('slotchange', this._handleSlotChange); + } + } + + firstUpdated () { + // Skip slot handling in SSR environment + if (isServer) return; + + const slot = this.shadowRoot?.querySelector('slot'); + if (slot) { + slot.addEventListener('slotchange', this._handleSlotChange); + // Initial slot content check + this._handleSlotChange(); + } + } + + updated (changedProperties: Map) { + super.updated(changedProperties); + + // Sync roving tabindex with interactive property + if (changedProperties.has('interactive')) { + this.rovingTabindexEnabled = this.interactive; + } + } + + private _handleSlotChange () { + // This method should never run in SSR, but add a guard just in case + if (isServer) return; + + const slot = this.shadowRoot?.querySelector('slot'); + if (!slot) return; + + const assignedNodes = slot.assignedNodes(); + const items: Element[] = []; + + // Filter for actual element nodes + assignedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + const item = node as Element; + + // For regular elements (not pie-list-item custom elements), set role="listitem" + if (item.tagName.toLowerCase() !== 'pie-list-item') { + item.setAttribute('role', 'listitem'); + } + + items.push(item); + } + }); + + // If we have more items than the threshold, use the repeat directive + this._useRepeat = items.length >= this.optimizeThreshold; + this._slottedItems = items; + + // Force a re-render to apply the repeat directive optimization + this.requestUpdate(); + } + + private _setupEventDelegation () { + // Handle clicks for all list items with one listener + this.addEventListener('click', (e: Event) => { + if (!this.interactive) return; + + const target = e.target as HTMLElement; + const listItem = target.closest('li, pie-list-item'); + + if (listItem && !listItem.hasAttribute('disabled')) { + // Dispatch a custom event with the clicked item + this.dispatchEvent(new CustomEvent('pie-list-item-click', { + bubbles: true, + composed: true, + detail: { + item: listItem, + originalEvent: e, + }, + })); + } + }); + } + render () { - return html`

Hello world!

`; + const classes = { + 'c-list': true, + 'c-list--compact': this.variant === 'compact', + 'c-list--interactive': this.interactive, + 'c-list--dividers': this.dividers, + 'c-list--rtl': this.isRTL, + }; + + // In SSR, always use the simple slot approach + // In browser, use repeat directive for many items, regular slot for fewer items + const content = isServer || !this._useRepeat || this._slottedItems.length === 0 + ? html`` + : repeat( + this._slottedItems, + (item, index) => item.getAttribute('key') || index, + (item) => item, + ); + + return html` +
    + ${content} +
+ `; } // Renders a `CSSResult` generated from SCSS by Vite static styles = unsafeCSS(styles); } +// Utility function for conditional attributes +const ifDefined = (value: string | null | undefined) => (value === null || value === undefined ? undefined : value); + defineCustomElement(componentSelector, PieList); declare global { diff --git a/packages/components/pie-list/src/list.scss b/packages/components/pie-list/src/list.scss index 6ffaedad64..4f2c449fc3 100644 --- a/packages/components/pie-list/src/list.scss +++ b/packages/components/pie-list/src/list.scss @@ -1 +1,39 @@ @use '@justeattakeaway/pie-css/scss' as p; + +.c-list { + display: block; + margin: 0; + padding: 0; + list-style: none; + width: 100%; + + // Default padding - will be inherited by child list items + --list-item-padding: var(--dt-spacing-d) var(--dt-spacing-d) var(--dt-spacing-d) var(--dt-spacing-d); + + // Remove default browser list padding/margin + &:where(ul, ol) { + margin: 0; + padding: 0; + } +} + +// Variant: Compact +.c-list--compact { + --list-item-padding: var(--dt-spacing-b) var(--dt-spacing-d) var(--dt-spacing-b) var(--dt-spacing-d); +} + +// Interactive items +.c-list--interactive ::slotted(*) { + cursor: pointer; +} + +// Dividers between items +.c-list--dividers ::slotted(*:not(:last-child)) { + border-bottom: 1px solid var(--dt-color-border-subtle); + --list-item-border-bottom: 1px solid var(--dt-color-border-subtle); +} + +// RTL support +.c-list--rtl { + direction: rtl; +} diff --git a/packages/components/pie-list/src/mixins/roving-tabindex-mixin.ts b/packages/components/pie-list/src/mixins/roving-tabindex-mixin.ts new file mode 100644 index 0000000000..6c97b2389f --- /dev/null +++ b/packages/components/pie-list/src/mixins/roving-tabindex-mixin.ts @@ -0,0 +1,268 @@ +import type { LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { type GenericConstructor } from '@justeattakeaway/pie-webc-core'; + +/** + * Interface defining the properties and methods for the RovingTabindex behavior + */ +export interface RovingTabindexInterface { + /** + * Whether the component has roving tabindex enabled + */ + rovingTabindexEnabled: boolean; + + /** + * CSS selector string used to identify focusable items + */ + rovingTabindexSelector: string; + + /** + * Initialize the roving tabindex pattern + */ + initializeRovingTabindex(): void; + + /** + * Get the currently focusable items + */ + getRovingTabindexItems(): Element[]; + + /** + * Update which item has tabindex="0" + */ + updateRovingTabindex(newIndex: number): void; +} + +/** + * Mixin that adds roving tabindex behavior to a component. + * This implements the accessibility pattern where only one item in a group is + * in the tab order (tabindex="0") and others can be reached via arrow keys. + * + * @param superClass - The class to extend with roving tabindex functionality + * @returns A class extending both the provided class and RovingTabindexInterface + */ +export const RovingTabindexMixin = + >(superClass: T) => { + class RovingTabindexElement extends superClass implements RovingTabindexInterface { + /** + * Whether roving tabindex is enabled + */ + @property({ type: Boolean, attribute: 'roving-tabindex-enabled' }) + public rovingTabindexEnabled = false; + + /** + * CSS selector to identify focusable items + * Can be any valid CSS selector string + */ + @property({ type: String, attribute: 'roving-tabindex-selector' }) + public rovingTabindexSelector = 'li, [role="listitem"]'; + + // Track the currently active index + private _rovingTabindexCurrentIndex = 0; + + // Bound method references to ensure proper 'this' context + private _boundKeyDownHandler: (e: KeyboardEvent) => void; + private _boundFocusInHandler: (e: FocusEvent) => void; + + constructor (...args: any[]) { + super(...args); + + // Bind methods to ensure 'this' context is preserved + this._boundKeyDownHandler = this._handleKeyDown.bind(this); + this._boundFocusInHandler = this._handleFocusIn.bind(this); + } + + connectedCallback () { + super.connectedCallback(); + + if (this.rovingTabindexEnabled) { + this._addEventListeners(); + } + } + + disconnectedCallback () { + super.disconnectedCallback(); + + if (this.rovingTabindexEnabled) { + this._removeEventListeners(); + } + } + + /** + * When properties change, update event listeners and tabindex state + */ + updated (changedProperties: Map) { + super.updated(changedProperties); + + if (changedProperties.has('rovingTabindexEnabled')) { + if (this.rovingTabindexEnabled) { + this._addEventListeners(); + this.initializeRovingTabindex(); + } else { + this._removeEventListeners(); + // Remove all tabindex attributes when disabled + this.getRovingTabindexItems().forEach((item) => { + item.removeAttribute('tabindex'); + }); + } + } + + if (changedProperties.has('rovingTabindexSelector') && + this.rovingTabindexEnabled) { + this.initializeRovingTabindex(); + } + } + + /** + * Get the currently active index + */ + public getRovingTabindexActiveIndex (): number { + return this._rovingTabindexCurrentIndex; + } + + /** + * Get all matching focusable items + */ + public getRovingTabindexItems (): Element[] { + return Array.from(this.querySelectorAll(this.rovingTabindexSelector)) + .filter((item) => !item.hasAttribute('disabled') && + !item.hasAttribute('aria-disabled')); + } + + /** + * Initialize the roving tabindex pattern + */ + public initializeRovingTabindex (): void { + if (!this.rovingTabindexEnabled) return; + + const items = this.getRovingTabindexItems(); + if (items.length === 0) return; + + // Make the item at currentIndex tabbable, or fallback to first item + const activeIndex = (this._rovingTabindexCurrentIndex < items.length) + ? this._rovingTabindexCurrentIndex : 0; + + items.forEach((item, index) => { + item.setAttribute('tabindex', index === activeIndex ? '0' : '-1'); + }); + + this._rovingTabindexCurrentIndex = activeIndex; + } + + /** + * Focus the item at the active index + */ + public focusActiveItem (): void { + if (!this.rovingTabindexEnabled) return; + + const items = this.getRovingTabindexItems(); + if (items.length === 0) return; + + if (this._rovingTabindexCurrentIndex >= 0 && + this._rovingTabindexCurrentIndex < items.length) { + (items[this._rovingTabindexCurrentIndex] as HTMLElement).focus(); + } + } + + /** + * Update which item has tabindex="0" + */ + public updateRovingTabindex (newIndex: number): void { + const items = this.getRovingTabindexItems(); + if (items.length === 0) return; + + // Validate index bounds + const validatedIndex = Math.max(0, Math.min(newIndex, items.length - 1)); + + items.forEach((item, index) => { + item.setAttribute('tabindex', index === validatedIndex ? '0' : '-1'); + }); + + this._rovingTabindexCurrentIndex = newIndex; + } + + /** + * Add event listeners for keyboard and focus navigation + */ + private _addEventListeners (): void { + this.addEventListener('keydown', this._boundKeyDownHandler); + this.addEventListener('focusin', this._boundFocusInHandler); + } + + /** + * Remove event listeners + */ + private _removeEventListeners (): void { + this.removeEventListener('keydown', this._boundKeyDownHandler); + this.removeEventListener('focusin', this._boundFocusInHandler); + } + + /** + * Handle keyboard navigation + */ + private _handleKeyDown (e: KeyboardEvent): void { + // Ignore if roving tabindex is disabled + if (!this.rovingTabindexEnabled) return; + + // Only process if a focusable item received the event + const target = e.target as HTMLElement; + const items = this.getRovingTabindexItems(); + let currentIndex = items.indexOf(target as Element); + + // If the target isn't directly one of our items, use our tracked index + if (currentIndex === -1) { + currentIndex = this._rovingTabindexCurrentIndex; + } + + let newIndex = currentIndex; + + switch (e.key) { + case 'ArrowDown': + case 'ArrowRight': + e.preventDefault(); + newIndex = Math.min(currentIndex + 1, items.length - 1); + break; + + case 'ArrowUp': + case 'ArrowLeft': + e.preventDefault(); + newIndex = Math.max(currentIndex - 1, 0); + break; + + case 'Home': + e.preventDefault(); + newIndex = 0; + break; + + case 'End': + e.preventDefault(); + newIndex = items.length - 1; + break; + + default: + return; // Don't handle other keys + } + + if (newIndex !== currentIndex) { + this.updateRovingTabindex(newIndex); + (items[newIndex] as HTMLElement).focus(); + } + } + + /** + * Handle focus changes to update tabindex values + */ + private _handleFocusIn (e: FocusEvent): void { + if (!this.rovingTabindexEnabled) return; + + const target = e.target as HTMLElement; + const items = this.getRovingTabindexItems(); + const newIndex = items.indexOf(target as Element); + + if (newIndex !== -1) { + this.updateRovingTabindex(newIndex); + } + } + } + + return RovingTabindexElement as GenericConstructor & T; + }; diff --git a/packages/components/pie-list/src/pie-list-item/index.ts b/packages/components/pie-list/src/pie-list-item/index.ts index d566dd5a66..907dd8440c 100644 --- a/packages/components/pie-list/src/pie-list-item/index.ts +++ b/packages/components/pie-list/src/pie-list-item/index.ts @@ -1,16 +1,519 @@ -import { LitElement } from 'lit'; -import { defineCustomElement } from '@justeattakeaway/pie-webc-core'; +import { html, unsafeCSS } from 'lit'; +import { PieElement } from '@justeattakeaway/pie-webc-core/src/internals/PieElement'; +import { property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { RtlMixin, defineCustomElement } from '@justeattakeaway/pie-webc-core'; + +import styles from './list-item.scss?inline'; import { type ListItemProps } from './defs'; +// Valid values available to consumers +export * from './defs'; + const componentSelector = 'pie-list-item'; /** * @tagname pie-list-item + * + * PIE List Item component for use within pie-list. */ -export class PieListItem extends LitElement implements ListItemProps { - // component logic +export class PieListItem extends RtlMixin(PieElement) implements ListItemProps { + /** + * Whether the item is selected + */ + @property({ type: Boolean, reflect: true }) + public selected = false; + + /** + * Whether the item is disabled + */ + @property({ type: Boolean, reflect: true }) + public disabled = false; + + /** + * Tab index for keyboard navigation + */ + @property({ type: Number, reflect: true }) + public tabindex = -1; + + /** + * Primary text content of the list item + */ + @property({ type: String }) + public primaryText = ''; + + /** + * Secondary text content displayed below the primary text + */ + @property({ type: String }) + public secondaryText = ''; + + /** + * Meta text content typically displayed in trailing position + */ + @property({ type: String }) + public metaText = ''; + + /** + * Type of content in the leading slot + * Affects styling and accessibility + */ + @property({ type: String, reflect: true }) + public leadingType: 'none' | 'icon' | 'avatar' | 'payment' | 'thumbnail' = 'none'; + + /** + * Type of content in the trailing slot + * Affects styling and accessibility + */ + @property({ type: String, reflect: true }) + public trailingType: 'none' | 'icon' | 'meta' | 'tag' | 'checkbox' | 'radio' | 'switch' = 'none'; + + /** + * Accessibility label + */ + @property({ type: String }) + public ariaLabel: string | null = null; + + /** + * ID of element that labels this item + */ + @property({ type: String }) + public ariaLabelledby: string | null = null; + + /** + * Value for form integration + */ + @property({ type: String }) + public value = ''; + + /** + * Tracks if parent list is in compact mode + */ + @state() + protected _compact = false; + + /** + * Tracks if parent list is interactive + */ + @state() + protected _interactive = false; + + /** + * Reference to the internal form control if present + */ + private _formControl: HTMLElement | null = null; + + /** + * MutationObserver for parent list + */ + private _parentObserver: MutationObserver | null = null; + + connectedCallback () { + super.connectedCallback(); + + this.setAttribute('role', 'listitem'); + + // Check for parent list properties + const parentList = this.closest('pie-list'); + + if (parentList) { + // Sync interactive state with parent + this._interactive = parentList.hasAttribute('interactive'); + // Sync compact state with parent + this._compact = parentList.getAttribute('variant') === 'compact'; + + // Watch for changes in the parent list's attributes + this._observeParentListChanges(parentList); + + // Ensure the item is focusable if in an interactive list + if (this._interactive) { + this.tabindex = 0; + } + } + } + + disconnectedCallback () { + super.disconnectedCallback(); + + // Clean up observer if it exists + if (this._parentObserver) { + this._parentObserver.disconnect(); + this._parentObserver = null; + } + } + + /** + * Called after first update and shadowRoot is initialized + */ + firstUpdated () { + // Set up mutation observer to detect slot changes + if (this.shadowRoot) { + this._setupSlotListeners(); + } + + // Find form controls in initial slots + this._findFormControl(); + } + + /** + * Set up an observer to watch for changes in the parent list's attributes + */ + private _observeParentListChanges (parentList: Element) { + // Create a MutationObserver to watch for attribute changes on the parent list + this._parentObserver = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes') { + const list = mutation.target as Element; + + // Update interactive state + if (mutation.attributeName === 'interactive') { + this._interactive = list.hasAttribute('interactive'); + this.tabindex = this._interactive && !this.disabled ? 0 : -1; + } + + // Update compact state + if (mutation.attributeName === 'variant') { + this._compact = list.getAttribute('variant') === 'compact'; + } + + // Request update to reflect changes + this.requestUpdate(); + } + }); + }); + + // Start observing the parent list element + this._parentObserver.observe(parentList, { attributes: true }); + } + + /** + * Handle click events + */ + private _handleClick (e: MouseEvent) { + if (this.disabled) { + e.preventDefault(); + return; + } + + // Find form control in slots if present + this._findFormControl(); + + // Handle form control clicks + if (this._formControl && this._interactive) { + const controlType = this._getControlType(); + + // Toggle checkbox state + if (controlType === 'checkbox' || controlType === 'switch') { + // Toggle the control + (this._formControl as HTMLInputElement).click(); + e.preventDefault(); // Prevent double click handling + } else if (controlType === 'radio') { + // Select the radio + (this._formControl as HTMLInputElement).click(); + e.preventDefault(); // Prevent double click handling + } + } + + // Dispatch custom event + if (this._interactive) { + this.dispatchEvent(new CustomEvent('pie-list-item-click', { + bubbles: true, + composed: true, + detail: { + value: this.value, + selected: this.selected, + originalEvent: e, + }, + })); + } + } + + /** + * Handle key events for accessibility + */ + private _handleKeyDown (e: KeyboardEvent) { + if (this.disabled || !this._interactive) { + return; + } + + // Handle Enter or Space to activate the item + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this._handleClick(e as unknown as MouseEvent); + } + } + + /** + * Set up slot change listeners + */ + private _setupSlotListeners () { + const slots = this.shadowRoot?.querySelectorAll('slot'); + slots?.forEach((slot) => { + slot.addEventListener('slotchange', () => { + this._handleSlotChange(slot as HTMLSlotElement); + }); + }); + } + + /** + * Handle changes to slot content + */ + private _handleSlotChange (slot: HTMLSlotElement) { + const slotName = slot.name || 'default'; + const elements = slot.assignedElements(); + + // Check for form controls in slots + if (slotName === 'leading' || slotName === 'trailing') { + this._findFormControl(); + } + + // Validate content types against props + this._validateSlotContent(slotName, elements); + } + + /** + * Find form controls in slots and set up event listeners + */ + private _findFormControl () { + // Look for form controls in slots + const leadingSlot = this.shadowRoot?.querySelector('slot[name="leading"]') as HTMLSlotElement | null; + const trailingSlot = this.shadowRoot?.querySelector('slot[name="trailing"]') as HTMLSlotElement | null; + + const leadingElements = leadingSlot?.assignedElements() || []; + const trailingElements = trailingSlot?.assignedElements() || []; + + // Check for form controls + const formControls = [ + ...leadingElements, + ...trailingElements + ].filter((el) => { + const tagName = el.tagName.toLowerCase(); + return tagName === 'input' || + tagName === 'pie-checkbox' || + tagName === 'pie-radio' || + tagName === 'pie-switch'; + }); + + if (formControls.length > 0) { + this._formControl = formControls[0] as HTMLElement; + this._syncFormControlState(); + } else { + this._formControl = null; + } + } + + /** + * Get the type of form control + */ + private _getControlType (): 'checkbox' | 'radio' | 'switch' | null { + if (!this._formControl) return null; + + const tagName = this._formControl.tagName.toLowerCase(); + + if (tagName === 'pie-checkbox' || + (tagName === 'input' && this._formControl.getAttribute('type') === 'checkbox')) { + return 'checkbox'; + } + + if (tagName === 'pie-radio' || + (tagName === 'input' && this._formControl.getAttribute('type') === 'radio')) { + return 'radio'; + } + + if (tagName === 'pie-switch') { + return 'switch'; + } + + return null; + } + + /** + * Synchronize state with form control + */ + private _syncFormControlState () { + if (!this._formControl) return; + + const controlType = this._getControlType(); + + if (controlType === 'checkbox' || controlType === 'radio' || controlType === 'switch') { + // Remove previous listeners if any + this._formControl.removeEventListener('change', this._handleFormControlChange); + + // Add new listener + this._formControl.addEventListener('change', this._handleFormControlChange); + + // Initial sync + this.selected = (this._formControl as HTMLInputElement).checked; + } + } + + /** + * Handle form control change events + */ + private _handleFormControlChange = () => { + if (!this._formControl) return; + const isChecked = (this._formControl as HTMLInputElement).checked; + this.selected = isChecked; + }; + + /** + * Validate slot content against declared types + */ + private _validateSlotContent (slotName: string, elements: Element[]) { + // Only perform validation in development mode + if (process.env.NODE_ENV !== 'development') return; + + if (slotName === 'leading' && this.leadingType !== 'none') { + this._validateContentType(elements, this.leadingType, 'leadingType', slotName); + } + + if (slotName === 'trailing' && this.trailingType !== 'none') { + this._validateContentType(elements, this.trailingType, 'trailingType', slotName); + } + } + + /** + * Validate content type matches the declared type + */ + private _validateContentType (elements: Element[], declaredType: string, propName: string, slotName: string) { + if (elements.length === 0) return; + + // Map of expected tag names for each type + const typeTagMap: Record = { + icon: ['pie-icon'], + avatar: ['pie-avatar'], + thumbnail: ['img'], + payment: ['pie-payment-icon'], + checkbox: ['pie-checkbox', 'input[type="checkbox"]'], + radio: ['pie-radio', 'input[type="radio"]'], + switch: ['pie-switch'], + }; + + const expectedTags = typeTagMap[declaredType] || []; + if (expectedTags.length === 0) return; + + // Check if any element matches expected tags + const hasMatchingElement = elements.some((el) => { + const tagName = el.tagName.toLowerCase(); + return expectedTags.some((expectedTag) => { + if (expectedTag.includes('[')) { + // Handle attribute selectors + const [tag, attr] = expectedTag.split('['); + const attrName = attr.replace(']', '').split('=')[0]; + const attrValue = attr.replace(']', '').split('=')[1]?.replace(/"/g, ''); + + return tagName === tag && + el.hasAttribute(attrName) && + (!attrValue || el.getAttribute(attrName) === attrValue); + } + return tagName === expectedTag; + }); + }); + + if (!hasMatchingElement) { + console.warn(`pie-list-item: Content in ${slotName} slot does not match the declared ${propName}="${declaredType}"`); + } + } + + /** + * Generate CSS classes based on component state + */ + private _generateClasses () { + const secondarySlot = this.shadowRoot?.querySelector('slot[name="secondary"]') as HTMLSlotElement | null; + const hasSecondary = this.secondaryText || + this.querySelector('[slot="secondary"]') !== null || + (secondarySlot?.assignedNodes().length ?? 0) > 0; + + return { + 'c-list-item': true, + 'c-list-item--selected': this.selected, + 'c-list-item--disabled': this.disabled, + 'c-list-item--compact': this._compact, + 'c-list-item--interactive': this._interactive, + 'c-list-item--with-secondary': !!hasSecondary, + [`c-list-item--leading-${this.leadingType}`]: this.leadingType !== 'none', + [`c-list-item--trailing-${this.trailingType}`]: this.trailingType !== 'none', + 'c-list-item--control': ['radio', 'checkbox', 'switch'].includes(this.trailingType), + // Removed explicit RTL class as it's handled by inheritance and logical properties + }; + } + + render () { + const classes = this._generateClasses(); + + // Determine what to use for an accessible name + const accessibleName = this.ariaLabel || undefined; + const labelledBy = this.ariaLabelledby || undefined; + + // Set correct role based on type + let role = 'listitem'; + let ariaChecked; + + if (this.trailingType === 'checkbox') { + role = 'checkbox'; + ariaChecked = this.selected ? 'true' : 'false'; + } else if (this.trailingType === 'radio') { + role = 'radio'; + ariaChecked = this.selected ? 'true' : 'false'; + } + + return html` +
  • + + + ${this.leadingType !== 'none' ? html` +
    + +
    + ` : ''} + + +
    + +
    + ${this.primaryText ? html`${this.primaryText}` : ''} + +
    + + + ${this.secondaryText ? html` +

    ${this.secondaryText}

    + ` : html` + + `} +
    + + + ${this.trailingType !== 'none' || this.metaText ? html` +
    + ${this.metaText ? html` + ${this.metaText} + ` : ''} + +
    + ` : ''} +
  • + `; + } + + // Renders a `CSSResult` generated from SCSS by Vite + static styles = unsafeCSS(styles); } +// Utility function for conditional attributes +const ifDefined = (value: string | null | undefined) => (value === null || value === undefined ? undefined : value); + defineCustomElement(componentSelector, PieListItem); declare global { diff --git a/packages/components/pie-list/src/pie-list-item/list-item.scss b/packages/components/pie-list/src/pie-list-item/list-item.scss index 6ffaedad64..0a43218437 100644 --- a/packages/components/pie-list/src/pie-list-item/list-item.scss +++ b/packages/components/pie-list/src/pie-list-item/list-item.scss @@ -1 +1,199 @@ @use '@justeattakeaway/pie-css/scss' as p; + +:host { + // These variables can be overridden by the parent list component + --list-item-leading-margin: var(--dt-spacing-c); + --list-item-trailing-margin: var(--dt-spacing-c); + --list-item-hover-bg: var(--dt-color-hover-1); + --list-item-active-bg: var(--dt-color-active-1); + --list-item-focus-outline: 2px solid var(--dt-color-focus); + --list-item-focus-offset: -2px; + --list-item-touch-target-height: 48px; +} + +.c-list-item { + display: flex; + align-items: center; + padding: var(--list-item-padding, var(--dt-spacing-d)); + list-style: none; + width: 100%; + box-sizing: border-box; + border-bottom: var(--list-item-border-bottom, none); + position: relative; + background-color: var(--dt-color-container-default); + + // Set minimum height for proper touch targets + min-height: var(--list-item-touch-target-height); +} + +// Layout container +.c-list-item__content { + flex: 1; + min-width: 0; // Ensures text truncation works +} + +// Primary text styles +.c-list-item__primary { + color: var(--dt-color-content-default); + font-size: #{p.font-size(--dt-font-body-l-size)}; + font-weight: var(--dt-font-weight-regular); + line-height: #{p.line-height(--dt-font-body-l-line-height)}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +// Secondary text styles +.c-list-item__secondary { + color: var(--dt-color-content-subdued); + font-size: #{p.font-size(--dt-font-body-s-size)}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 0; +} + +// Leading content container - using logical properties for RTL support +.c-list-item__leading { + margin-inline-end: var(--list-item-leading-margin); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +// Trailing content container - using logical properties for RTL support +.c-list-item__trailing { + margin-inline-start: var(--list-item-trailing-margin); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +// Meta text styles +.c-list-item__meta { + color: var(--dt-color-content-subdued); + font-size: #{p.font-size(--dt-font-body-s-size)}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +// Compact variation +.c-list-item--compact { + padding-block: var(--dt-spacing-c); + padding-inline: var(--dt-spacing-d); + min-height: 40px; +} + +.c-list-item--compact .c-list-item__primary { + font-size: var(--dt-font-size-2); + line-height: var(--dt-line-height-3); +} + +.c-list-item--compact .c-list-item__secondary { + font-size: var(--dt-font-size-1); + line-height: var(--dt-line-height-1); +} + +// Interactive states +.c-list-item--interactive { + cursor: pointer; +} + +.c-list-item--interactive:hover { + background-color: var(--list-item-hover-bg); +} + +.c-list-item--interactive:active { + background-color: var(--list-item-active-bg); +} + +.c-list-item--interactive:focus-visible { + outline: var(--list-item-focus-outline); + outline-offset: var(--list-item-focus-offset); +} + +// Selected state +.c-list-item--selected { + background-color: var(--dt-color-active-1); +} + +// Disabled state +.c-list-item--disabled { + opacity: 0.5; + pointer-events: none; +} + +// Content type variations - Leading +.c-list-item--leading-icon { + --list-item-icon-size: 24px; +} + +.c-list-item--leading-avatar { + --list-item-avatar-size: 40px; +} + +.c-list-item--leading-thumbnail { + --list-item-thumbnail-size: 40px; +} + +.c-list-item--leading-thumbnail .c-list-item__leading { + height: var(--list-item-thumbnail-size); + width: var(--list-item-thumbnail-size); + overflow: hidden; + border-radius: var(--dt-border-radius-b); +} + +.c-list-item--leading-payment { + --list-item-payment-size: 32px; +} + +// Content type variations - Trailing +.c-list-item--trailing-meta .c-list-item__trailing { + text-align: end; // Logical property instead of 'right' +} + +.c-list-item--trailing-tag .c-list-item__trailing > * { + max-width: 120px; // Limit tag width +} + +// Handle form controls +.c-list-item--control { + cursor: pointer; +} + +// With secondary text adjustments +.c-list-item--with-secondary { + align-items: flex-start; +} + +.c-list-item--with-secondary .c-list-item__leading, +.c-list-item--with-secondary .c-list-item__trailing { + margin-block-start: 2px; // Logical property instead of 'margin-top' +} + +// Thumbnail-specific styles for slotted content +.c-list-item--leading-thumbnail .c-list-item__leading ::slotted(img) { + width: 100%; + height: 100%; + object-fit: cover; +} + +.c-list-item__leading ::slotted(*), +.c-list-item__trailing ::slotted(*) { + display: inline-flex; + align-items: center; + justify-content: center; +} + +// Avatar-specific styles +.c-list-item--leading-avatar .c-list-item__leading ::slotted(pie-avatar) { + width: var(--list-item-avatar-size); + height: var(--list-item-avatar-size); +} + +.c-list-item--leading-checkbox .c-list-item__leading { + margin-inline-end: var(--dt-spacing-a); +} diff --git a/packages/components/pie-list/test/accessibility/pie-list-a11y.spec.ts b/packages/components/pie-list/test/accessibility/pie-list-a11y.spec.ts new file mode 100644 index 0000000000..f2e122dd32 --- /dev/null +++ b/packages/components/pie-list/test/accessibility/pie-list-a11y.spec.ts @@ -0,0 +1,125 @@ +import { test, expect } from '@justeattakeaway/pie-webc-testing/src/playwright/playwright-fixtures.ts'; +import { ListPage } from '../helpers/page-object/pie-list.page.ts'; + +test.describe('PieList - Accessibility tests', () => { + test('a11y - should test the default PieList component WCAG compliance', async ({ page, makeAxeBuilder }) => { + // Arrange + const listPage = new ListPage(page, 'list--default'); + await listPage.load(); + + // Act & Assert + const results = await makeAxeBuilder().analyze(); + expect(results.violations).toEqual([]); + }); + + test('a11y - should test the interactive PieList component WCAG compliance', async ({ page, makeAxeBuilder }) => { + // Arrange + const listPage = new ListPage(page, 'list--interactive'); + await listPage.load(); + + // Act & Assert + const results = await makeAxeBuilder().analyze(); + expect(results.violations).toEqual([]); + }); + + test('a11y - should test PieList with many items WCAG compliance', async ({ page, makeAxeBuilder }) => { + // Arrange + const listPage = new ListPage(page, 'list--many-items'); + await listPage.load(); + + // Act & Assert + const results = await makeAxeBuilder().analyze(); + expect(results.violations).toEqual([]); + }); + + test('a11y - should verify list has correct ARIA role', async ({ page }) => { + // Arrange + const listPage = new ListPage(page, 'list--default'); + await listPage.load(); + + // Check ARIA role using DOM method + const listRole = await page.evaluate(() => { + const list = document.querySelector('pie-list'); + const shadowRoot = list?.shadowRoot; + const ulElement = shadowRoot?.querySelector('ul'); + return ulElement?.getAttribute('role'); + }); + + expect(listRole).toBe('list'); + }); + + test('a11y - should have correct keyboard navigation structure for interactive lists', async ({ page }) => { + // Arrange + const listPage = new ListPage(page, 'list--interactive'); + await listPage.load(); + + // Tab to the list + await page.keyboard.press('Tab'); + + // Check if the first list item received focus + const isSomethingFocused = await page.evaluate(() => document.activeElement !== document.body); + + // Something should be focused + expect(isSomethingFocused).toBe(true); + + // Test arrow key navigation + // Press down arrow and check if focus moves to next item + await page.keyboard.press('ArrowDown'); + + // We want to check if focus moved correctly, this would need to be implemented + // based on the component's actual keyboard navigation behavior + const secondItemFocused = await page.evaluate(() => { + const { activeElement } = document; + // This depends on how the items are structured, adjust as needed + const items = Array.from(document.querySelectorAll('pie-list-item')); + return items.indexOf(activeElement as Element) === 1; // second item (index 1) + }); + + expect(secondItemFocused).toBe(true); + }); + + test('a11y - should properly manage focus with roving tabindex', async ({ page }) => { + // Arrange + const listPage = new ListPage(page, 'list--interactive'); + await listPage.load(); + + // Evaluate if the roving tabindex pattern is implemented correctly + const rovingTabindexImplemented = await page.evaluate(() => { + const list = document.querySelector('pie-list'); + return list?.hasAttribute('roving-tabindex-enabled') === true; + }); + + expect(rovingTabindexImplemented).toBe(true); + + // Check if only one item has tabindex="0" + const itemsWithTabindexZero = await page.evaluate(() => { + const items = Array.from(document.querySelectorAll('pie-list-item')); + return items.filter((item) => item.getAttribute('tabindex') === '0').length; + }); + + expect(itemsWithTabindexZero).toBe(1); + }); + + test('a11y - should verify list items have correct role', async ({ page }) => { + // Arrange + const listPage = new ListPage(page, 'list--default'); + await listPage.load(); + + // Check if list items have the right role + const listItemsHaveCorrectRole = await page.evaluate(() => { + const listItems = Array.from(document.querySelectorAll('pie-list-item')); + + // For custom elements, we need to check the shadow DOM + const allHaveCorrectRole = listItems.every((item) => { + const { shadowRoot } = item; + const liElement = shadowRoot?.querySelector('li'); + return liElement?.getAttribute('role') === 'listitem' || + item.getAttribute('role') === 'listitem'; + }); + + return allHaveCorrectRole; + }); + + expect(listItemsHaveCorrectRole).toBe(true); + }); +}); diff --git a/packages/components/pie-list/test/accessibility/pie-list-nested.spec.ts b/packages/components/pie-list/test/accessibility/pie-list-nested.spec.ts new file mode 100644 index 0000000000..ae23bd42ac --- /dev/null +++ b/packages/components/pie-list/test/accessibility/pie-list-nested.spec.ts @@ -0,0 +1,134 @@ +import { test, expect } from '@justeattakeaway/pie-webc-testing/src/playwright/playwright-fixtures.ts'; +import { ListPage } from '../helpers/page-object/pie-list.page.ts'; + +test.describe('PieList - Nested Shadow DOM Tests', () => { + test('should maintain proper ARIA roles across shadow DOM boundaries', async ({ page, makeAxeBuilder }) => { + // Arrange - create a test page with nested shadow DOM structure + const listPage = new ListPage(page, 'list--with-list-items'); + await listPage.load(); + + // Run axe analysis on the loaded story + const results = await makeAxeBuilder().analyze(); + expect(results.violations).toEqual([]); + + // Check that the list has the correct role using DOM APIs + const listRole = await page.evaluate(() => { + const list = document.querySelector('pie-list'); + const shadowRoot = list?.shadowRoot; + const ulElement = shadowRoot?.querySelector('ul'); + return ulElement?.getAttribute('role'); + }); + + expect(listRole).toBe('list'); + + // Check list items are properly exposed + const listItemCount = await page.evaluate(() => document.querySelectorAll('pie-list-item').length); + expect(listItemCount).toBeGreaterThan(0); + }); + + test('should allow proper keyboard navigation through nested shadow DOM', async ({ page }) => { + // Load the interactive list story + const listPage = new ListPage(page, 'list--interactive'); + await listPage.load(); + + // Tab to the first interactive element + await page.keyboard.press('Tab'); + + // Get information about the first focused element + const firstFocusInfo = await page.evaluate(() => { + const element = document.activeElement; + return { + tagName: element?.tagName.toLowerCase(), + textContent: element?.textContent?.trim(), + isListRelated: element?.tagName.toLowerCase() === 'pie-list' || + element?.tagName.toLowerCase() === 'pie-list-item' || + element?.tagName.toLowerCase() === 'li' || + element?.closest('pie-list-item') !== null || + element?.closest('li') !== null, + }; + }); + + // First focus should be on a list-related element + expect(firstFocusInfo.isListRelated).toBe(true); + + // Arrow down to navigate through the list + await page.keyboard.press('ArrowDown'); + + // Get information about the second focused element + const secondFocusInfo = await page.evaluate(() => { + const element = document.activeElement; + return { + tagName: element?.tagName.toLowerCase(), + textContent: element?.textContent?.trim(), + isListRelated: element?.tagName.toLowerCase() === 'pie-list' || + element?.tagName.toLowerCase() === 'pie-list-item' || + element?.tagName.toLowerCase() === 'li' || + element?.closest('pie-list-item') !== null || + element?.closest('li') !== null, + }; + }); + + // Second focus should also be on a list-related element + expect(secondFocusInfo.isListRelated).toBe(true); + + // And it should be a different element than the first one + expect(firstFocusInfo.tagName !== secondFocusInfo.tagName || + firstFocusInfo.textContent !== secondFocusInfo.textContent).toBe(true); + }); + + test('should handle RTL direction properly for lists', async ({ page }) => { + // Arrange - load the list with RTL direction + const listPage = new ListPage(page, 'list--default'); + await listPage.load({}, { writingDirection: 'rtl' }); + + // Check if the list has the RTL class/attribute + const hasRtlClass = await page.evaluate(() => { + const list = document.querySelector('pie-list'); + return list?.hasAttribute('dir') && list?.getAttribute('dir') === 'rtl'; + }); + + expect(hasRtlClass).toBe(true); + + // Check if the shadow DOM reflects RTL + const shadowDomHasRtl = await page.evaluate(() => { + const list = document.querySelector('pie-list'); + const shadowRoot = list?.shadowRoot; + const ulElement = shadowRoot?.querySelector('ul'); + + // Check for specific RTL class in the shadow DOM + // This depends on the actual implementation + return ulElement?.classList.contains('c-list--rtl'); + }); + + expect(shadowDomHasRtl).toBe(true); + }); + + test('should correctly forward event delegation for interactive lists', async ({ page }) => { + // Arrange + const listPage = new ListPage(page, 'list--interactive'); + await listPage.load(); + + // Click on a list item and check for custom event + const wasEventDispatched = await page.evaluate(() => { + let eventWasDispatched = false; + + // Add event listener for the custom event + document.addEventListener('pie-list-item-click', () => { + eventWasDispatched = true; + }); + + // Click the first list item + const listItem = document.querySelector('pie-list-item'); + listItem?.click(); + + return eventWasDispatched; + }); + + // Check if the event was dispatched + expect(wasEventDispatched).toBeTruthy(); + + // Also verify the list is interactive as a secondary check + const isInteractive = await page.evaluate(() => document.querySelector('pie-list')?.hasAttribute('interactive')); + expect(isInteractive).toBe(true); + }); +}); diff --git a/packages/components/pie-list/test/accessibility/pie-list.spec.ts b/packages/components/pie-list/test/accessibility/pie-list.spec.ts deleted file mode 100644 index fd611fe7e2..0000000000 --- a/packages/components/pie-list/test/accessibility/pie-list.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { test, expect } from '@justeattakeaway/pie-webc-testing/src/playwright/playwright-fixtures.ts'; -import { BasePage } from '@justeattakeaway/pie-webc-testing/src/helpers/page-object/base-page.ts'; -import { PieList } from '../../src/index.ts'; - -test.describe('PieList - Accessibility tests', () => { - test('a11y - should test the PieList component WCAG compliance', async ({ page, makeAxeBuilder }) => { - // Arrange - const basePage = new BasePage(page, 'list--default'); - - basePage.load(); - await page.waitForTimeout(2500); - - // Act - const results = await makeAxeBuilder().analyze(); - - expect(results.violations).toEqual([]); - }); -}); diff --git a/packages/components/pie-list/test/component/pie-list.spec.ts b/packages/components/pie-list/test/component/pie-list.spec.ts index 7828b0a9d9..5924ddaa99 100644 --- a/packages/components/pie-list/test/component/pie-list.spec.ts +++ b/packages/components/pie-list/test/component/pie-list.spec.ts @@ -1,20 +1,213 @@ import { test, expect } from '@playwright/test'; -import { BasePage } from '@justeattakeaway/pie-webc-testing/src/helpers/page-object/base-page.ts'; - -const componentSelector = '[data-test-id="pie-list"]'; +import { ListPage } from '../helpers/page-object/pie-list.page.ts'; +import { type ListProps } from '../../src'; test.describe('PieList - Component tests', () => { test('should render successfully', async ({ page }) => { // Arrange - const basePage = new BasePage(page, 'list--default'); - - basePage.load(); - await page.waitForTimeout(2500); + const listPage = new ListPage(page); // Act - const list = page.locator(componentSelector); + await listPage.load(); // Assert - expect(list).toBeVisible(); + await expect(listPage.listComponent.componentLocator).toBeVisible(); + + // Check if the list element exists in the shadow DOM by using page.evaluate + const listExists = await page.evaluate(() => { + const list = document.querySelector('pie-list'); + const shadowRoot = list?.shadowRoot; + return shadowRoot?.querySelector('[data-test-id="pie-list"]') !== null; + }); + + expect(listExists).toBe(true); + }); + + test.describe('props', () => { + test.describe('variant', () => { + test.describe('when set to "default"', () => { + test('should render with default styling', async ({ page }) => { + // Arrange + const props: ListProps = { + variant: 'default', + }; + + const listPage = new ListPage(page); + await listPage.loadWithProps(props); + + // Act + const list = listPage.listComponent.componentLocator; + + // Assert + await expect(list).toHaveAttribute('variant', 'default'); + }); + }); + + test.describe('when set to "compact"', () => { + test('should render with compact styling', async ({ page }) => { + // Arrange + const props: ListProps = { + variant: 'compact', + }; + + const listPage = new ListPage(page, 'list--compact-variant'); + await listPage.loadWithProps(props); + + // Act + const list = listPage.listComponent.componentLocator; + + // Assert + await expect(list).toHaveAttribute('variant', 'compact'); + }); + }); + }); + + test.describe('interactive', () => { + test.describe('when true', () => { + test('should make list items interactive', async ({ page }) => { + // Arrange + const props: ListProps = { + interactive: true, + }; + + const listPage = new ListPage(page, 'list--interactive'); + await listPage.loadWithProps(props); + + // Act + const list = listPage.listComponent.componentLocator; + await expect(list).toHaveAttribute('interactive', ''); + + // Check if clicking changes visual state via adding a class or attribute + // We can verify the interactive behavior by checking if the component responds visually + const hasPseudoClass = await page.evaluate(async () => { + const item = document.querySelector('pie-list-item'); + item?.click(); + + // Check if shadowRoot contains any element with active or similar class + // Or check if the component dispatches a custom event + // This is a simpler check - just confirms the attribute exists + return item?.hasAttribute('interactive') || + document.querySelector('pie-list')?.hasAttribute('interactive'); + }); + + // Assert + expect(hasPseudoClass).toBeTruthy(); + }); + }); + + test.describe('when false', () => { + test('should not have interactive attribute', async ({ page }) => { + // Arrange + const props: ListProps = { + interactive: false, + }; + + const listPage = new ListPage(page); + await listPage.loadWithProps(props); + + // Act + const list = listPage.listComponent.componentLocator; + + // Assert + await expect(list).not.toHaveAttribute('interactive', ''); + }); + }); + }); + + test.describe('dividers', () => { + test.describe('when true', () => { + test('should render with dividers between items', async ({ page }) => { + // Arrange + const props: ListProps = { + dividers: true, + }; + + const listPage = new ListPage(page, 'list--with-dividers'); + await listPage.loadWithProps(props); + + // Act + const list = listPage.listComponent.componentLocator; + + // Assert + await expect(list).toHaveAttribute('dividers', ''); + }); + }); + + test.describe('when false', () => { + test('should not have dividers attribute', async ({ page }) => { + // Arrange + const props: ListProps = { + dividers: false, + }; + + const listPage = new ListPage(page); + await listPage.loadWithProps(props); + + // Act + const list = listPage.listComponent.componentLocator; + + // Assert + await expect(list).not.toHaveAttribute('dividers', ''); + }); + }); + }); + + test.describe('optimizeThreshold', () => { + test('should use the optimizeThreshold value to determine rendering strategy', async ({ page }) => { + // Arrange + const props: ListProps = { + optimizeThreshold: 5, // Lower threshold to trigger optimization + }; + + const listPage = new ListPage(page, 'list--many-items'); + await listPage.loadWithProps(props); + + // Act + const list = listPage.listComponent.componentLocator; + + // Assert + await expect(list).toHaveAttribute('optimizeThreshold', '5'); + + // Check that items are still visible (regardless of rendering strategy) + const items = listPage.listComponent.listItems(); + expect(await items.count()).toBeGreaterThan(0); + }); + }); + }); + + test.describe('Slot behavior', () => { + test('should render slotted pie-list-item elements', async ({ page }) => { + // Arrange + const listPage = new ListPage(page, 'list--default'); + await listPage.load(); + + // Act & Assert + const items = page.locator('pie-list-item'); + expect(await items.count()).toBeGreaterThan(0); + }); + + test('should dynamically update when items are added', async ({ page }) => { + // This would need to be tested with a specific story that allows adding items + // For this test, we'll simulate it with page evaluation + + // Arrange + const listPage = new ListPage(page, 'list--default'); + await listPage.load(); + + // Get initial count + const initialCount = await page.locator('pie-list-item').count(); + + // Act: Add a new item programmatically + await page.evaluate(() => { + const list = document.querySelector('pie-list'); + const newItem = document.createElement('pie-list-item'); + newItem.textContent = 'Dynamically Added Item'; + list?.appendChild(newItem); + }); + + // Assert + const newCount = await page.locator('pie-list-item').count(); + expect(newCount).toBe(initialCount + 1); + }); }); }); diff --git a/packages/components/pie-list/test/helpers/page-object/pie-list.component.ts b/packages/components/pie-list/test/helpers/page-object/pie-list.component.ts new file mode 100644 index 0000000000..2306afbe6d --- /dev/null +++ b/packages/components/pie-list/test/helpers/page-object/pie-list.component.ts @@ -0,0 +1,44 @@ +import type { Locator, Page } from '@playwright/test'; + +export class ListComponent { + readonly componentLocator: Locator; + + constructor (page: Page) { + this.componentLocator = page.locator('pie-list'); + } + + /** + * Gets all list items within the component + */ + listItems () { + // Get by selector + return this.componentLocator.locator('pie-list-item, li'); + } + + /** + * Gets a specific list item by index + */ + getItemAtIndex (index: number) { + return this.listItems().nth(index); + } + + /** + * Gets a specific list item by text content + */ + getItemByText (text: string) { + return this.listItems().filter({ hasText: text }); + } + + /** + * Checks if the list has the specified attribute + */ + async hasAttribute (page: Page, attribute: string): Promise { + return page.evaluate( + ([selector, attr]) => { + const element = document.querySelector(selector); + return element?.hasAttribute(attr) ?? false; + }, + ['pie-list', attribute], + ); + } +} diff --git a/packages/components/pie-list/test/helpers/page-object/pie-list.page.ts b/packages/components/pie-list/test/helpers/page-object/pie-list.page.ts new file mode 100644 index 0000000000..f91c8b909b --- /dev/null +++ b/packages/components/pie-list/test/helpers/page-object/pie-list.page.ts @@ -0,0 +1,21 @@ +import type { Page } from '@playwright/test'; +import { BasePage } from '@justeattakeaway/pie-webc-testing/src/helpers/page-object/base-page.ts'; +import { ListComponent } from './pie-list.component.ts'; +import { type ListProps } from '../../../src'; + +export class ListPage extends BasePage { + readonly listComponent: ListComponent; + + constructor(page: Page, storyId: string = 'list--default') { + super(page, storyId); + this.listComponent = new ListComponent(page); + } + + /** + * Load a specific list story with optional props + */ + async loadWithProps(props?: Partial, additionalOptions = {}) { + await this.load({ ...props }, additionalOptions); + return this; + } +} diff --git a/packages/components/pie-list/test/helpers/page-object/selectors.ts b/packages/components/pie-list/test/helpers/page-object/selectors.ts new file mode 100644 index 0000000000..8130aead5a --- /dev/null +++ b/packages/components/pie-list/test/helpers/page-object/selectors.ts @@ -0,0 +1,16 @@ +const list = { + selectors: { + container: { + description: 'The selector for the list element', + dataTestId: 'pie-list', + }, + listItem: { + description: 'The selector for the list item element', + dataTestId: 'pie-list-item-content', + } + }, +}; + +export { + list, +}; diff --git a/packages/components/pie-list/test/visual/pie-list.spec.ts b/packages/components/pie-list/test/visual/pie-list.spec.ts index b56cc309fc..c51bf45947 100644 --- a/packages/components/pie-list/test/visual/pie-list.spec.ts +++ b/packages/components/pie-list/test/visual/pie-list.spec.ts @@ -1,14 +1,84 @@ import { test } from '@playwright/test'; import percySnapshot from '@percy/playwright'; -import { BasePage } from '@justeattakeaway/pie-webc-testing/src/helpers/page-object/base-page.ts'; +import { percyWidths } from '@justeattakeaway/pie-webc-testing/src/percy/breakpoints.ts'; +import { ListPage } from '../helpers/page-object/pie-list.page.ts'; -test.describe('PieList - Visual tests`', () => { +test.describe('PieList - Visual tests', () => { test('should display the PieList component successfully', async ({ page }) => { - const basePage = new BasePage(page, 'list--default'); + const listPage = new ListPage(page, 'list--default'); - basePage.load(); - await page.waitForTimeout(2500); + await listPage.load(); + await page.waitForTimeout(1000); // Allow time for component to render - await percySnapshot(page, 'PieList - Visual Test'); + await percySnapshot(page, 'PieList - Default Variant', percyWidths); + }); + + test('should display the compact variant correctly', async ({ page }) => { + const listPage = new ListPage(page, 'list--compact-variant'); + + await listPage.load(); + await page.waitForTimeout(1000); // Allow time for component to render + + await percySnapshot(page, 'PieList - Compact Variant', percyWidths); + }); + + test('should display dividers correctly', async ({ page }) => { + const listPage = new ListPage(page, 'list--with-dividers'); + + await listPage.load(); + await page.waitForTimeout(1000); // Allow time for component to render + + await percySnapshot(page, 'PieList - With Dividers', percyWidths); + }); + + test('should display interactive list correctly', async ({ page }) => { + const listPage = new ListPage(page, 'list--interactive'); + + await listPage.load(); + await page.waitForTimeout(1000); // Allow time for component to render + + await percySnapshot(page, 'PieList - Interactive', percyWidths); + }); + + // Test all combinations + test('should display full-featured list correctly (interactive + compact + dividers)', async ({ page }) => { + const listPage = new ListPage(page, 'list--full-featured'); + + await listPage.load(); + await page.waitForTimeout(1000); // Allow time for component to render + + await percySnapshot(page, 'PieList - Full Featured', percyWidths); + }); + + test('should display long text content list correctly', async ({ page }) => { + const listPage = new ListPage(page, 'list--long-text-content'); + + await listPage.load(); + await page.waitForTimeout(1000); // Allow time for component to render + + await percySnapshot(page, 'PieList - Long Text Content', percyWidths); + }); + + test('should display a list with many items correctly', async ({ page }) => { + const listPage = new ListPage(page, 'list--many-items'); + + await listPage.load(); + await page.waitForTimeout(1000); // Allow time for component to render + + await percySnapshot(page, 'PieList - Many Items', percyWidths); + }); + + // Test RTL support + const directions = ['ltr', 'rtl']; + + directions.forEach((direction) => { + test(`should render correctly with direction: ${direction}`, async ({ page }) => { + const listPage = new ListPage(page, 'list--default'); + + await listPage.load({}, { writingDirection: direction }); + await page.waitForTimeout(1000); // Allow time for component to render + + await percySnapshot(page, `PieList - Direction: ${direction}`, percyWidths); + }); }); });