Skip to content

Commit 5ba4082

Browse files
Merge pull request #1 from invoke-ai/feat/components/custom-select
feat: custom select, build fanagling
2 parents 34330dc + 570df03 commit 5ba4082

File tree

13 files changed

+1330
-264
lines changed

13 files changed

+1330
-264
lines changed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import { useCallback, useState } from 'react';
3+
4+
import { FormControl, FormLabel } from '..';
5+
import type { Item } from './custom-select';
6+
import { CustomSelect } from './custom-select';
7+
8+
const meta: Meta<typeof CustomSelect> = {
9+
title: 'Primitives/CustomSelect',
10+
tags: ['autodocs'],
11+
component: CustomSelect,
12+
};
13+
14+
export default meta;
15+
type Story = StoryObj<typeof CustomSelect>;
16+
17+
const dessertItems: Item[] = [
18+
{
19+
group: 'SE Asian Countries',
20+
value: 'thailand',
21+
label: 'Thailand',
22+
},
23+
{
24+
group: 'SE Asian Countries',
25+
value: 'vietnam',
26+
label: 'Vietnam',
27+
},
28+
{
29+
group: 'SE Asian Countries',
30+
value: 'malaysai',
31+
label: 'Malaysia',
32+
},
33+
{
34+
group: 'Desserts',
35+
value: 'chocolate',
36+
label: 'Chocolate',
37+
description:
38+
'Chocolate is a usually sweet, brown food preparation of roasted and ground cacao seeds. It is made in the form of a liquid, paste, or in a block, or used as a flavoring ingredient in other foods.',
39+
},
40+
{
41+
group: 'Desserts',
42+
value: 'strawberry',
43+
label: 'Strawberry',
44+
description:
45+
'Strawberries are bright red fruits with a sweet yet slightly tart taste. They are often enjoyed fresh but are also used in a variety of desserts and sauces.',
46+
isDisabled: true,
47+
},
48+
{
49+
group: 'Desserts',
50+
value: 'vanilla',
51+
label: 'Vanilla',
52+
description:
53+
'Vanilla is a popular flavor derived from orchids of the genus Vanilla. It is used in a variety of desserts and beverages for its sweet and creamy flavor.',
54+
},
55+
{
56+
value: 'other1',
57+
label: 'Some Other Value',
58+
description: 'This is a description of some other value',
59+
},
60+
{
61+
value: 'other2',
62+
label: 'Another Value',
63+
},
64+
{
65+
value: 'other3',
66+
label: 'Something else entirely',
67+
},
68+
];
69+
70+
const Component = () => {
71+
const [selectedItem, setSelectedItem] = useState<Item | null>(dessertItems[0]);
72+
73+
const onChange = useCallback((selectedItem: Item | null) => {
74+
setSelectedItem(selectedItem);
75+
}, []);
76+
77+
return (
78+
<FormControl w="20rem" orientation="vertical">
79+
<FormLabel>Framework</FormLabel>
80+
<CustomSelect items={dessertItems} selectedItem={selectedItem} onChange={onChange} isClearable />
81+
</FormControl>
82+
);
83+
};
84+
85+
export const Default: Story = {
86+
render: Component,
87+
};
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import type { SelectProps as ArkSelectProps } from '@ark-ui/react';
2+
import { Portal, Select } from '@ark-ui/react';
3+
import { Divider, Icon, useFormControl, useMultiStyleConfig } from '@chakra-ui/react';
4+
import { Fragment, useCallback, useMemo } from 'react';
5+
import { useTranslation } from 'react-i18next';
6+
import { PiArrowCounterClockwiseBold, PiCaretDownBold } from 'react-icons/pi';
7+
8+
import { Flex, IconButton, Text, Tooltip } from '..';
9+
10+
const isItemDisabledDefault: ArkSelectProps<Item>['isItemDisabled'] = (item: Item) =>
11+
item.isDisabled === undefined ? false : item.isDisabled;
12+
const itemToStringDefault: ArkSelectProps<Item>['itemToString'] = (item: Item) => item.label;
13+
const itemToValueDefault: ArkSelectProps<Item>['itemToValue'] = (item: Item) => item.value;
14+
const positioningDefault: ArkSelectProps<Item>['positioning'] = { sameWidth: true, gutter: 4 };
15+
const groupSortFuncDefault = (a: ItemGroup, b: ItemGroup) => {
16+
if (!a.group) {
17+
return -1;
18+
}
19+
if (!b.group) {
20+
return 1;
21+
}
22+
return a.group.localeCompare(b.group);
23+
};
24+
25+
export type Item = {
26+
label: string;
27+
value: string;
28+
description?: string;
29+
group?: string;
30+
isDisabled?: boolean;
31+
};
32+
33+
export type ItemGroup = {
34+
group?: string;
35+
items: Item[];
36+
};
37+
38+
// This is not exported from ark, we need to define it ourselves.
39+
type SelectValueChangeDetails = {
40+
value: string[];
41+
items: Item[];
42+
};
43+
44+
export type CustomSelectProps = Omit<
45+
ArkSelectProps<Item>,
46+
'id' | 'value' | 'items' | 'asChild' | 'onValueChange' | 'onChange'
47+
> & {
48+
items: Item[];
49+
selectedItem: Item | null;
50+
isClearable?: boolean;
51+
placeholder?: string;
52+
onChange: (selectedItem: Item | null) => void;
53+
groupSortFunc?: (a: ItemGroup, b: ItemGroup) => number;
54+
};
55+
56+
const groupedItemsReducer = (acc: ItemGroup[], val: Item, _idx: number, _arr: Item[]) => {
57+
const existingGroup = acc.find((group) => group.group === val.group);
58+
if (existingGroup) {
59+
existingGroup.items.push(val);
60+
} else {
61+
const newItemGroup: ItemGroup = { items: [val] };
62+
if (val.group) {
63+
newItemGroup.group = val.group;
64+
}
65+
acc.push(newItemGroup);
66+
}
67+
return acc;
68+
};
69+
70+
export const CustomSelect = (props: CustomSelectProps) => {
71+
const {
72+
items,
73+
selectedItem,
74+
onChange,
75+
isItemDisabled = isItemDisabledDefault,
76+
itemToString = itemToStringDefault,
77+
itemToValue = itemToValueDefault,
78+
isClearable = false,
79+
placeholder: _placeholder,
80+
positioning = positioningDefault,
81+
groupSortFunc = groupSortFuncDefault,
82+
invalid,
83+
disabled,
84+
...rest
85+
} = props;
86+
const { t } = useTranslation();
87+
88+
const value = useMemo(() => (selectedItem ? [selectedItem.value] : []), [selectedItem]);
89+
90+
const groupedItems = useMemo<ItemGroup[]>(() => {
91+
const _groupedItems = items.reduce(groupedItemsReducer, [] as ItemGroup[]);
92+
_groupedItems.sort(groupSortFunc);
93+
return _groupedItems;
94+
}, [groupSortFunc, items]);
95+
96+
const onValueChange = useCallback(
97+
(e: SelectValueChangeDetails) => {
98+
onChange(e.items.length ? e.items[0] : null);
99+
},
100+
[onChange]
101+
);
102+
103+
const onClickClear = useCallback(() => {
104+
onChange(null);
105+
}, [onChange]);
106+
107+
const placeholder = useMemo(() => _placeholder ?? t('common.selectAnItem', 'Select an Item'), [_placeholder, t]);
108+
109+
const styles = useMultiStyleConfig('CustomSelect');
110+
const inputProps = useFormControl({
111+
isDisabled: disabled,
112+
isInvalid: invalid,
113+
});
114+
115+
return (
116+
<Tooltip label={selectedItem?.description} placement="top" openDelay={500}>
117+
<Select.Root
118+
value={value}
119+
items={items}
120+
onValueChange={onValueChange}
121+
isItemDisabled={isItemDisabled}
122+
itemToString={itemToString}
123+
itemToValue={itemToValue}
124+
positioning={positioning}
125+
disabled={inputProps.disabled}
126+
invalid={inputProps['aria-invalid']}
127+
{...rest}
128+
asChild
129+
>
130+
<Flex data-part="root" __css={styles.root}>
131+
<Select.Control asChild>
132+
<Flex>
133+
<Select.Trigger asChild>
134+
<Flex as="button">
135+
<Select.ValueText asChild>
136+
<Flex>{selectedItem?.label ?? placeholder}</Flex>
137+
</Select.ValueText>
138+
<Select.Indicator>
139+
<Icon as={PiCaretDownBold} />
140+
</Select.Indicator>
141+
</Flex>
142+
</Select.Trigger>
143+
{isClearable && (
144+
<IconButton
145+
aria-label="Clear selection"
146+
variant="ghost"
147+
size="sm"
148+
icon={<PiArrowCounterClockwiseBold />}
149+
isDisabled={!selectedItem || inputProps.disabled}
150+
onClick={onClickClear}
151+
/>
152+
)}
153+
</Flex>
154+
</Select.Control>
155+
<Portal>
156+
<Select.Positioner>
157+
<Select.Content asChild>
158+
<Flex __css={styles.content}>
159+
{groupedItems.map((itemGroup, i) => (
160+
<Fragment key={`${itemGroup.group}_${i}`}>
161+
<ItemGroupComponent itemGroup={itemGroup} />
162+
{/* {i < groupedItems.length - 1 && <Divider pt={1} />} */}
163+
</Fragment>
164+
))}
165+
</Flex>
166+
</Select.Content>
167+
</Select.Positioner>
168+
</Portal>
169+
</Flex>
170+
</Select.Root>
171+
</Tooltip>
172+
);
173+
};
174+
175+
type ItemGroupComponentProps = {
176+
itemGroup: ItemGroup;
177+
};
178+
179+
const ItemGroupComponent = ({ itemGroup }: ItemGroupComponentProps) => {
180+
if (!itemGroup.group) {
181+
return (
182+
<>
183+
{itemGroup.items.map((item) => (
184+
<SelectItem key={item.value} item={item} />
185+
))}
186+
</>
187+
);
188+
}
189+
190+
return (
191+
<Select.ItemGroup id={itemGroup.group} asChild>
192+
<Flex>
193+
{itemGroup.group && (
194+
<Select.ItemGroupLabel htmlFor={itemGroup.group} asChild>
195+
<Flex alignItems="center" gap={2} userSelect="none">
196+
<Text flexShrink={0}>{itemGroup.group}</Text>
197+
<Divider />
198+
</Flex>
199+
</Select.ItemGroupLabel>
200+
)}
201+
{itemGroup.items.map((item) => (
202+
<SelectItem key={item.value} item={item} />
203+
))}
204+
</Flex>
205+
</Select.ItemGroup>
206+
);
207+
};
208+
209+
type SelectItemProps = {
210+
item: Item;
211+
};
212+
213+
const SelectItem = ({ item }: SelectItemProps) => {
214+
return (
215+
<Select.Item item={item} asChild>
216+
<Flex>
217+
<Select.ItemText asChild>
218+
<Flex>
219+
<Text data-part="item-text-label">{item.label}</Text>
220+
{item?.description && (
221+
<Text data-part="item-text-description" noOfLines={1}>
222+
{item?.description}
223+
</Text>
224+
)}
225+
</Flex>
226+
</Select.ItemText>
227+
</Flex>
228+
</Select.Item>
229+
);
230+
};
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export type { CustomSelectProps, Item, ItemGroup } from './custom-select';
2+
export { CustomSelect } from './custom-select';

