From d11727f64a98d0a9a2d541ad6f036e757d5ea7c8 Mon Sep 17 00:00:00 2001 From: "matthew.hardern" Date: Wed, 23 Apr 2025 09:42:24 +0100 Subject: [PATCH 1/2] feat(pie-list-item): implement core functionality Adds properties, state management, and lifecycle hooks for the list item component. Implements parent list synchronization, accessibility attributes, and slot management. Prepares component for interactive and compact variants. --- .changeset/sharp-chefs-relate.md | 5 + apps/pie-storybook/.storybook/backgrounds.ts | 2 +- .../pie-storybook/stories/pie-list.stories.ts | 403 +++++++++++++- .../pie-list/pie-list.test.stories.ts | 34 -- packages/components/pie-list/src/defs.ts | 25 +- packages/components/pie-list/src/index.ts | 184 ++++++- packages/components/pie-list/src/list.scss | 38 ++ .../src/mixins/roving-tabindex-mixin.ts | 268 +++++++++ .../pie-list/src/pie-list-item/index.ts | 511 +++++++++++++++++- .../pie-list/src/pie-list-item/list-item.scss | 196 +++++++ .../test/accessibility/pie-list-a11y.spec.ts | 125 +++++ .../accessibility/pie-list-nested.spec.ts | 134 +++++ .../test/accessibility/pie-list.spec.ts | 18 - .../pie-list/test/component/pie-list.spec.ts | 211 +++++++- .../helpers/page-object/pie-list.component.ts | 44 ++ .../test/helpers/page-object/pie-list.page.ts | 21 + .../test/helpers/page-object/selectors.ts | 16 + .../pie-list/test/visual/pie-list.spec.ts | 82 ++- 18 files changed, 2220 insertions(+), 97 deletions(-) create mode 100644 .changeset/sharp-chefs-relate.md delete mode 100644 packages/components/pie-list/pie-list.test.stories.ts create mode 100644 packages/components/pie-list/src/mixins/roving-tabindex-mixin.ts create mode 100644 packages/components/pie-list/test/accessibility/pie-list-a11y.spec.ts create mode 100644 packages/components/pie-list/test/accessibility/pie-list-nested.spec.ts delete mode 100644 packages/components/pie-list/test/accessibility/pie-list.spec.ts create mode 100644 packages/components/pie-list/test/helpers/page-object/pie-list.component.ts create mode 100644 packages/components/pie-list/test/helpers/page-object/pie-list.page.ts create mode 100644 packages/components/pie-list/test/helpers/page-object/selectors.ts 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..b20d316d94 100644 --- a/apps/pie-storybook/stories/pie-list.stories.ts +++ b/apps/pie-storybook/stories/pie-list.stories.ts @@ -1,34 +1,399 @@ 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: { + backgrounds: { + default: 'background-subtle', + }, + }, + render: () => html` +
+ + Primary text + +
+ `, +}; + +export const CompactListItem: Story = { + name: 'List Item - Compact', + parameters: { + backgrounds: { + default: 'background-subtle', + }, + }, + render: () => html` +
+ + Primary text + +
+ `, +}; + +export const SecondaryTextListItem: Story = { + name: 'List Item - Secondary Text', parameters: { - design: { - type: 'figma', - url: '', + backgrounds: { + default: 'background-subtle', }, }, + render: () => html` +
+ + Primary text + +
+ `, }; -export default listStoryMeta; +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` +
+ + + + + +
+ `, +}; -// TODO: remove the eslint-disable rule when props are added -// eslint-disable-next-line no-empty-pattern -const Template = ({}: ListProps) => 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 Default = createStory(Template, defaultArgs)(); +//---------------------------------------------------------------------- +// 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..f1897e6ffd 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..a9a8c20788 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,197 @@ @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: var(--dt-font-body-l-size); + font-weight: var(--dt-font-weight-regular); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +// Secondary text styles +.c-list-item__secondary { + color: var(--dt-color-content-subdued); + font-size: var(--dt-font-body-s-size); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +// 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: var(--dt-font-body-s-size); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +// Compact variation +.c-list-item--compact { + padding-block: var(--dt-spacing-b); + 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); + }); }); }); From c9bdc2cff32535347e3c5b92c87044bf9a3396b1 Mon Sep 17 00:00:00 2001 From: "matthew.hardern" Date: Tue, 29 Apr 2025 13:45:19 +0100 Subject: [PATCH 2/2] feat(pie-list): improve list item styling and add new stories Update list item component to enhance typography and layout: - Use p.font-size() for better font size control - Add line-height to primary text for improved readability - Change compact variant padding for better vertical alignment - Convert secondary text div to p element for semantic markup - Adjust meta text font size using p.font-size() - Remove margin from secondary text Add new stories for list items with secondary text and icons/thumbnails --- .../pie-storybook/stories/pie-list.stories.ts | 36 +++++++++++++++++++ .../pie-list/src/pie-list-item/index.ts | 2 +- .../pie-list/src/pie-list-item/list-item.scss | 10 +++--- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/apps/pie-storybook/stories/pie-list.stories.ts b/apps/pie-storybook/stories/pie-list.stories.ts index b20d316d94..b3e1fcd539 100644 --- a/apps/pie-storybook/stories/pie-list.stories.ts +++ b/apps/pie-storybook/stories/pie-list.stories.ts @@ -192,6 +192,42 @@ export const CheckboxLeadingListItem: Story = { `, }; +export const IconLeadingListItemSecondaryText: Story = { + name: 'List Item - Icon Leading - Secondary Text', + parameters: { + backgrounds: { + default: 'background-subtle', + }, + }, + render: () => html` +
    + + + + + +
    + `, +}; + +export const ThumbnailLeadingListItemSecondaryText: Story = { + name: 'List Item - Thumbnail Leading - Secondary Text', + parameters: { + backgrounds: { + default: 'background-subtle', + }, + }, + render: () => html` +
    + + + placeholder + + +
    + `, +}; + //---------------------------------------------------------------------- // 2. INTERACTIVITY (INTERACTIVE/NON-INTERACTIVE) //---------------------------------------------------------------------- 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 f1897e6ffd..907dd8440c 100644 --- a/packages/components/pie-list/src/pie-list-item/index.ts +++ b/packages/components/pie-list/src/pie-list-item/index.ts @@ -488,7 +488,7 @@ export class PieListItem extends RtlMixin(PieElement) implements ListItemProps { ${this.secondaryText ? html` -
    ${this.secondaryText}
    +

    ${this.secondaryText}

    ` : html` `} 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 a9a8c20788..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 @@ -35,8 +35,9 @@ // Primary text styles .c-list-item__primary { color: var(--dt-color-content-default); - font-size: var(--dt-font-body-l-size); + 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; @@ -45,10 +46,11 @@ // Secondary text styles .c-list-item__secondary { color: var(--dt-color-content-subdued); - font-size: var(--dt-font-body-s-size); + 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 @@ -72,7 +74,7 @@ // Meta text styles .c-list-item__meta { color: var(--dt-color-content-subdued); - font-size: var(--dt-font-body-s-size); + font-size: #{p.font-size(--dt-font-body-s-size)}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -80,7 +82,7 @@ // Compact variation .c-list-item--compact { - padding-block: var(--dt-spacing-b); + padding-block: var(--dt-spacing-c); padding-inline: var(--dt-spacing-d); min-height: 40px; }