Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions lib/components/custom-select/custom-select.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useCallback, useState } from 'react';

import { FormControl, FormLabel } from '..';
import type { Item } from './custom-select';
import { CustomSelect } from './custom-select';

const meta: Meta<typeof CustomSelect> = {
title: 'Primitives/CustomSelect',
tags: ['autodocs'],
component: CustomSelect,
};

export default meta;
type Story = StoryObj<typeof CustomSelect>;

const dessertItems: Item[] = [
{
group: 'SE Asian Countries',
value: 'thailand',
label: 'Thailand',
},
{
group: 'SE Asian Countries',
value: 'vietnam',
label: 'Vietnam',
},
{
group: 'SE Asian Countries',
value: 'malaysai',
label: 'Malaysia',
},
{
group: 'Desserts',
value: 'chocolate',
label: 'Chocolate',
description:
'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.',
},
{
group: 'Desserts',
value: 'strawberry',
label: 'Strawberry',
description:
'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.',
isDisabled: true,
},
{
group: 'Desserts',
value: 'vanilla',
label: 'Vanilla',
description:
'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.',
},
{
value: 'other1',
label: 'Some Other Value',
description: 'This is a description of some other value',
},
{
value: 'other2',
label: 'Another Value',
},
{
value: 'other3',
label: 'Something else entirely',
},
];

const Component = () => {
const [selectedItem, setSelectedItem] = useState<Item | null>(dessertItems[0]);

const onChange = useCallback((selectedItem: Item | null) => {
setSelectedItem(selectedItem);
}, []);

return (
<FormControl w="20rem" orientation="vertical">
<FormLabel>Framework</FormLabel>
<CustomSelect items={dessertItems} selectedItem={selectedItem} onChange={onChange} isClearable />
</FormControl>
);
};

export const Default: Story = {
render: Component,
};
230 changes: 230 additions & 0 deletions lib/components/custom-select/custom-select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import type { SelectProps as ArkSelectProps } from '@ark-ui/react';
import { Portal, Select } from '@ark-ui/react';
import { Divider, Icon, useFormControl, useMultiStyleConfig } from '@chakra-ui/react';
import { Fragment, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold, PiCaretDownBold } from 'react-icons/pi';

import { Flex, IconButton, Text, Tooltip } from '..';

const isItemDisabledDefault: ArkSelectProps<Item>['isItemDisabled'] = (item: Item) =>
item.isDisabled === undefined ? false : item.isDisabled;
const itemToStringDefault: ArkSelectProps<Item>['itemToString'] = (item: Item) => item.label;
const itemToValueDefault: ArkSelectProps<Item>['itemToValue'] = (item: Item) => item.value;
const positioningDefault: ArkSelectProps<Item>['positioning'] = { sameWidth: true, gutter: 4 };
const groupSortFuncDefault = (a: ItemGroup, b: ItemGroup) => {
if (!a.group) {
return -1;
}
if (!b.group) {
return 1;
}
return a.group.localeCompare(b.group);
};

export type Item = {
label: string;
value: string;
description?: string;
group?: string;
isDisabled?: boolean;
};

export type ItemGroup = {
group?: string;
items: Item[];
};

// This is not exported from ark, we need to define it ourselves.
type SelectValueChangeDetails = {
value: string[];
items: Item[];
};

export type CustomSelectProps = Omit<
ArkSelectProps<Item>,
'id' | 'value' | 'items' | 'asChild' | 'onValueChange' | 'onChange'
> & {
items: Item[];
selectedItem: Item | null;
isClearable?: boolean;
placeholder?: string;
onChange: (selectedItem: Item | null) => void;
groupSortFunc?: (a: ItemGroup, b: ItemGroup) => number;
};

const groupedItemsReducer = (acc: ItemGroup[], val: Item, _idx: number, _arr: Item[]) => {
const existingGroup = acc.find((group) => group.group === val.group);
if (existingGroup) {
existingGroup.items.push(val);
} else {
const newItemGroup: ItemGroup = { items: [val] };
if (val.group) {
newItemGroup.group = val.group;
}
acc.push(newItemGroup);
}
return acc;
};

