Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Combobox #2574

Merged
merged 7 commits into from
Nov 14, 2024
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
},
"dependencies": {
"@apollo/client": "3.11.8",
"@ariakit/react": "0.4.13",
"@headlessui/react": "^1.7.17",
"@polkadot/api": "13.1.1",
"@polkadot/keyring": "13.1.1",
Expand Down
34 changes: 34 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const MembersModal = ({ children }: PropsWithChildren) => {
<Modal.Content>
<Box padding={[4, 5]} gap={6} fillContainer>
{members.length !== 0 ? (
<SearchInput autoFocus placeholder={t('general.input.searchLabel')} value={query} onChange={setQuery} />
<SearchInput autoFocus value={query} placeholder={t('general.input.searchLabel')} onChange={setQuery} />
) : null}

{filteredMembers.length === 0 ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ export const ManageMultishard = ({ seedInfo, onBack, onClose, onComplete }: Prop
disabled={inactiveAccounts[getAccountId(index)]}
placeholder={t('onboarding.paritySigner.accountNamePlaceholder')}
value={accountNames[getAccountId(index)] || ''}
onChange={(name) => updateAccountName(name, index)}
onChange={(value) => updateAccountName(value, index)}
/>
</div>
<ul className="flex flex-col gap-2.5">
Expand Down Expand Up @@ -317,7 +317,7 @@ export const ManageMultishard = ({ seedInfo, onBack, onClose, onComplete }: Prop
disabled={inactiveAccounts[getAccountId(index, chainId, derivedKeyIndex)]}
placeholder={t('onboarding.paritySigner.accountNamePlaceholder')}
value={accountNames[getAccountId(index, chainId, derivedKeyIndex)] || ''}
onChange={(name) => updateAccountName(name, index, chainId, derivedKeyIndex)}
onChange={(value) => updateAccountName(value, index, chainId, derivedKeyIndex)}
/>
<IconButton
name={
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/pages/Staking/ui/ShardedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const ShardedList = ({
<Checkbox
checked={shardsStats.selected === shardsStake.length}
semiChecked={shardsStats.selected > 0 && shardsStats.selected < shardsStake.length}
onChange={(checked) => selectAllShards(checked)}
onChange={selectAllShards}
>
<div className="grid grid-cols-[174px,104px,104px] items-center gap-x-6">
<div className="flex items-center gap-x-2">
Expand Down
55 changes: 55 additions & 0 deletions src/renderer/shared/ui-kit/Combobox/Combobox.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { noop } from 'lodash';

import { Combobox } from './Combobox';

const meta: Meta<typeof Combobox> = {
title: 'Design System/kit/Combobox',
component: Combobox,
parameters: {
layout: 'centered',
},
render: (params) => {
return (
<Combobox {...params} placeholder="Type something ..." value="" onChange={noop}>
<Combobox.Content>
{[
{ text: '🍎 Apple', value: 'Apple' },
{ text: '🍇 Grape', value: 'Grape' },
{ text: '🍊 Orange', value: 'Orange' },
{ text: '🍓 Strawberry', value: 'Strawberry' },
{ text: '🍉 Watermelon', value: 'Watermelon' },
].map((item) => (
<Combobox.Item key={item.text} value={item.value}>
{item.text}
</Combobox.Item>
))}
</Combobox.Content>
</Combobox>
);
},
};

export default meta;

type Story = StoryObj<typeof Combobox>;

export const Default: Story = {};

export const Small: Story = {
args: {
height: 'sm',
},
};

export const Disabled: Story = {
args: {
disabled: true,
},
};

export const Invalid: Story = {
args: {
invalid: true,
},
};
154 changes: 154 additions & 0 deletions src/renderer/shared/ui-kit/Combobox/Combobox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import * as Ariakit from '@ariakit/react';
import * as RadixPopover from '@radix-ui/react-popover';
import {
Children,
type ComponentProps,
type PropsWithChildren,
type RefObject,
createContext,
startTransition,
useContext,
useMemo,
useRef,
useState,
} from 'react';

import { cnTw } from '@/shared/lib/utils';
import { Input } from '../Input/Input';
import { ScrollArea } from '../ScrollArea/ScrollArea';
import { Surface } from '../Surface/Surface';
import { useTheme } from '../Theme/useTheme';
import { gridSpaceConverter } from '../_helpers/gridSpaceConverter';

type ContextProps = {
testId?: string;
open?: boolean;
onOpenChange?: (value: boolean) => void;
};

type ExpandedContextProps = {
comboboxRef?: RefObject<HTMLInputElement>;
tuul-wq marked this conversation as resolved.
Show resolved Hide resolved
listboxRef?: RefObject<HTMLDivElement>;
tuul-wq marked this conversation as resolved.
Show resolved Hide resolved
};

const Context = createContext<ContextProps & ExpandedContextProps>({});

type InputProps = Pick<ComponentProps<typeof Input>, 'disabled' | 'invalid' | 'placeholder' | 'height'>;

type ControlledPopoverProps = {
value: string;
onChange: (value: string) => void;
};

type RootProps = PropsWithChildren<ControlledPopoverProps & ContextProps & InputProps>;

const Root = ({ testId = 'Combobox', value, onChange, children, ...inputProps }: RootProps) => {
const comboboxRef = useRef<HTMLInputElement>(null);
const listboxRef = useRef<HTMLDivElement>(null);

const [open, onOpenChange] = useState(false);

const ctx = useMemo(() => ({ open, onOpenChange, testId, comboboxRef, listboxRef }), [open, testId]);

return (
<Context.Provider value={ctx}>
<RadixPopover.Root modal open={open} onOpenChange={onOpenChange}>
<Ariakit.ComboboxProvider
open={open}
setOpen={onOpenChange}
defaultValue={value}
defaultSelectedValue={value}
setSelectedValue={onChange}
setValue={(value) => startTransition(() => onChange(value))}
>
<Trigger {...inputProps} />
{children}
</Ariakit.ComboboxProvider>
</RadixPopover.Root>
</Context.Provider>
);
};

const Trigger = ({ placeholder, ...inputProps }: InputProps) => {
const { onOpenChange, comboboxRef } = useContext(Context);

return (
<RadixPopover.Anchor asChild>
<Ariakit.Combobox
autoSelect
autoComplete="both"
ref={comboboxRef}
placeholder={placeholder}
render={({ onChange, ...props }) => <Input {...props} {...inputProps} onChangeEvent={onChange} />}
onFocus={() => onOpenChange?.(true)}
onBlur={() => onOpenChange?.(false)}
/>
</RadixPopover.Anchor>
);
};

const Content = ({ children }: PropsWithChildren) => {
const { portalContainer } = useTheme();
const { testId, comboboxRef, listboxRef } = useContext(Context);

if (Children.count(children) === 0) return null;

return (
<RadixPopover.Portal container={portalContainer}>
<RadixPopover.Content
asChild
hideWhenDetached
style={{ width: 'var(--radix-popover-trigger-width)' }}
collisionPadding={gridSpaceConverter(2)}
sideOffset={gridSpaceConverter(2)}
data-testid={testId}
onOpenAutoFocus={(e) => e.preventDefault()}
onInteractOutside={(event) => {
const target = event.target as Element | null;
const isCombobox = target === comboboxRef?.current;
const inListbox = target && listboxRef?.current?.contains(target);
if (isCombobox || inListbox) {
event.preventDefault();
}
}}
>
<Surface
elevation={1}
className={cnTw(
'flex h-max max-h-[--radix-popper-available-height] flex-col',
'overflow-hidden duration-100 animate-in fade-in zoom-in-95',
)}
>
<ScrollArea>
<Ariakit.ComboboxList className="flex flex-col gap-y-1 p-1" ref={listboxRef} role="listbox">
{children}
</Ariakit.ComboboxList>
</ScrollArea>
</Surface>
</RadixPopover.Content>
</RadixPopover.Portal>
);
};

type ItemProps = {
value: string;
};
const Item = ({ value, children }: PropsWithChildren<ItemProps>) => {
return (
<Ariakit.ComboboxItem
focusOnHover
value={value}
className={cnTw(
'flex cursor-pointer rounded p-2 text-footnote text-text-secondary',
'bg-block-background-default data-[active-item]:bg-block-background-hover',
)}
>
{children}
</Ariakit.ComboboxItem>
);
};

export const Combobox = Object.assign(Root, {
Content,
Item,
});
2 changes: 1 addition & 1 deletion src/renderer/shared/ui-kit/Input/Input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ describe('ui/Inputs/Input', () => {
const input = screen.getByRole('textbox');
await user.type(input, 'x');

expect(spyChange).toBeCalledWith('x');
expect(spyChange).toHaveBeenCalledWith('x');
});
});
8 changes: 7 additions & 1 deletion src/renderer/shared/ui-kit/Input/Input.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type ChangeEvent,
type ClipboardEvent,
type ComponentPropsWithoutRef,
type ReactNode,
Expand All @@ -21,6 +22,7 @@ type ComponentProps = {
prefixElement?: ReactNode;
suffixElement?: ReactNode;
onChange?: (value: string) => void;
onChangeEvent?: (event: ChangeEvent<HTMLInputElement>) => void;
onPaste?: (event: ClipboardEvent) => void;
};

Expand All @@ -42,6 +44,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
prefixElement,
suffixElement,
onChange,
onChangeEvent,
onPaste,
...props
},
Expand Down Expand Up @@ -96,7 +99,10 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
autoFocus={autoFocus}
disabled={disabled}
spellCheck={spellCheck}
onChange={(event) => onChange?.(event.target.value)}
onChange={(event) => {
onChange?.(event.target.value);
onChangeEvent?.(event);
}}
onPaste={(event) => onPaste?.(event)}
{...props}
/>
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/shared/ui-kit/ScrollArea/ScrollArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ type Props = PropsWithChildren<
}
>;

export const ScrollArea = ({ onScroll, orientation = 'vertical', children }: Props) => (
export const ScrollArea = ({ orientation = 'vertical', children, onScroll }: Props) => (
<RadixScrollArea.Root type="scroll" scrollHideDelay={500} className="flex h-full w-full flex-col overflow-hidden">
<RadixScrollArea.Viewport className="h-full w-full" onScroll={onScroll}>
{children}
Expand Down
1 change: 1 addition & 0 deletions src/renderer/shared/ui-kit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export { ThemeProvider } from './Theme/ThemeProvider';
export { ScrollArea } from './ScrollArea/ScrollArea';
export { InputFile } from './InputFile/InputFile';
export { Checkbox } from './Checkbox/Checkbox';
export { Combobox } from './Combobox/Combobox';
export { Dropdown } from './Dropdown/Dropdown';
export { Skeleton } from './Skeleton/Skeleton';
export { Carousel } from './Carousel/Carousel';
Expand Down
5 changes: 2 additions & 3 deletions src/renderer/shared/ui/Dropdowns/Combobox/Combobox.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Combobox as HeadlessCombobox, Transition } from '@headlessui/react';
import { type ComponentProps, Fragment } from 'react';
import { type ChangeEvent, type ComponentProps, Fragment } from 'react';

import { cnTw } from '@/shared/lib/utils';
import { Input } from '@/shared/ui-kit';
Expand Down Expand Up @@ -46,8 +46,7 @@ export const Combobox = ({
<HeadlessCombobox.Input
as={Input}
displayValue={(option: ComboboxOption) => option.value}
// @ts-expect-error onChange doesn't respect custom <Input /> onChange type
onChange={onInput}
onChangeEvent={(e: ChangeEvent<HTMLInputElement>) => onInput(e.target.value)}
{...inputProps}
/>

Expand Down
Empty file.
Empty file.
Loading
Loading