Skip to content

Commit 6970f81

Browse files
authored
Allow text selection of multi-select pills (#1171)
* Allow text selection of multi-select pills * Fix * Update
1 parent 35f29a3 commit 6970f81

File tree

2 files changed

+140
-4
lines changed

2 files changed

+140
-4
lines changed

packages/cells/src/cells/multi-select-cell.tsx

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
} from "@glideapps/glide-data-grid";
1515

1616
import { styled } from "@linaria/react";
17-
import Select, { type MenuProps, components, type StylesConfig } from "react-select";
17+
import Select, { type MenuProps, type MultiValueGenericProps, components, type StylesConfig } from "react-select";
1818
import CreatableSelect from "react-select/creatable";
1919

2020
type SelectOption = { value: string; label?: string; color?: string };
@@ -128,6 +128,48 @@ const CustomMenu: React.FC<CustomMenuProps> = p => {
128128
return <Menu {...rest}>{children}</Menu>;
129129
};
130130

131+
/**
132+
* Custom MultiValueLabel component that allows text selection within pills.
133+
* By default, react-select prevents text selection via onMouseDown preventDefault.
134+
* We override this to allow users to select and copy text from the pills.
135+
*
136+
* Side effects:
137+
* - Clicking on the pill label text won't focus the select input (click elsewhere to focus)
138+
* - Clicking on the pill label text won't open the dropdown menu (click input area to open)
139+
* - Removing pills via the X button still works normally (separate component)
140+
* - Keyboard navigation still works normally
141+
*
142+
* Note on type assertions: react-select's MultiValueGenericProps.innerProps type is
143+
* { className?: string }, but the underlying div element accepts all standard div props.
144+
* The type assertion to React.ComponentPropsWithoutRef<"div"> is necessary to add
145+
* event handlers that the actual DOM element supports.
146+
*/
147+
const SelectableMultiValueLabel: React.FC<MultiValueGenericProps<SelectOption>> = props => {
148+
// Cast innerProps to the full div props type since react-select's types are overly restrictive
149+
// (they only type { className?: string } but the div accepts all standard props)
150+
const existingInnerProps = props.innerProps as React.ComponentPropsWithoutRef<"div"> | undefined;
151+
152+
const enhancedInnerProps: React.ComponentPropsWithoutRef<"div"> = {
153+
...existingInnerProps,
154+
// Allow text selection by stopping propagation but not preventing default
155+
onMouseDown: (e: React.MouseEvent<HTMLDivElement>) => {
156+
e.stopPropagation(); // Prevents react-select from treating it as a control click
157+
existingInnerProps?.onMouseDown?.(e);
158+
},
159+
onTouchEnd: (e: React.TouchEvent<HTMLDivElement>) => {
160+
e.stopPropagation();
161+
existingInnerProps?.onTouchEnd?.(e);
162+
},
163+
};
164+
165+
return (
166+
<components.MultiValueLabel
167+
{...props}
168+
innerProps={enhancedInnerProps as typeof props.innerProps}
169+
/>
170+
);
171+
};
172+
131173
export type MultiSelectCell = CustomCell<MultiSelectCellProps>;
132174

