Skip to content

Commit 7672994

Browse files
committed
fix(list): prevent double interact event emits, when an item is clicked
1 parent d816178 commit 7672994

File tree

7 files changed

+261
-237
lines changed

7 files changed

+261
-237
lines changed

etc/lime-elements.api.md

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1733,10 +1733,6 @@ export namespace JSX {
17331733
"iconSize"?: IconSize;
17341734
"image"?: ListItem['image'];
17351735
"language"?: Languages;
1736-
"onInteract"?: (event: LimelListItemCustomEvent<{
1737-
selected: boolean;
1738-
item: ListItem;
1739-
}>) => void;
17401736
"primaryComponent"?: ListItem['primaryComponent'];
17411737
"secondaryText"?: string;
17421738
"selected"?: boolean;
@@ -2345,16 +2341,6 @@ export interface LimelListCustomEvent<T> extends CustomEvent<T> {
23452341
target: HTMLLimelListElement;
23462342
}
23472343

2348-
// 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)
2349-
//
2350-
// @public (undocumented)
2351-
export interface LimelListItemCustomEvent<T> extends CustomEvent<T> {
2352-
// (undocumented)
2353-
detail: T;
2354-
// (undocumented)
2355-
target: HTMLLimelListItemElement;
2356-
}
2357-
23582344
// 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)
23592345
//
23602346
// @public (undocumented)

src/components/list-item/examples/list-item-actions.tsx

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ListItem, MenuItem, ListSeparator } from '@limetech/lime-elements';
1+
import { MenuItem, ListSeparator } from '@limetech/lime-elements';
22
import { Component, h, Host, State } from '@stencil/core';
33

