Skip to content

Commit fbd16c4

Browse files
committed
Add label UIs
1 parent deba4fe commit fbd16c4

18 files changed

+878
-15
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright (C) 2025 Intel Corporation
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { Content, Dialog } from '@geti/ui';
5+
import { Label as LabelType } from 'src/constants/shared-types';
6+
7+
import { CreateLabelForm } from './create-label-form.component';
8+
9+
interface AddLabelDialogProps {
10+
closeDialog: () => void;
11+
existingLabels: LabelType[];
12+
}
13+
14+
export const AddLabelDialog = ({ existingLabels, closeDialog }: AddLabelDialogProps) => {
15+
return (
16+
<Dialog>
17+
<Content>
18+
<CreateLabelForm onClose={closeDialog} existingLabels={existingLabels} />
19+
</Content>
20+
</Dialog>
21+
);
22+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (C) 2025 Intel Corporation
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { Button, DialogTrigger } from '@geti/ui';
5+
import { Label as LabelType } from 'src/constants/shared-types';
6+
7+
import { AddLabelDialog } from './add-label-dialog.component';
8+
9+
import classes from './add-label.module.scss';
10+
11+
interface AddLabelProps {
12+
existingLabels: LabelType[];
13+
}
14+
15+
export const AddLabel = ({ existingLabels }: AddLabelProps) => {
16+
return (
17+
<DialogTrigger type={'popover'} hideArrow placement={'bottom right'}>
18+
<Button variant={'secondary'} UNSAFE_className={classes.addLabelButton}>
19+
Add label
20+
</Button>
21+
{(close) => <AddLabelDialog closeDialog={close} existingLabels={existingLabels} />}
22+
</DialogTrigger>
23+
);
24+
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.addLabelButton {
2+
border: none;
3+
min-width: max-content;
4+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright (C) 2025 Intel Corporation
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { useMemo } from 'react';
5+
6+
import { Flex } from '@geti/ui';
7+
import { Add } from '@geti/ui/icons';
8+
import { getDistinctColorBasedOnHash } from '@geti/ui/utils';
9+
import { useProjectIdentifier } from 'hooks/use-project-identifier.hook';
10+
import { Label as LabelType } from 'src/constants/shared-types';
11+
import { useAnnotator } from 'src/shared/annotator/annotator-provider.component';
12+
import { v4 as uuid } from 'uuid';
13+
14+
import { useUpdateLabel } from '../api/use-update-label';
15+
import { Label } from '../label/label.component';
16+
17+
const getDefaultLabel = (): LabelType => {
18+
const id = uuid();
19+
20+
return {
21+
id,
22+
name: '',
23+
color: getDistinctColorBasedOnHash(id),
24+
};
25+
};
26+
27+
interface CreateLabelProps {
28+
onCreateLabel: (newLabel: LabelType) => void;
29+
onClose: () => void;
30+
existingLabels: LabelType[];
31+
isDisabled?: boolean;
32+
}
33+
34+
const CreateLabel = ({ onCreateLabel, onClose, existingLabels, isDisabled }: CreateLabelProps) => {
35+
const defaultLabel = useMemo(() => getDefaultLabel(), []);
36+
37+
return (
38+
<Label label={defaultLabel} existingLabels={existingLabels}>
39+
<Label.Form onSubmit={onCreateLabel}>
40+
<Flex marginTop={0} gap={'size-50'} justifyContent={'center'}>
41+
<Label.ColorPicker />
42+
43+
<Label.NameField onClose={onClose} ariaLabel={'New label name'} />
44+
45+
<Label.Button isDisabled={isDisabled} color={'var(--spectrum-global-color-gray-200)'}>
46+
<Add />
47+
</Label.Button>
48+
</Flex>
49+
</Label.Form>
50+
</Label>
51+
);
52+
};
53+
54+
interface CreateLabelFormProps {
55+
onClose: () => void;
56+
onSuccess?: (label: LabelType) => void;
57+
existingLabels?: LabelType[];
58+
}
59+
60+
export const CreateLabelForm = ({ onClose, existingLabels = [], onSuccess }: CreateLabelFormProps) => {
61+
const projectId = useProjectIdentifier();
62+
63+
const { selectedLabelId, setSelectedLabelId } = useAnnotator();
64+
65+
const createLabelMutation = useUpdateLabel();
66+
67+
const addLabel = (label: LabelType) => {
68+
createLabelMutation.mutate(
69+
{
70+
body: {
71+
labels_to_add: [
72+
{
73+
id: label.id,
74+
name: label.name,
75+
color: label.color,
76+
},
77+
],
78+
},
79+
params: {
80+
path: {
81+
project_id: projectId,
82+
},
83+
},
84+
},
85+
{
86+
onSuccess: async () => {
87+
if (selectedLabelId === null) {
88+
setSelectedLabelId(label.id);
89+
}
90+
91+
onSuccess?.(label);
92+
93+
onClose();
94+
},
95+
}
96+
);
97+
};
98+
99+
return (
100+
<CreateLabel
101+
existingLabels={existingLabels}
102+
onCreateLabel={addLabel}
103+
onClose={onClose}
104+
isDisabled={createLabelMutation.isPending}
105+
/>
106+
);
107+
};
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Copyright (C) 2025 Intel Corporation
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { $api } from 'src/api/client';
5+
6+
export const useUpdateLabel = () => {
7+
return $api.useMutation('patch', '/api/projects/{project_id}/labels');
8+
};
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Copyright (C) 2025 Intel Corporation
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { RefObject } from 'react';
5+
6+
import { ActionButton, Flex, View } from '@geti/ui';
7+
import { Close } from '@geti/ui/icons';
8+
import { ThemeProvider } from '@geti/ui/theme';
9+
import { Popover } from 'react-aria-components';
10+
11+
import { Label } from '../../../constants/shared-types';
12+
import { Point } from '../types';
13+
import { CreateLabelForm } from './add-label/create-label-form.component';
14+
15+
interface CreateLabelPopoverProps {
16+
onSuccess: (label: Label) => void;
17+
existingLabels: Label[];
18+
mousePosition: Point | null;
19+
onClose: () => void;
20+
ref: RefObject<SVGSVGElement | null>;
21+
}
22+
23+
export const CreateLabelPopover = ({
24+
onSuccess,
25+
existingLabels,
26+
onClose,
27+
mousePosition,
28+
ref,
29+
}: CreateLabelPopoverProps) => {
30+
if (mousePosition === null) return null;
31+
32+
return (
33+
<Popover
34+
isOpen
35+
offset={mousePosition.y}
36+
crossOffset={mousePosition.x}
37+
placement={'bottom start'}
38+
onOpenChange={onClose}
39+
triggerRef={ref}
40+
style={{
41+
transform: 'translate(-50%, -50%)',
42+
}}
43+
>
44+
<ThemeProvider>
45+
<View
46+
backgroundColor={'gray-50'}
47+
padding={'size-200'}
48+
height={'100%'}
49+
borderRadius={'regular'}
50+
borderWidth={'thin'}
51+
borderColor={'gray-400'}
52+
>
53+
<Flex justifyContent={'space-between'} gap={'size-100'}>
54+
<CreateLabelForm onClose={onClose} onSuccess={onSuccess} existingLabels={existingLabels} />
55+
<ActionButton isQuiet onPress={onClose}>
56+
<Close />
57+
</ActionButton>
58+
</Flex>
59+
</View>
60+
</ThemeProvider>
61+
</Popover>
62+
);
63+
};
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (C) 2025 Intel Corporation
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { useRef, useState } from 'react';
5+
6+
import { DimensionValue, DOMRefValue, Flex, useUnwrapDOMRef } from '@geti/ui';
7+
import { Checkmark } from '@geti/ui/icons';
8+
import { Label as LabelType } from 'src/constants/shared-types';
9+
import { useOnOutsideClick } from 'src/shared/hooks/use-on-click-outside.hook';
10+
11+
import { Label } from '../label/label.component';
12+
13+
import classes from './edit-label.module.scss';
14+
15+
interface EditLabelProps {
16+
label: LabelType;
17+
onAccept: (editedLabel: LabelType) => void;
18+
onClose: () => void;
19+
width?: DimensionValue;
20+
isDisabled?: boolean;
21+
existingLabels: LabelType[];
22+
shouldCloseOnOutsideClick?: boolean;
23+
}
24+
25+
const useCloseLabelEditionOnOutsideClick = ({
26+
onClose,
27+
shouldCloseOnOutsideClick,
28+
isColorPickerOpen,
29+
}: {
30+
onClose: () => void;
31+
shouldCloseOnOutsideClick: boolean;
32+
isColorPickerOpen: boolean;
33+
}) => {
34+
const wrappedFormRef = useRef<DOMRefValue<HTMLFormElement>>(null);
35+
const formRef = useUnwrapDOMRef(wrappedFormRef);
36+
37+
useOnOutsideClick(formRef, () => {
38+
if (isColorPickerOpen) {
39+
return;
40+
}
41+
42+
onClose();
43+
});
44+
45+
return shouldCloseOnOutsideClick ? wrappedFormRef : null;
46+
};
47+
48+
export const EditLabel = ({
49+
label,
50+
onAccept,
51+
onClose,
52+
width,
53+
isDisabled,
54+
existingLabels,
55+
shouldCloseOnOutsideClick = false,
56+
}: EditLabelProps) => {
57+
const [isColorPickerOpen, setIsColorPickerOpen] = useState<boolean>(false);
58+
59+
const formRef = useCloseLabelEditionOnOutsideClick({ onClose, shouldCloseOnOutsideClick, isColorPickerOpen });
60+
61+
return (
62+
<Label label={label} existingLabels={existingLabels}>
63+
<Label.Form onSubmit={onAccept} ref={formRef}>
64+
<Flex
65+
marginTop={0}
66+
gap={'size-50'}
67+
width={width}
68+
justifyContent={'center'}
69+
UNSAFE_className={classes.editLabelContainer}
70+
>
71+
<Label.ColorPicker onOpenChange={setIsColorPickerOpen} />
72+
73+
<Label.NameField isQuiet onClose={onClose} ariaLabel={'Edit label name'} />
74+
75+
<Label.Button isDisabled={isDisabled} color={'var(--energy-blue)'}>
76+
<Checkmark />
77+
</Label.Button>
78+
</Flex>
79+
</Label.Form>
80+
</Label>
81+
);
82+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.editLabelContainer {
2+
border: 1px solid var(--spectrum-global-color-gray-300);
3+
border-radius: var(--spectrum-alias-border-radius-regular);
4+
background-color: var(--spectrum-global-color-gray-100);
5+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright (C) 2025 Intel Corporation
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { CSSProperties, ReactNode } from 'react';
5+
6+
import { Text } from '@geti/ui';
7+
import { clsx } from 'clsx';
8+
import { usePress } from 'react-aria';
9+
import { Label } from 'src/constants/shared-types';
10+
11+
import classes from './label-badge.module.scss';
12+
13+
interface LabelIndicatorProps {
14+
label: Label;
15+
isSelected: boolean;
16+
onClick: () => void;
17+
children: ReactNode;
18+
}
19+
20+
export const LabelBadge = ({ label, isSelected, onClick, children: actionButtons }: LabelIndicatorProps) => {
21+
const { pressProps } = usePress({
22+
onPress: onClick,
23+
});
24+
25+
return (
26+
<div
27+
{...pressProps}
28+
style={{ '--labelBgColor': label.color } as CSSProperties}
29+
className={clsx(classes.badge, { [classes.selected]: isSelected })}
30+
aria-selected={isSelected}
31+
aria-label={`Label ${label.name}`}
32+
>
33+
<Text UNSAFE_className={classes.buttonText}>{label.name}</Text>
34+
{actionButtons}
35+
</div>
36+
);
37+
};

0 commit comments

Comments
 (0)