Skip to content

Commit d97aa47

Browse files
committed
Merge branch 'develop' into 'fb-utc-523'
Workflow run: https://github.com/HumanSignal/label-studio/actions/runs/21649155580
2 parents 9f049b2 + fd850db commit d97aa47

File tree

6 files changed

+267
-14
lines changed

6 files changed

+267
-14
lines changed

web/libs/editor/src/components/TaskSummary/DataSummary.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useMemo } from "react";
22
import { flexRender, getCoreRowModel, useReactTable, createColumnHelper } from "@tanstack/react-table";
3-
import { cnm, JsonViewer } from "@humansignal/ui";
3+
import { JsonViewer } from "@humansignal/ui";
44
import { Chip } from "./Chip";
55
import { ResizeHandler } from "./ResizeHandler";
66
import type { ObjectTypes } from "./types";

web/libs/editor/src/mixins/Tool.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ const ToolMixin = types
5656
},
5757

5858
get getSelectedShape() {
59-
return self.control.annotation.highlightedNode;
59+
return self.control?.annotation?.highlightedNode;
6060
},
6161

6262
get extraShortcuts() {
@@ -106,7 +106,7 @@ const ToolMixin = types
106106
*/
107107
shouldSkipInteractions(e) {
108108
const isCtrlPressed = e.evt && (e.evt.metaKey || e.evt.ctrlKey);
109-
const hasSelection = self.control.annotation.hasSelection;
109+
const hasSelection = self.control?.annotation?.hasSelection;
110110

111111
return !!isCtrlPressed && !hasSelection;
112112
},

web/libs/ui/src/lib/code-editor/code-editor.module.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@
129129

130130
// Create Project modal has z-index: 10000, so we have to overrule it
131131
z-index: 11000;
132+
max-width: 600px;
132133
}
133134

134135
:global(.CodeMirror-hints .CodeMirror-hint) {

web/libs/ui/src/lib/select/select.module.scss

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,88 @@
8383
[data-radix-popper-content-wrapper] {
8484
min-width: var(--radix-popper-anchor-width) !important;
8585
max-width: var(--radix-popper-available-width);
86-
}
86+
}
87+
88+
// Selected Items Group styles
89+
.selectedItemsGroup {
90+
position: sticky;
91+
top: 0;
92+
z-index: 10;
93+
background: var(--color-primary-background);
94+
border-bottom: 1px solid var(--color-neutral-border);
95+
}
96+
97+
.selectedItemsHeader {
98+
display: flex;
99+
align-items: center;
100+
gap: var(--spacing-tight);
101+
padding: var(--spacing-tight);
102+
cursor: pointer;
103+
104+
// Button reset styles
105+
width: 100%;
106+
border: none;
107+
background: transparent;
108+
text-align: left;
109+
font: inherit;
110+
color: inherit;
111+
112+
&:hover {
113+
background: var(--color-primary-background-hover);
114+
}
115+
116+
&:focus {
117+
outline: 2px solid var(--color-primary-border);
118+
outline-offset: -2px;
119+
}
120+
}
121+
122+
.selectedItemsTitle {
123+
flex: 1;
124+
display: flex;
125+
align-items: center;
126+
gap: var(--spacing-tight);
127+
color: var(--color-primary-content);
128+
}
129+
130+
.selectedItemsCaret {
131+
transition: opacity 150ms ease-out;
132+
}
133+
134+
.selectedItemsContent {
135+
max-height: 160px;
136+
overflow-y: auto;
137+
border-top: 1px solid var(--color-neutral-border-subtler);
138+
}
139+
140+
.selectedItem {
141+
display: flex;
142+
align-items: center;
143+
gap: var(--spacing-tight);
144+
padding: var(--spacing-tight);
145+
padding-left: var(--spacing-widest);
146+
cursor: pointer;
147+
transition: background 150ms ease-out;
148+
149+
// Button reset styles
150+
width: 100%;
151+
border: none;
152+
background: transparent;
153+
text-align: left;
154+
font: inherit;
155+
color: inherit;
156+
157+
&:hover:not(:disabled) {
158+
background: var(--color-primary-background-hover);
159+
}
160+
161+
&:focus {
162+
outline: 2px solid var(--color-primary-border);
163+
outline-offset: -2px;
164+
}
165+
166+
&:disabled {
167+
cursor: not-allowed;
168+
opacity: 0.5;
169+
}
170+
}

