Skip to content

Commit db7c25c

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

File tree

5 files changed

+223
-126
lines changed

5 files changed

+223
-126
lines changed

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

Lines changed: 77 additions & 15 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
/**
@@ -44,29 +44,29 @@ export class ListItemActionsExample {
4444
public render() {
4545
return (
4646
<Host>
47-
<ul>
47+
<ul onClick={this.onClick} onKeyDown={this.onKeyDown}>
4848
<limel-list-item
4949
tabIndex={0}
50+
data-value={1}
5051
value={1}
5152
type="option"
5253
selected={this.selectedItems.has(1)}
5354
text="King of Tokyo"
5455
secondaryText="A fun dice game for 2-6 players"
5556
icon="gorilla"
5657
actions={this.actionItems}
57-
onInteract={this.onListItemInteraction}
5858
disabled={this.disabled}
5959
/>
6060
<limel-list-item
6161
tabIndex={0}
62+
data-value={2}
6263
value={2}
6364
type="option"
6465
selected={this.selectedItems.has(2)}
6566
text="Pandemic"
6667
secondaryText="Cooperative board game where players work together to save the world"
6768
icon="virus"
6869
actions={this.actionItems}
69-
onInteract={this.onListItemInteraction}
7070
disabled={this.disabled}
7171
/>
7272
</ul>
@@ -85,21 +85,83 @@ export class ListItemActionsExample {
8585
);
8686
}
8787

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 {
88+
private toggleValue = (value: number, text: string) => {
89+
const selected = this.selectedItems.has(value);
90+
if (selected) {
9791
this.selectedItems = new Set(
98-
[...this.selectedItems].filter((id) => id !== itemValue)
92+
[...this.selectedItems].filter((id) => id !== value)
9993
);
94+
this.lastInteraction = `Deselected "${text}"`;
95+
} else {
96+
this.selectedItems = new Set([...this.selectedItems, value]);
97+
this.lastInteraction = `Selected "${text}"`;
10098
}
99+
};
101100

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

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

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

Lines changed: 51 additions & 15 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 = {
@@ -73,16 +72,21 @@ export class ListItemCheckboxExample {
7372
public render() {
7473
return (
7574
<Host>
76-
<ul role="group" aria-label="Notification preferences">
75+
<ul
76+
role="group"
77+
aria-label="Notification preferences"
78+
onClick={this.onClick}
79+
onKeyDown={this.onKeyDown}
80+
>
7781
{this.items.map((item) => (
7882
<limel-list-item
7983
key={item.value}
84+
data-value={item.value}
8085
value={item.value}
8186
text={item.text}
8287
secondaryText={item.secondaryText}
8388
type="checkbox"
8489
selected={this.selectedValues.has(item.value)}
85-
onInteract={this.onListItemInteraction}
8690
icon={this.icon}
8791
badgeIcon={this.badgeIcon}
8892
/>
@@ -108,22 +112,54 @@ export class ListItemCheckboxExample {
108112
);
109113
}
110114

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 {
115+
private toggleValue = (value: number, text: string) => {
116+
const selected = this.selectedValues.has(value);
117+
if (selected) {
121118
this.selectedValues = new Set(
122-
[...this.selectedValues].filter((id) => id !== itemValue)
119+
[...this.selectedValues].filter((id) => id !== value)
123120
);
121+
this.lastInteraction = `Deselected "${text}"`;
122+
} else {
123+
this.selectedValues = new Set([...this.selectedValues, value]);
124+
this.lastInteraction = `Selected "${text}"`;
125+
}
126+
};
127+
128+
private onClick = (event: MouseEvent) => {
129+
const host = (event.target as HTMLElement).closest('limel-list-item');
130+
if (!host) {
131+
return;
124132
}
133+
const value = Number((host as HTMLElement).dataset.value);
134+
const text = host.getAttribute('text') || '';
135+
this.toggleValue(value, text);
136+
};
125137

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

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

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

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export class ListItemInteractiveExample {
5050

5151
public render() {
5252
return (
53-
<Host>
53+
<Host onKeyDown={this.onHostKeyDown}>
5454
<limel-list-item
5555
tabindex={0}
5656
text="Interactive List Item, with `type='option'`"
@@ -59,7 +59,7 @@ export class ListItemInteractiveExample {
5959
disabled={this.disabled}
6060
selected={this.selected}
6161
type="option"
62-
onInteract={this.onInteract}
62+
onClick={this.onItemClick}
6363
/>
6464
<limel-example-controls>
6565
<limel-switch
@@ -81,10 +81,49 @@ export class ListItemInteractiveExample {
8181
);
8282
}
8383

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);
84+
private toggleSelection = () => {
85+
if (this.disabled) {
86+
return;
87+
}
88+
this.selected = !this.selected;
89+
this.lastEvent = `Item ${this.selected ? 'selected' : 'deselected'}`;
90+
};
91+
92+
private onItemClick = (event: MouseEvent) => {
93+
const target = event.target as HTMLElement;
94+
if (
95+
target.closest('.action-menu-trigger') ||
96+
target.closest('limel-menu')
97+
) {
98+
return; // ignore action menu clicks
99+
}
100+
this.toggleSelection();
101+
};
102+
103+
private onHostKeyDown = (event: KeyboardEvent) => {
104+
if (this.disabled) {
105+
return;
106+
}
107+
const isEnter = event.key === 'Enter';
108+
const isSpace =
109+
event.key === ' ' ||
110+
event.key === 'Space' ||
111+
event.key === 'Spacebar' ||
112+
event.code === 'Space';
113+
if (!isEnter && !isSpace) {
114+
return;
115+
}
116+
if (event.repeat) {
117+
return;
118+
}
119+
if (isSpace) {
120+
event.preventDefault();
121+
}
122+
// Ensure the focused element is our list item before toggling
123+
const active = document.activeElement as HTMLElement | null;
124+
if (active && active.tagName.toLowerCase() === 'limel-list-item') {
125+
this.toggleSelection();
126+
}
88127
};
89128

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

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

Lines changed: 45 additions & 10 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 = {
@@ -57,10 +56,16 @@ export class ListItemRadioExample {
5756
public render() {
5857
return (
5958
<Host>
60-
<ul role="radiogroup" aria-label="Package size">
59+
<ul
60+
role="radiogroup"
61+
aria-label="Package size"
62+
onClick={this.onClick}
63+
onKeyDown={this.onKeyDown}
64+
>
6165
{this.items.map((item) => (
6266
<limel-list-item
6367
key={item.value}
68+
data-value={item.value}
6469
value={item.value}
6570
text={item.text}
6671
secondaryText={item.secondaryText}
@@ -69,7 +74,6 @@ export class ListItemRadioExample {
6974
tabindex={
7075
this.selectedValue === item.value ? 0 : -1
7176
}
72-
onInteract={this.onListItemInteraction}
7377
icon={this.icon}
7478
badgeIcon={this.badgeIcon}
7579
/>
@@ -95,15 +99,46 @@ export class ListItemRadioExample {
9599
);
96100
}
97101

98-
private onListItemInteraction = (
99-
event: CustomEvent<{ selected: boolean; item: ListItem }>
100-
) => {
101-
const itemValue = event.detail.item.value as number;
102+
private selectValue = (value: number, text: string) => {
103+
this.selectedValue = value;
104+
this.lastInteraction = `Selected "${text}"`;
105+
};
102106

103-
// For radio buttons, always select the clicked item (even if it was already selected)
104-
this.selectedValue = itemValue;
107+
private onClick = (event: MouseEvent) => {
108+
const host = (event.target as HTMLElement).closest('limel-list-item');
109+
if (!host) {
110+
return;
111+
}
112+
const value = Number((host as HTMLElement).dataset.value);
113+
const text = host.getAttribute('text') || '';
114+
this.selectValue(value, text);
115+
};
105116

106-
this.lastInteraction = `Selected "${event.detail.item.text}"`;
117+
private onKeyDown = (event: KeyboardEvent) => {
118+
const isEnter = event.key === 'Enter';
119+
const isSpace =
120+
event.key === ' ' ||
121+
event.key === 'Space' ||
122+
event.key === 'Spacebar' ||
123+
event.code === 'Space';
124+
if (!isEnter && !isSpace) {
125+
return;
126+
}
127+
if (event.repeat) {
128+
return;
129+
}
130+
if (isSpace) {
131+
event.preventDefault();
132+
}
133+
const focused = (event.target as HTMLElement).closest(
134+
'limel-list-item'
135+
);
136+
if (!focused) {
137+
return;
138+
}
139+
const value = Number((focused as HTMLElement).dataset.value);
140+
const text = focused.getAttribute('text') || '';
141+
this.selectValue(value, text);
107142
};
108143

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

0 commit comments

Comments
 (0)