export const CustomSelect = (props: CustomSelectProps) => {
const {
items,
selectedItem,
onChange,
isItemDisabled = isItemDisabledDefault,
itemToString = itemToStringDefault,
itemToValue = itemToValueDefault,
isClearable = false,
placeholder: _placeholder,
positioning = positioningDefault,
groupSortFunc = groupSortFuncDefault,
invalid,
disabled,
...rest
} = props;
const { t } = useTranslation();

const value = useMemo(() => (selectedItem ? [selectedItem.value] : []), [selectedItem]);

const groupedItems = useMemo<ItemGroup[]>(() => {
const _groupedItems = items.reduce(groupedItemsReducer, [] as ItemGroup[]);
_groupedItems.sort(groupSortFunc);
return _groupedItems;
}, [groupSortFunc, items]);

const onValueChange = useCallback(
(e: SelectValueChangeDetails) => {
onChange(e.items.length ? e.items[0] : null);
},
[onChange]
);

const onClickClear = useCallback(() => {
onChange(null);
}, [onChange]);

const placeholder = useMemo(() => _placeholder ?? t('common.selectAnItem', 'Select an Item'), [_placeholder, t]);

const styles = useMultiStyleConfig('CustomSelect');
const inputProps = useFormControl({
isDisabled: disabled,
isInvalid: invalid,
});

return (
<Tooltip label={selectedItem?.description} placement="top" openDelay={500}>
<Select.Root
value={value}
items={items}
onValueChange={onValueChange}
isItemDisabled={isItemDisabled}
itemToString={itemToString}
itemToValue={itemToValue}
positioning={positioning}
disabled={inputProps.disabled}
invalid={inputProps['aria-invalid']}
{...rest}
asChild
>
<Flex data-part="root" __css={styles.root}>
<Select.Control asChild>
<Flex>
<Select.Trigger asChild>
<Flex as="button">
<Select.ValueText asChild>
<Flex>{selectedItem?.label ?? placeholder}</Flex>
</Select.ValueText>
<Select.Indicator>
<Icon as={PiCaretDownBold} />
</Select.Indicator>
</Flex>
</Select.Trigger>
{isClearable && (
<IconButton
aria-label="Clear selection"
variant="ghost"
size="sm"
icon={<PiArrowCounterClockwiseBold />}
isDisabled={!selectedItem || inputProps.disabled}
onClick={onClickClear}
/>
)}
</Flex>
</Select.Control>
<Portal>
<Select.Positioner>
<Select.Content asChild>
<Flex __css={styles.content}>
{groupedItems.map((itemGroup, i) => (
<Fragment key={`${itemGroup.group}_${i}`}>
<ItemGroupComponent itemGroup={itemGroup} />
{/* {i < groupedItems.length - 1 && <Divider pt={1} />} */}
</Fragment>
))}
</Flex>
</Select.Content>
</Select.Positioner>
</Portal>
</Flex>
</Select.Root>
</Tooltip>
);
};

type ItemGroupComponentProps = {
itemGroup: ItemGroup;
};

const ItemGroupComponent = ({ itemGroup }: ItemGroupComponentProps) => {
if (!itemGroup.group) {
return (
<>
{itemGroup.items.map((item) => (
<SelectItem key={item.value} item={item} />
))}
</>
);
}

return (
<Select.ItemGroup id={itemGroup.group} asChild>
<Flex>
{itemGroup.group && (
<Select.ItemGroupLabel htmlFor={itemGroup.group} asChild>
<Flex alignItems="center" gap={2} userSelect="none">
<Text flexShrink={0}>{itemGroup.group}</Text>
<Divider />
</Flex>
</Select.ItemGroupLabel>
)}
{itemGroup.items.map((item) => (
<SelectItem key={item.value} item={item} />
))}
</Flex>
</Select.ItemGroup>
);
};

type SelectItemProps = {
item: Item;
};

const SelectItem = ({ item }: SelectItemProps) => {
return (
<Select.Item item={item} asChild>
<Flex>
<Select.ItemText asChild>
<Flex>
<Text data-part="item-text-label">{item.label}</Text>
{item?.description && (
<Text data-part="item-text-description" noOfLines={1}>
{item?.description}
</Text>
)}
</Flex>
</Select.ItemText>
</Flex>
</Select.Item>
);
};
2 changes: 2 additions & 0 deletions lib/components/custom-select/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { CustomSelectProps, Item, ItemGroup } from './custom-select';
export { CustomSelect } from './custom-select';
1 change: 1 addition & 0 deletions lib/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from './card';
export * from './checkbox';
export * from './combobox';
export * from './context-menu';
export * from './custom-select';
export * from './divider';
export * from './editable';
export * from './expander';
Expand Down
16 changes: 16 additions & 0 deletions lib/components/link/external-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { PiArrowSquareOutBold } from 'react-icons/pi';

import { Icon } from '..';
import type { LinkProps } from '.';
import { Link } from '.';

export type ExternalLinkProps = Omit<LinkProps, 'isExternal' | 'children'> & { label: string };

export const ExternalLink = (props: ExternalLinkProps) => {
return (
<Link href={props.href} isExternal>
{props.label}
<Icon display="inline" verticalAlign="middle" marginInlineStart={2} as={PiArrowSquareOutBold} />
</Link>
);
};
2 changes: 2 additions & 0 deletions lib/components/link/index.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export type { ExternalLinkProps } from './external-link';
export { ExternalLink } from './external-link';
export type { LinkProps } from './wrapper';
export { Link } from './wrapper';
2 changes: 1 addition & 1 deletion lib/theme/animations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ export const spinKeyframes: Keyframes = keyframes`
}
`;

export const spinAnimation = `${spinKeyframes} 0.45s linear infinite`;
export const spinAnimation = `${spinKeyframes} 1s linear infinite`;
Loading