web/libs/ui/src/lib/select/select.stories.tsx

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,13 +211,15 @@ export const MultipleWithBadges: Story = {
211211
]}
212212
placeholder="Choose technologies..."
213213
renderSelected={(selectedOptions) => {
214-
if (!selectedOptions || selectedOptions.length === 0) return null;
214+
if (!selectedOptions || selectedOptions?.length === 0) return null;
215215
return (
216216
<BadgeGroup
217-
items={selectedOptions.map((opt: any) => ({
218-
id: opt?.value ?? opt,
219-
label: opt?.label ?? opt?.value ?? opt,
220-
}))}
217+
items={
218+
selectedOptions?.map((opt: any) => ({
219+
id: opt?.value ?? opt,
220+
label: opt?.label ?? opt?.value ?? opt,
221+
})) ?? []
222+
}
221223
variant="info"
222224
shape="squared"
223225
/>
@@ -228,3 +230,34 @@ export const MultipleWithBadges: Story = {
228230
);
229231
},
230232
};
233+
234+
const techOptions = Array.from({ length: 100 }, (_, i) => ({
235+
value: `tech-${i}`,
236+
label: `Technology ${i}`,
237+
}));
238+
239+
/**
240+
* Multiple Select with Virtual List and Search - Base Demo
241+
*
242+
* This story demonstrates the new "Selected Items Group" feature that appears
243+
* at the top of the dropdown when:
244+
* - multiple={true}
245+
* - searchable={true}
246+
* - isVirtualList={true}
247+
* - Items are selected
248+
*
249+
* The group starts collapsed by default. Click the caret to expand and see
250+
* all selected items. Selected items also appear in their normal position
251+
* in the list (dual representation).
252+
*/
253+
export const MultipleSelectWithVirtualListAndSearch: Story = {
254+
args: {
255+
multiple: true,
256+
searchable: true,
257+
isVirtualList: true,
258+
value: ["tech-5", "tech-12", "tech-23", "tech-45", "tech-67"],
259+
options: techOptions as any[],
260+
placeholder: "Select technologies...",
261+
label: "Multiple Select with Selected Items Group",
262+
},
263+
};

web/libs/ui/src/lib/select/select.tsx

Lines changed: 140 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
} from "@humansignal/shad/components/ui/command";
1111
import { Popover, PopoverContent, PopoverTrigger } from "@humansignal/shad/components/ui/popover";
1212
import type { SelectOption, OptionProps, SelectProps } from "./types.ts";
13-
import { Checkbox, Label } from "@humansignal/ui";
13+
import { Checkbox, Label, Typography } from "@humansignal/ui";
14+
import { Badge } from "../badge/badge";
1415
import { isDefined } from "@humansignal/core/lib/utils/helpers";
1516
import { IconChevron, IconChevronDown } from "@humansignal/icons";
1617
import clsx from "clsx";
@@ -22,6 +23,113 @@ import InfiniteLoader from "react-window-infinite-loader";
2223
const VARIABLE_LIST_ITEM_HEIGHT = 40;
2324
const VARIABLE_LIST_COUNT_RENDERED = 5;
2425
const VARIABLE_LIST_PAGE_SIZE = 20;
26+
27+
/**
28+
* Props for SelectedItemsGroup component
29+
*/
30+
type SelectedItemsGroupProps = {
31+
expanded: boolean;
32+
onToggleExpand: () => void;
33+
selectedOptions: any[];
34+
onDeselectItem: (value: any) => void;
35+
onDeselectAll: () => void;
36+
disabled?: boolean;
37+
};
38+
39+
/**
40+
* SelectedItemsGroup - Internal component for displaying selected items in a collapsible group
41+
* Only visible when multiple, searchable, and isVirtualList are all true
42+
*/
43+
const SelectedItemsGroup = ({
44+
expanded,
45+
onToggleExpand,
46+
selectedOptions,
47+
onDeselectItem,
48+
onDeselectAll,
49+
disabled,
50+
}: SelectedItemsGroupProps) => {
51+
const handleItemClick = useCallback(
52+
(option: any) => {
53+
if (disabled) return;
54+
const value = option?.value ?? option;
55+
onDeselectItem(value);
56+
},
57+
[onDeselectItem, disabled],
58+
);
59+
60+
const handleDeselectAllClick = useCallback(
61+
(e: React.MouseEvent) => {
62+
e.stopPropagation();
63+
if (disabled) return;
64+
onDeselectAll();
65+
},
66+
[onDeselectAll, disabled],
67+
);
68+
69+
return (
70+
<div className={styles.selectedItemsGroup}>
71+
{/* Header - Always visible */}
72+
<button
73+
type="button"
74+
className={styles.selectedItemsHeader}
75+
onClick={onToggleExpand}
76+
aria-expanded={expanded}
77+
aria-label={`Selected items group, ${selectedOptions.length} items selected`}
78+
>
79+
{/* Caret icon */}
80+
{expanded ? (
81+
<IconChevron className={styles.selectedItemsCaret} aria-hidden="true" />
82+
) : (
83+
<IconChevronDown className={styles.selectedItemsCaret} aria-hidden="true" />
84+
)}
85+
86+
{/* Deselect all checkbox */}
87+
<Checkbox
88+
tabIndex={-1}
89+
checked={true}
90+
readOnly
91+
disabled={disabled}
92+
onClick={handleDeselectAllClick}
93+
aria-label="Deselect all items"
94+
/>
95+
96+
{/* Title with counter badge */}
97+
<div className={styles.selectedItemsTitle}>
98+
<Typography variant="body">Selected items</Typography>
99+
<Badge variant="info" shape="squared" className="ml-auto">
100+
{selectedOptions.length}
101+
</Badge>
102+
</div>
103+
</button>
104+
105+
{/* Content - Conditionally rendered when expanded */}
106+
{expanded && (
107+
<div className={styles.selectedItemsContent}>
108+
{selectedOptions.map((option, index) => {
109+
const optionValue = option?.value ?? option;
110+
const label = option?.label ?? optionValue;
111+
112+
return (
113+
<button
114+
key={`selected-${optionValue}-${index}`}
115+
type="button"
116+
className={styles.selectedItem}
117+
onClick={() => handleItemClick(option)}
118+
tabIndex={disabled ? -1 : 0}
119+
aria-label={`Deselect ${label}`}
120+
disabled={disabled}
121+
>
122+
<Checkbox tabIndex={-1} checked={true} readOnly disabled={disabled} />
123+
<div className="w-full min-w-0 truncate">{label}</div>
124+
</button>
125+
);
126+
})}
127+
</div>
128+
)}
129+
</div>
130+
);
131+
};
132+
25133
/*
26134
* This file defines a custom Select component for the Design System, which uses a fully custom UI for
27135
* dropdowns and options.
@@ -109,6 +217,7 @@ export const Select = forwardRef(
109217
initialValue = initialValue[0];
110218
}
111219
const [isOpen, setIsOpen] = useState<boolean>(false);
220+
const [selectedGroupExpanded, setSelectedGroupExpanded] = useState<boolean>(false);
112221
const [value, setValue] = useState<any>(initialValue);
113222

114223
valueRef.current = value;
@@ -184,6 +293,13 @@ export const Select = forwardRef(
184293
}, [options]);
185294

186295
const _options = useMemo(() => {
296+
// If searchFilter is provided, always use it (even with empty query)
297+
// This allows custom filtering logic for API-based searches
298+
if (searchFilter) {
299+
return flatOptions.filter((option) => searchFilter(option, query ?? ""));
300+
}
301+
302+
// Default behavior: no filtering when not searchable or query is empty
187303
if (!searchable || !query.trim()) return options;
188304

189305
const filterHandler = (option: any, queryString: string) => {
@@ -194,7 +310,7 @@ export const Select = forwardRef(
194310
value?.toString()?.toLowerCase().includes(queryString.toLowerCase())
195311
);
196312
};
197-
return flatOptions.filter((option) => (searchFilter ?? filterHandler)(option, query));
313+
return flatOptions.filter((option) => filterHandler(option, query));
198314
}, [options, flatOptions, searchable, query, searchFilter]);
199315

200316
const isSelected = useCallback(
@@ -390,11 +506,30 @@ export const Select = forwardRef(
390506
)}
391507
<CommandList
392508
label="Select an option"
393-
className={
394-
searchable ? "shadow-inner shadow-neutral-surface-inset border-t border-neutral-border shadow-" : ""
395-
}
509+
className={cnm({
510+
"shadow-inner shadow-neutral-surface-inset border-t border-neutral-border shadow-": searchable,
511+
"max-h-none": footer !== undefined,
512+
})}
396513
>
514+
{/* Selected Items Group - Only for multiple + searchable + virtual lists */}
515+
{multiple && searchable && isVirtualList && selectedOptions.length > 0 && (
516+
<SelectedItemsGroup
517+
expanded={selectedGroupExpanded}
518+
onToggleExpand={() => setSelectedGroupExpanded(!selectedGroupExpanded)}
519+
selectedOptions={selectedOptions}
520+
onDeselectItem={(value) => _onChange(value, true)}
521+
onDeselectAll={() => {
522+
selectedOptions.forEach((opt) => {
523+
const val = opt?.value ?? opt;
524+
_onChange(val, true);
525+
});
526+
}}
527+
disabled={disabled}
528+
/>
529+
)}
530+
397531
<CommandEmpty>{searchable ? "No results found." : ""}</CommandEmpty>
532+
398533
<CommandGroup>
399534
{props.header ? props.header : null}
400535
{isVirtualList ? (

0 commit comments

Comments
 (0)