lib/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from './card';
77
export * from './checkbox';
88
export * from './combobox';
99
export * from './context-menu';
10+
export * from './custom-select';
1011
export * from './divider';
1112
export * from './editable';
1213
export * from './expander';
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { PiArrowSquareOutBold } from 'react-icons/pi';
2+
3+
import { Icon } from '..';
4+
import type { LinkProps } from '.';
5+
import { Link } from '.';
6+
7+
export type ExternalLinkProps = Omit<LinkProps, 'isExternal' | 'children'> & { label: string };
8+
9+
export const ExternalLink = (props: ExternalLinkProps) => {
10+
return (
11+
<Link href={props.href} isExternal>
12+
{props.label}
13+
<Icon display="inline" verticalAlign="middle" marginInlineStart={2} as={PiArrowSquareOutBold} />
14+
</Link>
15+
);
16+
};

lib/components/link/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
1+
export type { ExternalLinkProps } from './external-link';
2+
export { ExternalLink } from './external-link';
13
export type { LinkProps } from './wrapper';
24
export { Link } from './wrapper';

lib/theme/animations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ export const spinKeyframes: Keyframes = keyframes`
1010
}
1111
`;
1212

13-
export const spinAnimation = `${spinKeyframes} 0.45s linear infinite`;
13+
export const spinAnimation = `${spinKeyframes} 1s linear infinite`;

0 commit comments

Comments
 (0)