44
/**
@@ -15,7 +15,9 @@ import { Component, h, Host, State } from '@stencil/core';
1515
* will eventually extract `event.detail.value` to get the actual `Action`
1616
*
1717
* :::note
18-
* The action menu of the disabled items remains enabled!
18+
* Disabled list items keep their action menu enabled so actions remain
19+
* accessible (e.g., for contextual info). The example guards against
20+
* toggling selection when disabled.
1921
* :::
2022
*/
2123
@Component({
@@ -44,29 +46,29 @@ export class ListItemActionsExample {
4446
public render() {
4547
return (
4648
<Host>
47-
<ul>
49+
<ul onClick={this.onClick} onKeyDown={this.onKeyDown}>
4850
<limel-list-item
4951
tabIndex={0}
52+
data-value={1}
5053
value={1}
5154
type="option"
5255
selected={this.selectedItems.has(1)}
5356
text="King of Tokyo"
5457
secondaryText="A fun dice game for 2-6 players"
5558
icon="gorilla"
5659
actions={this.actionItems}
57-
onInteract={this.onListItemInteraction}
5860
disabled={this.disabled}
5961
/>
6062
<limel-list-item
6163
tabIndex={0}
64+
data-value={2}
6265
value={2}
6366
type="option"
6467
selected={this.selectedItems.has(2)}
6568
text="Pandemic"
6669
secondaryText="Cooperative board game where players work together to save the world"
6770
icon="virus"
6871
actions={this.actionItems}
69-
onInteract={this.onListItemInteraction}
7072
disabled={this.disabled}
7173
/>
7274
</ul>
@@ -85,21 +87,83 @@ export class ListItemActionsExample {
8587
);
8688
}
8789

88-
private onListItemInteraction = (
89-
event: CustomEvent<{ selected: boolean; item: ListItem }>
90-
) => {
91-
const itemValue = event.detail.item.value as number;
92-
const isSelected = event.detail.selected;
93-
94-
if (isSelected) {
95-
this.selectedItems = new Set([...this.selectedItems, itemValue]);
96-
} else {
90+
private toggleValue = (value: number, text: string) => {
91+
const selected = this.selectedItems.has(value);
92+
if (selected) {
9793
this.selectedItems = new Set(
98-
[...this.selectedItems].filter((id) => id !== itemValue)
94+
[...this.selectedItems].filter((id) => id !== value)
9995
);
96+
this.lastInteraction = `Deselected "${text}"`;
97+
} else {
98+
this.selectedItems = new Set([...this.selectedItems, value]);
99+
this.lastInteraction = `Selected "${text}"`;
100100
}
101+
};
101102

102-
this.lastInteraction = `Item "${event.detail.item.text}" interaction: selected=${isSelected}`;
103+
private onClick = (event: MouseEvent) => {
104+
// If the entire example is disabled, ignore item clicks
105+
if (this.disabled) {
106+
return;
107+
}
108+
const host = (event.target as HTMLElement).closest('limel-list-item');
109+
if (!host) {
110+
return;
111+
}
112+
// Skip if clicking the action menu trigger or inside the menu
113+
if (
114+
(event.target as HTMLElement).closest('.action-menu-trigger') ||
115+
(event.target as HTMLElement).closest('limel-menu')
116+
) {
117+
return;
118+
}
119+
// Respect per-item disabled state (attribute reflected by the component)
120+
if (
121+
host.hasAttribute('disabled') ||
122+
host.getAttribute('aria-disabled') === 'true'
123+
) {
124+
return;
125+
}
126+
const value = Number((host as HTMLElement).dataset.value);
127+
const text = host.getAttribute('text') || '';
128+
this.toggleValue(value, text);
129+
};
130+
131+
private onKeyDown = (event: KeyboardEvent) => {
132+
if (this.disabled) {
133+
return;
134+
}
135+
const isEnter = event.key === 'Enter';
136+
const isSpace =
137+
event.key === ' ' ||
138+
event.key === 'Space' ||
139+
event.key === 'Spacebar' ||
140+
event.code === 'Space';
141+
if (!isEnter && !isSpace) {
142+
return;
143+
}
144+
if (event.repeat) {
145+
return;
146+
}
147+
if (isSpace) {
148+
event.preventDefault();
149+
}
150+
const focused = (event.target as HTMLElement).closest(
151+
'limel-list-item'
152+
);
153+
if (!focused) {
154+
return;
155+
}
156+
if (
157+
(event.target as HTMLElement).closest('.action-menu-trigger') ||
158+
(event.target as HTMLElement).closest('limel-menu') ||
159+
focused.hasAttribute('disabled') ||
160+
focused.getAttribute('aria-disabled') === 'true'
161+
) {
162+
return;
163+
}
164+
const value = Number((focused as HTMLElement).dataset.value);
165+
const text = focused.getAttribute('text') || '';
166+
this.toggleValue(value, text);
103167
};
104168

105169
private setDisabled = (event: CustomEvent<boolean>) => {

src/components/list-item/examples/list-item-checkbox.tsx

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { ListItem } from '@limetech/lime-elements';
21
import { Component, h, Host, State } from '@stencil/core';
32

43
const NOTIFICATION_ICON = {
@@ -15,13 +14,14 @@ const NOTIFICATION_ICON = {
1514
* Checkboxes allow users to select multiple options from a group.
1615
*
1716
* :::important
18-
* - The consumer component should set `role="group"` for the `ul` or
19-
* the container of the `limel-list-item`s
17+
* - Apply `role="group"` to the container for accessibility.
18+
* - Selection logic is fully managed by the parent example via click and
19+
* key delegation; each item receives `selected` based on `selectedValues`.
2020
* :::
2121
*
2222
* :::note
23-
* - The checkboxes are purely visual - the selection logic
24-
* is handled by the parent component through the interact events.
23+
* The checkboxes are presentational only. For production usage prefer
24+
* a container component (`limel-list type="checkbox"`) to centralize state.
2525
* :::
2626
*/
2727
@Component({
@@ -73,16 +73,21 @@ export class ListItemCheckboxExample {
7373
public render() {
7474
return (
7575
<Host>
76-
<ul role="group" aria-label="Notification preferences">
76+
<ul
77+
role="group"
78+
aria-label="Notification preferences"
79+
onClick={this.onClick}
80+
onKeyDown={this.onKeyDown}
81+
>
7782
{this.items.map((item) => (
7883
<limel-list-item
7984
key={item.value}
85+
data-value={item.value}
8086
value={item.value}
8187
text={item.text}
8288
secondaryText={item.secondaryText}
8389
type="checkbox"
8490
selected={this.selectedValues.has(item.value)}
85-
onInteract={this.onListItemInteraction}
8691
icon={this.icon}
8792
badgeIcon={this.badgeIcon}
8893
/>
@@ -108,22 +113,54 @@ export class ListItemCheckboxExample {
108113
);
109114
}
110115

111-
private onListItemInteraction = (
112-
event: CustomEvent<{ selected: boolean; item: ListItem }>
113-
) => {
114-
const itemValue = event.detail.item.value as number;
115-
const isSelected = event.detail.selected;
116-
117-
// For checkboxes, toggle the selection state
118-
if (isSelected) {
119-
this.selectedValues = new Set([...this.selectedValues, itemValue]);
120-
} else {
116+
private toggleValue = (value: number, text: string) => {
117+
const selected = this.selectedValues.has(value);
118+
if (selected) {
121119
this.selectedValues = new Set(
122-
[...this.selectedValues].filter((id) => id !== itemValue)
120+
[...this.selectedValues].filter((id) => id !== value)
123121
);
122+
this.lastInteraction = `Deselected "${text}"`;
123+
} else {
124+
this.selectedValues = new Set([...this.selectedValues, value]);
125+
this.lastInteraction = `Selected "${text}"`;
126+
}
127+
};
128+
129+
private onClick = (event: MouseEvent) => {
130+
const host = (event.target as HTMLElement).closest('limel-list-item');
131+
if (!host) {
132+
return;
124133
}
134+
const value = Number((host as HTMLElement).dataset.value);
135+
const text = host.getAttribute('text') || '';
136+
this.toggleValue(value, text);
137+
};
125138

126-
this.lastInteraction = `${isSelected ? 'Selected' : 'Deselected'} "${event.detail.item.text}"`;
139+
private onKeyDown = (event: KeyboardEvent) => {
140+
const isEnter = event.key === 'Enter';
141+
const isSpace =
142+
event.key === ' ' ||
143+
event.key === 'Space' ||
144+
event.key === 'Spacebar' ||
145+
event.code === 'Space';
146+
if (!isEnter && !isSpace) {
147+
return;
148+
}
149+
if (event.repeat) {
150+
return;
151+
}
152+
if (isSpace) {
153+
event.preventDefault();
154+
}
155+
const focused = (event.target as HTMLElement).closest(
156+
'limel-list-item'
157+
);
158+
if (!focused) {
159+
return;
160+
}
161+
const value = Number((focused as HTMLElement).dataset.value);
162+
const text = focused.getAttribute('text') || '';
163+
this.toggleValue(value, text);
127164
};
128165

129166
private setBadgeIcon = (event: CustomEvent<boolean>) => {

src/components/list-item/examples/list-item-interactive.tsx

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,23 @@ import { Component, h, Host, State } from '@stencil/core';
44
* Interactive list item example
55
*
66
* A list item with the default type (`type="listitem"`) shows a simpler
7-
* visual feedback when hovered. Once it is clicked, it emits an event
8-
* with details about the item.
7+
* visual feedback when hovered.
98
*
10-
* However, certain item `type`s are "selectable";
11-
* for instance `option`, `radio` and `checkbox`.
9+
* However, certain item `type`s are "selectable"; for instance `option`, `radio` and `checkbox`.
1210
* When users click them (or focus and press <kbd>Enter</kbd> or <kbd>Space</kbd>)
13-
* these items toggle their selection.
11+
* these items must toggle their selection.
12+
*
13+
* This example demonstrates manual selection handling. Note that
14+
* `limel-list-item` does not emit its own `interact` event. The consumer
15+
* should listen for native `click` events and handle keyboard events.
16+
*
17+
* The component is purely presentational; selection state is passed in
18+
* via the `selected` prop and updated by the parent example using
19+
* native `click` and keyboard events.
20+
*
21+
* Item `type`s that are "selectable" (`option`, `radio`, `checkbox`)
22+
* are expected to be managed by a container component like `limel-list`.
23+
* For standalone demo purposes we toggle `selected` ourselves.
1424
*
1525
* A `selected` item will both visually indicate that it is selected
1626
* and also informs assistive technology about its state, using proper ARIA attributes.
@@ -50,7 +60,7 @@ export class ListItemInteractiveExample {
5060

5161
public render() {
5262
return (
53-
<Host>
63+
<Host onKeyDown={this.onHostKeyDown}>
5464
<limel-list-item
5565
tabindex={0}
5666
text="Interactive List Item, with `type='option'`"
@@ -59,7 +69,7 @@ export class ListItemInteractiveExample {
5969
disabled={this.disabled}
6070
selected={this.selected}
6171
type="option"
62-
onInteract={this.onInteract}
72+
onClick={this.onItemClick}
6373
/>
6474
<limel-example-controls>
6575
<limel-switch
@@ -81,10 +91,49 @@ export class ListItemInteractiveExample {
8191
);
8292
}
8393

84-
private onInteract = (event: CustomEvent) => {
85-
this.lastEvent = `Item clicked - Selected: ${event.detail.selected}`;
86-
this.selected = event.detail.selected;
87-
console.log('List item interacted:', event.detail);
94+
private toggleSelection = () => {
95+
if (this.disabled) {
96+
return;
97+
}
98+
this.selected = !this.selected;
99+
this.lastEvent = `Item ${this.selected ? 'selected' : 'deselected'}`;
100+
};
101+
102+
private onItemClick = (event: MouseEvent) => {
103+
const target = event.target as HTMLElement;
104+
if (
105+
target.closest('.action-menu-trigger') ||
106+
target.closest('limel-menu')
107+
) {
108+
return; // ignore action menu clicks
109+
}
110+
this.toggleSelection();
111+
};
112+
113+
private onHostKeyDown = (event: KeyboardEvent) => {
114+
if (this.disabled) {
115+
return;
116+
}
117+
const isEnter = event.key === 'Enter';
118+
const isSpace =
119+
event.key === ' ' ||
120+
event.key === 'Space' ||
121+
event.key === 'Spacebar' ||
122+
event.code === 'Space';
123+
if (!isEnter && !isSpace) {
124+
return;
125+
}
126+
if (event.repeat) {
127+
return;
128+
}
129+
if (isSpace) {
130+
event.preventDefault();
131+
}
132+
// Ensure the focused element is our list item before toggling
133+
const active = document.activeElement as HTMLElement | null;
134+
if (active && active.tagName.toLowerCase() === 'limel-list-item') {
135+
this.toggleSelection();
136+
}
88137
};
89138

90139
private setDisabled = (event: CustomEvent<boolean>) => {

0 commit comments

Comments
 (0)