diff --git a/etc/lime-elements.api.md b/etc/lime-elements.api.md index e534997dcf..851fb6014d 100644 --- a/etc/lime-elements.api.md +++ b/etc/lime-elements.api.md @@ -1733,10 +1733,6 @@ export namespace JSX { "iconSize"?: IconSize; "image"?: ListItem['image']; "language"?: Languages; - "onInteract"?: (event: LimelListItemCustomEvent<{ - selected: boolean; - item: ListItem; - }>) => void; "primaryComponent"?: ListItem['primaryComponent']; "secondaryText"?: string; "selected"?: boolean; @@ -2345,16 +2341,6 @@ export interface LimelListCustomEvent extends CustomEvent { target: HTMLLimelListElement; } -// Warning: (ae-missing-release-tag) "LimelListItemCustomEvent" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export interface LimelListItemCustomEvent extends CustomEvent { - // (undocumented) - detail: T; - // (undocumented) - target: HTMLLimelListItemElement; -} - // Warning: (ae-missing-release-tag) "LimelMenuCustomEvent" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/src/components/list-item/examples/list-item-actions.tsx b/src/components/list-item/examples/list-item-actions.tsx index b2b418a4b6..a4f33ebc96 100644 --- a/src/components/list-item/examples/list-item-actions.tsx +++ b/src/components/list-item/examples/list-item-actions.tsx @@ -1,4 +1,4 @@ -import { ListItem, MenuItem, ListSeparator } from '@limetech/lime-elements'; +import { MenuItem, ListSeparator } from '@limetech/lime-elements'; import { Component, h, Host, State } from '@stencil/core'; /** @@ -15,7 +15,9 @@ import { Component, h, Host, State } from '@stencil/core'; * will eventually extract `event.detail.value` to get the actual `Action` * * :::note - * The action menu of the disabled items remains enabled! + * Disabled list items keep their action menu enabled so actions remain + * accessible (e.g., for contextual info). The example guards against + * toggling selection when disabled. * ::: */ @Component({ @@ -44,9 +46,10 @@ export class ListItemActionsExample { public render() { return ( -
    +
    @@ -85,21 +87,83 @@ export class ListItemActionsExample { ); } - private onListItemInteraction = ( - event: CustomEvent<{ selected: boolean; item: ListItem }> - ) => { - const itemValue = event.detail.item.value as number; - const isSelected = event.detail.selected; - - if (isSelected) { - this.selectedItems = new Set([...this.selectedItems, itemValue]); - } else { + private toggleValue = (value: number, text: string) => { + const selected = this.selectedItems.has(value); + if (selected) { this.selectedItems = new Set( - [...this.selectedItems].filter((id) => id !== itemValue) + [...this.selectedItems].filter((id) => id !== value) ); + this.lastInteraction = `Deselected "${text}"`; + } else { + this.selectedItems = new Set([...this.selectedItems, value]); + this.lastInteraction = `Selected "${text}"`; } + }; - this.lastInteraction = `Item "${event.detail.item.text}" interaction: selected=${isSelected}`; + private onClick = (event: MouseEvent) => { + // If the entire example is disabled, ignore item clicks + if (this.disabled) { + return; + } + const host = (event.target as HTMLElement).closest('limel-list-item'); + if (!host) { + return; + } + // Skip if clicking the action menu trigger or inside the menu + if ( + (event.target as HTMLElement).closest('.action-menu-trigger') || + (event.target as HTMLElement).closest('limel-menu') + ) { + return; + } + // Respect per-item disabled state (attribute reflected by the component) + if ( + host.hasAttribute('disabled') || + host.getAttribute('aria-disabled') === 'true' + ) { + return; + } + const value = Number((host as HTMLElement).dataset.value); + const text = host.getAttribute('text') || ''; + this.toggleValue(value, text); + }; + + private onKeyDown = (event: KeyboardEvent) => { + if (this.disabled) { + return; + } + const isEnter = event.key === 'Enter'; + const isSpace = + event.key === ' ' || + event.key === 'Space' || + event.key === 'Spacebar' || + event.code === 'Space'; + if (!isEnter && !isSpace) { + return; + } + if (event.repeat) { + return; + } + if (isSpace) { + event.preventDefault(); + } + const focused = (event.target as HTMLElement).closest( + 'limel-list-item' + ); + if (!focused) { + return; + } + if ( + (event.target as HTMLElement).closest('.action-menu-trigger') || + (event.target as HTMLElement).closest('limel-menu') || + focused.hasAttribute('disabled') || + focused.getAttribute('aria-disabled') === 'true' + ) { + return; + } + const value = Number((focused as HTMLElement).dataset.value); + const text = focused.getAttribute('text') || ''; + this.toggleValue(value, text); }; private setDisabled = (event: CustomEvent) => { diff --git a/src/components/list-item/examples/list-item-checkbox.tsx b/src/components/list-item/examples/list-item-checkbox.tsx index 4fea7ac51d..a57a3b39ed 100644 --- a/src/components/list-item/examples/list-item-checkbox.tsx +++ b/src/components/list-item/examples/list-item-checkbox.tsx @@ -1,4 +1,3 @@ -import { ListItem } from '@limetech/lime-elements'; import { Component, h, Host, State } from '@stencil/core'; const NOTIFICATION_ICON = { @@ -15,13 +14,14 @@ const NOTIFICATION_ICON = { * Checkboxes allow users to select multiple options from a group. * * :::important - * - The consumer component should set `role="group"` for the `ul` or - * the container of the `limel-list-item`s + * - Apply `role="group"` to the container for accessibility. + * - Selection logic is fully managed by the parent example via click and + * key delegation; each item receives `selected` based on `selectedValues`. * ::: * * :::note - * - The checkboxes are purely visual - the selection logic - * is handled by the parent component through the interact events. + * The checkboxes are presentational only. For production usage prefer + * a container component (`limel-list type="checkbox"`) to centralize state. * ::: */ @Component({ @@ -73,16 +73,21 @@ export class ListItemCheckboxExample { public render() { return ( -
      +
        {this.items.map((item) => ( @@ -108,22 +113,54 @@ export class ListItemCheckboxExample { ); } - private onListItemInteraction = ( - event: CustomEvent<{ selected: boolean; item: ListItem }> - ) => { - const itemValue = event.detail.item.value as number; - const isSelected = event.detail.selected; - - // For checkboxes, toggle the selection state - if (isSelected) { - this.selectedValues = new Set([...this.selectedValues, itemValue]); - } else { + private toggleValue = (value: number, text: string) => { + const selected = this.selectedValues.has(value); + if (selected) { this.selectedValues = new Set( - [...this.selectedValues].filter((id) => id !== itemValue) + [...this.selectedValues].filter((id) => id !== value) ); + this.lastInteraction = `Deselected "${text}"`; + } else { + this.selectedValues = new Set([...this.selectedValues, value]); + this.lastInteraction = `Selected "${text}"`; + } + }; + + private onClick = (event: MouseEvent) => { + const host = (event.target as HTMLElement).closest('limel-list-item'); + if (!host) { + return; } + const value = Number((host as HTMLElement).dataset.value); + const text = host.getAttribute('text') || ''; + this.toggleValue(value, text); + }; - this.lastInteraction = `${isSelected ? 'Selected' : 'Deselected'} "${event.detail.item.text}"`; + private onKeyDown = (event: KeyboardEvent) => { + const isEnter = event.key === 'Enter'; + const isSpace = + event.key === ' ' || + event.key === 'Space' || + event.key === 'Spacebar' || + event.code === 'Space'; + if (!isEnter && !isSpace) { + return; + } + if (event.repeat) { + return; + } + if (isSpace) { + event.preventDefault(); + } + const focused = (event.target as HTMLElement).closest( + 'limel-list-item' + ); + if (!focused) { + return; + } + const value = Number((focused as HTMLElement).dataset.value); + const text = focused.getAttribute('text') || ''; + this.toggleValue(value, text); }; private setBadgeIcon = (event: CustomEvent) => { diff --git a/src/components/list-item/examples/list-item-interactive.tsx b/src/components/list-item/examples/list-item-interactive.tsx index f14799fcff..dd489e89ee 100644 --- a/src/components/list-item/examples/list-item-interactive.tsx +++ b/src/components/list-item/examples/list-item-interactive.tsx @@ -4,13 +4,23 @@ import { Component, h, Host, State } from '@stencil/core'; * Interactive list item example * * A list item with the default type (`type="listitem"`) shows a simpler - * visual feedback when hovered. Once it is clicked, it emits an event - * with details about the item. + * visual feedback when hovered. * - * However, certain item `type`s are "selectable"; - * for instance `option`, `radio` and `checkbox`. + * However, certain item `type`s are "selectable"; for instance `option`, `radio` and `checkbox`. * When users click them (or focus and press Enter or Space) - * these items toggle their selection. + * these items must toggle their selection. + * + * This example demonstrates manual selection handling. Note that + * `limel-list-item` does not emit its own `interact` event. The consumer + * should listen for native `click` events and handle keyboard events. + * + * The component is purely presentational; selection state is passed in + * via the `selected` prop and updated by the parent example using + * native `click` and keyboard events. + * + * Item `type`s that are "selectable" (`option`, `radio`, `checkbox`) + * are expected to be managed by a container component like `limel-list`. + * For standalone demo purposes we toggle `selected` ourselves. * * A `selected` item will both visually indicate that it is selected * and also informs assistive technology about its state, using proper ARIA attributes. @@ -50,7 +60,7 @@ export class ListItemInteractiveExample { public render() { return ( - + { - this.lastEvent = `Item clicked - Selected: ${event.detail.selected}`; - this.selected = event.detail.selected; - console.log('List item interacted:', event.detail); + private toggleSelection = () => { + if (this.disabled) { + return; + } + this.selected = !this.selected; + this.lastEvent = `Item ${this.selected ? 'selected' : 'deselected'}`; + }; + + private onItemClick = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if ( + target.closest('.action-menu-trigger') || + target.closest('limel-menu') + ) { + return; // ignore action menu clicks + } + this.toggleSelection(); + }; + + private onHostKeyDown = (event: KeyboardEvent) => { + if (this.disabled) { + return; + } + const isEnter = event.key === 'Enter'; + const isSpace = + event.key === ' ' || + event.key === 'Space' || + event.key === 'Spacebar' || + event.code === 'Space'; + if (!isEnter && !isSpace) { + return; + } + if (event.repeat) { + return; + } + if (isSpace) { + event.preventDefault(); + } + // Ensure the focused element is our list item before toggling + const active = document.activeElement as HTMLElement | null; + if (active && active.tagName.toLowerCase() === 'limel-list-item') { + this.toggleSelection(); + } }; private setDisabled = (event: CustomEvent) => { diff --git a/src/components/list-item/examples/list-item-radio.tsx b/src/components/list-item/examples/list-item-radio.tsx index c277472de2..69cdc89db8 100644 --- a/src/components/list-item/examples/list-item-radio.tsx +++ b/src/components/list-item/examples/list-item-radio.tsx @@ -1,4 +1,3 @@ -import { ListItem } from '@limetech/lime-elements'; import { Component, h, Host, State } from '@stencil/core'; const NOTIFICATION_ICON = { @@ -15,13 +14,14 @@ const NOTIFICATION_ICON = { * Radio buttons allow users to select only one option from a group. * * :::important - * - The consumer component should set `role="radiogroup"` for the `ul` or - * the container of the `limel-list-item`s + * - Set `role="radiogroup"` on the container for accessibility. + * - Only one value is selected at a time; clicks and Enter/Space update + * `selectedValue` and re-render. * ::: * * :::note - * The radio buttons are purely visual - the selection logic - * is handled by the parent component through the interact events. + * The radio visuals are purely presentational; state comes from the parent. + * In production, prefer using `limel-list type="radio"` to centralize logic. * ::: */ @Component({ @@ -57,10 +57,16 @@ export class ListItemRadioExample { public render() { return ( -
          +
          {this.items.map((item) => ( ))} -
        + - ) => { - const itemValue = event.detail.item.value as number; + private selectValue = (value: number, text: string) => { + this.selectedValue = value; + this.lastInteraction = `Selected "${text}"`; + }; - // For radio buttons, always select the clicked item (even if it was already selected) - this.selectedValue = itemValue; + private onClick = (event: MouseEvent) => { + const host = (event.target as HTMLElement).closest('limel-list-item'); + if (!host) { + return; + } + const value = Number((host as HTMLElement).dataset.value); + const text = host.getAttribute('text') || ''; + this.selectValue(value, text); + }; - this.lastInteraction = `Selected "${event.detail.item.text}"`; + private onKeyDown = (event: KeyboardEvent) => { + const isEnter = event.key === 'Enter'; + const isSpace = + event.key === ' ' || + event.key === 'Space' || + event.key === 'Spacebar' || + event.code === 'Space'; + if (!isEnter && !isSpace) { + return; + } + if (event.repeat) { + return; + } + if (isSpace) { + event.preventDefault(); + } + const focused = (event.target as HTMLElement).closest( + 'limel-list-item' + ); + if (!focused) { + return; + } + const value = Number((focused as HTMLElement).dataset.value); + const text = focused.getAttribute('text') || ''; + this.selectValue(value, text); }; private setBadgeIcon = (event: CustomEvent) => { diff --git a/src/components/list-item/list-item.e2e.ts b/src/components/list-item/list-item.e2e.ts index 9fa198b398..a215687e11 100644 --- a/src/components/list-item/list-item.e2e.ts +++ b/src/components/list-item/list-item.e2e.ts @@ -45,55 +45,30 @@ describe('limel-list-item', () => { expect(checkbox).not.toBeNull(); }); - it('emits interact on click for selectable types', async () => { + it('renders action menu trigger without producing interact event', async () => { const page = await newE2EPage({ - html: '', + html: '', }); const host = await page.find('limel-list-item'); - const spy = await page.spyOnEvent('interact'); - await host.click(); - await page.waitForChanges(); - expect(spy).toHaveReceivedEventTimes(1); - }); - - it('does not emit interact when disabled', async () => { - const page = await newE2EPage({ - html: '', - }); - const host = await page.find('limel-list-item'); - const spy = await page.spyOnEvent('interact'); - await host.click(); - await page.waitForChanges(); - expect(spy).not.toHaveReceivedEvent(); - }); - - it('emits interact on Enter/Space when focused (keyboard activation)', async () => { - const page = await newE2EPage({ - html: '', - }); - const host = await page.find('limel-list-item'); - const spy = await page.spyOnEvent('interact'); - await host.focus(); - await page.keyboard.press('Enter'); + await host.setProperty('actions', [{ text: 'Action', value: 'a' }]); await page.waitForChanges(); - await page.keyboard.press('Space'); + const trigger = await host.find('.action-menu-trigger'); + expect(trigger).not.toBeNull(); + const interactSpy = await page.spyOnEvent('interact'); + await trigger.click(); await page.waitForChanges(); - expect(spy).toHaveReceivedEventTimes(2); + expect(interactSpy).not.toHaveReceivedEvent(); }); - it('does not emit interact when activating the action menu trigger', async () => { + it('reflects aria-selected when type=option and selected prop changes', async () => { const page = await newE2EPage({ - html: ``, + html: '', }); const host = await page.find('limel-list-item'); - const spy = await page.spyOnEvent('interact'); - await host.setProperty('actions', [{ text: 'Action', value: 'a' }]); - await page.waitForChanges(); - const trigger = await host.find('.action-menu-trigger'); - expect(trigger).not.toBeNull(); - await trigger.click(); + expect(host).toEqualAttribute('aria-selected', 'false'); + await host.setProperty('selected', true); await page.waitForChanges(); - expect(spy).not.toHaveReceivedEvent(); + expect(host).toEqualAttribute('aria-selected', 'true'); }); it('renders icon and image when provided (presence checks)', async () => { diff --git a/src/components/list-item/list-item.tsx b/src/components/list-item/list-item.tsx index 7d74f56e4e..fbc4673782 100644 --- a/src/components/list-item/list-item.tsx +++ b/src/components/list-item/list-item.tsx @@ -1,12 +1,4 @@ -import { - Component, - Host, - Prop, - h, - Event, - EventEmitter, - Element, -} from '@stencil/core'; +import { Component, Host, Prop, h } from '@stencil/core'; import { getIconName } from '../icon/get-icon-props'; import type { IconSize } from '../icon/icon.types'; import { createRandomString } from '../../util/random-string'; @@ -143,18 +135,6 @@ export class ListItemComponent implements ListItem { public type: 'listitem' | 'menuitem' | 'option' | 'radio' | 'checkbox' = 'listitem'; - /** - * Emitted when the list item toggles selection (only for selectable types and not disabled). - */ - @Event() - public interact: EventEmitter<{ - selected: boolean; - item: ListItem; - }>; - - @Element() - private host: HTMLLimelListItemElement; - /** * Used to describe the list item for assistive technology. */ @@ -198,8 +178,6 @@ export class ListItemComponent implements ListItem { 'has-primary-component': !!this.primaryComponent?.name, }} {...ariaProps} - onClick={this.onClick} - onKeyDown={this.onKeyDown} > {this.renderRadioButton()} {this.renderCheckbox()} @@ -353,86 +331,6 @@ export class ListItemComponent implements ListItem { ); }; - private onClick = (event: MouseEvent) => { - if (this.disabled) { - // Ignore toggling, but don't block embedded controls - return; - } - - const target = event.target as HTMLElement | null; - const cameFromActionTrigger = !!target?.closest('.action-menu-trigger'); - const cameFromNoToggle = !!target?.closest('[data-no-toggle]'); - const cameFromMenu = !!target?.closest('limel-menu'); - if (cameFromActionTrigger || cameFromNoToggle || cameFromMenu) { - return; - } - - if (this.isSelectableType()) { - this.handleInteraction(); - } - // For non-selectable types (menuitem/listitem), allow native click to bubble - }; - - private onKeyDown = (event: KeyboardEvent) => { - if (this.disabled) { - return; - } - - // Only handle keyboard when the host itself has focus. - // This avoids toggling when Space/Enter is pressed on inner controls - // like the action menu trigger or any primary component. - const shadowRoot = this.host.shadowRoot; - const activeElement = shadowRoot - ? (shadowRoot.activeElement as HTMLElement | null) - : null; - if (activeElement && activeElement !== this.host) { - return; - } - - const isEnter = event.key === 'Enter'; - const isSpace = - event.key === ' ' || - event.key === 'Space' || - event.key === 'Spacebar' || - event.code === 'Space'; - - if (!isEnter && !isSpace) { - return; - } - - // Avoid re-triggering while key is held down and auto-repeats - if (event.repeat) { - // Also prevent default scroll on Space when repeating - if (isSpace) { - event.preventDefault(); - } - return; - } - - // Prevent page scroll and default button behavior on Space - if (isSpace) { - event.preventDefault(); - } - - if (this.isSelectableType()) { - this.handleInteraction(); - return; - } - - // For non-selectable items, treat Enter and Space as activation (simulate click) - if (isEnter || isSpace) { - this.host.click(); - } - }; - - private isSelectableType(): boolean { - return ( - this.type === 'option' || - this.type === 'radio' || - this.type === 'checkbox' - ); - } - private getHostRole(): string { switch (this.type) { case 'option': { @@ -453,27 +351,6 @@ export class ListItemComponent implements ListItem { } } - private handleInteraction = () => { - const newSelected = !this.selected; - - const item: ListItem = { - text: this.text, - secondaryText: this.secondaryText, - disabled: this.disabled, - icon: this.icon, - selected: newSelected, - value: this.value, - actions: this.actions, - primaryComponent: this.primaryComponent, - image: this.image, - }; - - this.interact.emit({ - selected: newSelected, - item: item, - }); - }; - private actionMenuLabel = (): string => { return translate.get('file-viewer.more-actions', this.language); };