133175
const Editor: ReturnType<ProvideEditorCallback<MultiSelectCell>> = p => {
@@ -367,6 +409,7 @@ const Editor: ReturnType<ProvideEditorCallback<MultiSelectCell>> = p => {
367409
components={{
368410
DropdownIndicator: () => null,
369411
IndicatorSeparator: () => null,
412+
MultiValueLabel: SelectableMultiValueLabel,
370413
Menu: props => {
371414
if (menuDisabled) {
372415
return null;
@@ -379,10 +422,10 @@ const Editor: ReturnType<ProvideEditorCallback<MultiSelectCell>> = p => {
379422
},
380423
}}
381424
onChange={async e => {
382-
if (e === null) {
425+
if (e === null || !Array.isArray(e)) {
383426
return;
384427
}
385-
submitValues(e.map(x => x.value));
428+
submitValues(e.map((x: SelectOption) => x.value));
386429
}}
387430
/>
388431
</Wrap>

packages/cells/test/multi-select-cell.test.tsx

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ describe("Multi Select Editor", () => {
270270
const Editor = renderer.provideEditor?.({
271271
...getMockCell(),
272272
location: [0, 0],
273-
}).editor;
273+
}).editor;
274274
if (Editor === undefined) {
275275
throw new Error("Editor is invalid");
276276
}
@@ -377,4 +377,97 @@ describe("Multi Select Editor", () => {
377377
});
378378

379379
// TODO: Add test for creating new options
380+
381+
it("allows text selection in pill labels (onMouseDown does not prevent default)", async () => {
382+
const mockCell = getMockCell({
383+
data: {
384+
kind: "multi-select-cell",
385+
options: [
386+
{ value: "option1", label: "Option 1", color: "red" },
387+
{ value: "option2", label: "Option 2", color: "blue" },
388+
],
389+
values: ["option1", "option2"],
390+
},
391+
});
392+
// @ts-ignore
393+
const Editor = renderer.provideEditor?.({
394+
...mockCell,
395+
location: [0, 0],
396+
}).editor;
397+
if (Editor === undefined) {
398+
throw new Error("Editor is invalid");
399+
}
400+
401+
const mockCellOnChange = vi.fn();
402+
const result = render(<Editor isHighlighted={false} value={mockCell} onChange={mockCellOnChange} />);
403+
const cellEditor = result.getByTestId("multi-select-cell");
404+
405+
// Find the pill labels (MultiValueLabel components render with the label text)
406+
const pillLabel = getByText(cellEditor, "Option 1");
407+
expect(pillLabel).toBeDefined();
408+
409+
// Simulate mousedown on the pill label - it should not prevent default (allowing text selection)
410+
// We verify this by checking that the event's defaultPrevented is false after the handler runs
411+
const mouseDownEvent = new MouseEvent("mousedown", {
412+
bubbles: true,
413+
cancelable: true,
414+
});
415+
416+
// The event should not be prevented (allowing text selection)
417+
pillLabel.dispatchEvent(mouseDownEvent);
418+
expect(mouseDownEvent.defaultPrevented).toBe(false);
419+
420+
// The onChange should NOT have been called just from clicking the label
421+
// (stopPropagation prevents the control from receiving the click)
422+
expect(mockCellOnChange).not.toHaveBeenCalled();
423+
});
424+
425+
it("still allows removing pills via the remove button after text selection enhancement", async () => {
426+
const mockCell = getMockCell({
427+
data: {
428+
kind: "multi-select-cell",
429+
options: [
430+
{ value: "option1", label: "Option 1", color: "red" },
431+
{ value: "option2", label: "Option 2", color: "blue" },
432+
],
433+
values: ["option1"],
434+
},
435+
});
436+
// @ts-ignore
437+
const Editor = renderer.provideEditor?.({
438+
...mockCell,
439+
location: [0, 0],
440+
}).editor;
441+
if (Editor === undefined) {
442+
throw new Error("Editor is invalid");
443+
}
444+
445+
const mockCellOnChange = vi.fn();
446+
const result = render(<Editor isHighlighted={false} value={mockCell} onChange={mockCellOnChange} />);
447+
const cellEditor = result.getByTestId("multi-select-cell");
448+
449+
// Find the pill label first
450+
const pillLabel = getByText(cellEditor, "Option 1");
451+
expect(pillLabel).toBeDefined();
452+
453+
// The remove button is a sibling of the label within the multi-value container
454+
// react-select renders: <div class="...multi-value"><div class="...label">text</div><div class="...remove">X</div></div>
455+
const multiValueContainer = pillLabel.parentElement;
456+
expect(multiValueContainer).not.toBeNull();
457+
458+
// Find the remove button (it's the element with the SVG/X icon, typically the last child or has a specific role)
459+
// react-select's remove button contains an SVG with a path
460+
const removeButton = multiValueContainer?.querySelector("svg")?.parentElement;
461+
expect(removeButton).not.toBeNull();
462+
463+
// Click the remove button
464+
fireEvent.click(removeButton!);
465+
466+
// The onChange should have been called to remove the value
467+
expect(mockCellOnChange).toHaveBeenCalledTimes(1);
468+
expect(mockCellOnChange).toHaveBeenCalledWith({
469+
...mockCell,
470+
data: { ...mockCell.data, values: [] },
471+
});
472+
});
380473
});

0 commit comments

Comments
 (0)