diff --git a/package.json b/package.json index 29daab8ac4..198e4e09e4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7654fa17b..9e603339e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@apollo/client': specifier: 3.11.8 version: 3.11.8(@types/react@18.0.14)(graphql@16.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@ariakit/react': + specifier: 0.4.13 + version: 0.4.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@headlessui/react': specifier: ^1.7.17 version: 1.7.17(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -564,6 +567,21 @@ packages: subscriptions-transport-ws: optional: true + '@ariakit/core@0.4.12': + resolution: {integrity: sha512-+NNpy88tdP/w9mOBPuDrMTbtapPbo/8yVIzpQB7TAmN0sPh/Cq3nU1f2KCTCIujPmwRvAcMSW9UHOlFmbKEPOA==} + + '@ariakit/react-core@0.4.13': + resolution: {integrity: sha512-iIjQeupP9d0pOubOzX4a0UPXbhXbp0ZCduDpkv7+u/pYP/utk/YRECD0M/QpZr6YSeltmDiNxKjdyK8r9Yhv4Q==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@ariakit/react@0.4.13': + resolution: {integrity: sha512-pTGYgoqCojfyt2xNJ5VQhejxXwwtcP7VDDqcnnVChv7TA2TWWyYerJ5m4oxViI1pgeNqnHZwKlQ79ZipF7W2kQ==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + '@babel/code-frame@7.23.5': resolution: {integrity: sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==} engines: {node: '>=6.9.0'} @@ -10456,6 +10474,22 @@ snapshots: transitivePeerDependencies: - '@types/react' + '@ariakit/core@0.4.12': {} + + '@ariakit/react-core@0.4.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@ariakit/core': 0.4.12 + '@floating-ui/dom': 1.6.10 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + use-sync-external-store: 1.2.0(react@18.3.1) + + '@ariakit/react@0.4.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@ariakit/react-core': 0.4.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@babel/code-frame@7.23.5': dependencies: '@babel/highlight': 7.23.4 diff --git a/src/renderer/features/fellowship-members/components/MembersModal.tsx b/src/renderer/features/fellowship-members/components/MembersModal.tsx index 816955ef8a..844ad80722 100644 --- a/src/renderer/features/fellowship-members/components/MembersModal.tsx +++ b/src/renderer/features/fellowship-members/components/MembersModal.tsx @@ -47,7 +47,7 @@ export const MembersModal = ({ children }: PropsWithChildren) => { {members.length !== 0 ? ( - + ) : null} {filteredMembers.length === 0 ? ( diff --git a/src/renderer/pages/Onboarding/Vault/ManageMultishard/ManageMultishard.tsx b/src/renderer/pages/Onboarding/Vault/ManageMultishard/ManageMultishard.tsx index a38b5b0162..bd8e64dc38 100644 --- a/src/renderer/pages/Onboarding/Vault/ManageMultishard/ManageMultishard.tsx +++ b/src/renderer/pages/Onboarding/Vault/ManageMultishard/ManageMultishard.tsx @@ -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)} />
    @@ -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)} /> 0 && shardsStats.selected < shardsStake.length} - onChange={(checked) => selectAllShards(checked)} + onChange={selectAllShards} >
    diff --git a/src/renderer/shared/ui-kit/Combobox/Combobox.stories.tsx b/src/renderer/shared/ui-kit/Combobox/Combobox.stories.tsx new file mode 100644 index 0000000000..aea3188457 --- /dev/null +++ b/src/renderer/shared/ui-kit/Combobox/Combobox.stories.tsx @@ -0,0 +1,55 @@ +import { type Meta, type StoryObj } from '@storybook/react'; +import { noop } from 'lodash'; + +import { Combobox } from './Combobox'; + +const meta: Meta = { + title: 'Design System/kit/Combobox', + component: Combobox, + parameters: { + layout: 'centered', + }, + render: (params) => { + return ( + + + {[ + { text: '🍎 Apple', value: 'Apple' }, + { text: '🍇 Grape', value: 'Grape' }, + { text: '🍊 Orange', value: 'Orange' }, + { text: '🍓 Strawberry', value: 'Strawberry' }, + { text: '🍉 Watermelon', value: 'Watermelon' }, + ].map((item) => ( + + {item.text} + + ))} + + + ); + }, +}; + +export default meta; + +type Story = StoryObj; + +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, + }, +}; diff --git a/src/renderer/shared/ui-kit/Combobox/Combobox.tsx b/src/renderer/shared/ui-kit/Combobox/Combobox.tsx new file mode 100644 index 0000000000..c4131e6e7c --- /dev/null +++ b/src/renderer/shared/ui-kit/Combobox/Combobox.tsx @@ -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; + listboxRef?: RefObject; +}; + +const Context = createContext({}); + +type InputProps = Pick, 'disabled' | 'invalid' | 'placeholder' | 'height'>; + +type ControlledPopoverProps = { + value: string; + onChange: (value: string) => void; +}; + +type RootProps = PropsWithChildren; + +const Root = ({ testId = 'Combobox', value, onChange, children, ...inputProps }: RootProps) => { + const comboboxRef = useRef(null); + const listboxRef = useRef(null); + + const [open, onOpenChange] = useState(false); + + const ctx = useMemo(() => ({ open, onOpenChange, testId, comboboxRef, listboxRef }), [open, testId]); + + return ( + + + startTransition(() => onChange(value))} + > + + {children} + + + + ); +}; + +const Trigger = ({ placeholder, ...inputProps }: InputProps) => { + const { onOpenChange, comboboxRef } = useContext(Context); + + return ( + + } + onFocus={() => onOpenChange?.(true)} + onBlur={() => onOpenChange?.(false)} + /> + + ); +}; + +const Content = ({ children }: PropsWithChildren) => { + const { portalContainer } = useTheme(); + const { testId, comboboxRef, listboxRef } = useContext(Context); + + if (Children.count(children) === 0) return null; + + return ( + + 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(); + } + }} + > + + + + {children} + + + + + + ); +}; + +type ItemProps = { + value: string; +}; +const Item = ({ value, children }: PropsWithChildren) => { + return ( + + {children} + + ); +}; + +export const Combobox = Object.assign(Root, { + Content, + Item, +}); diff --git a/src/renderer/shared/ui-kit/Input/Input.test.tsx b/src/renderer/shared/ui-kit/Input/Input.test.tsx index e07cdf5b8c..4622d59c6e 100644 --- a/src/renderer/shared/ui-kit/Input/Input.test.tsx +++ b/src/renderer/shared/ui-kit/Input/Input.test.tsx @@ -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'); }); }); diff --git a/src/renderer/shared/ui-kit/Input/Input.tsx b/src/renderer/shared/ui-kit/Input/Input.tsx index daf4172436..3165578750 100644 --- a/src/renderer/shared/ui-kit/Input/Input.tsx +++ b/src/renderer/shared/ui-kit/Input/Input.tsx @@ -1,4 +1,5 @@ import { + type ChangeEvent, type ClipboardEvent, type ComponentPropsWithoutRef, type ReactNode, @@ -21,6 +22,7 @@ type ComponentProps = { prefixElement?: ReactNode; suffixElement?: ReactNode; onChange?: (value: string) => void; + onChangeEvent?: (event: ChangeEvent) => void; onPaste?: (event: ClipboardEvent) => void; }; @@ -42,6 +44,7 @@ export const Input = forwardRef( prefixElement, suffixElement, onChange, + onChangeEvent, onPaste, ...props }, @@ -96,7 +99,10 @@ export const Input = forwardRef( 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} /> diff --git a/src/renderer/shared/ui-kit/ScrollArea/ScrollArea.tsx b/src/renderer/shared/ui-kit/ScrollArea/ScrollArea.tsx index b33fd0b742..d49b970bbe 100644 --- a/src/renderer/shared/ui-kit/ScrollArea/ScrollArea.tsx +++ b/src/renderer/shared/ui-kit/ScrollArea/ScrollArea.tsx @@ -12,7 +12,7 @@ type Props = PropsWithChildren< } >; -export const ScrollArea = ({ onScroll, orientation = 'vertical', children }: Props) => ( +export const ScrollArea = ({ orientation = 'vertical', children, onScroll }: Props) => ( {children} diff --git a/src/renderer/shared/ui-kit/index.ts b/src/renderer/shared/ui-kit/index.ts index f8e18a9201..4794d0744d 100644 --- a/src/renderer/shared/ui-kit/index.ts +++ b/src/renderer/shared/ui-kit/index.ts @@ -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'; diff --git a/src/renderer/shared/ui/Dropdowns/Combobox/Combobox.tsx b/src/renderer/shared/ui/Dropdowns/Combobox/Combobox.tsx index a3ce751a47..974b4aba33 100644 --- a/src/renderer/shared/ui/Dropdowns/Combobox/Combobox.tsx +++ b/src/renderer/shared/ui/Dropdowns/Combobox/Combobox.tsx @@ -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'; @@ -46,8 +46,7 @@ export const Combobox = ({ option.value} - // @ts-expect-error onChange doesn't respect custom onChange type - onChange={onInput} + onChangeEvent={(e: ChangeEvent) => onInput(e.target.value)} {...inputProps} /> diff --git a/src/renderer/shared/ui/Inputs/Input/Input.tsx b/src/renderer/shared/ui/Inputs/Input/Input.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/renderer/shared/ui/Inputs/SearchInput/SearchInput.tsx b/src/renderer/shared/ui/Inputs/SearchInput/SearchInput.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/renderer/widgets/CreateWallet/ui/MultisigWallet/components/Signatory.tsx b/src/renderer/widgets/CreateWallet/ui/MultisigWallet/components/Signatory.tsx index 2a869a289a..57b3fe2517 100644 --- a/src/renderer/widgets/CreateWallet/ui/MultisigWallet/components/Signatory.tsx +++ b/src/renderer/widgets/CreateWallet/ui/MultisigWallet/components/Signatory.tsx @@ -36,26 +36,23 @@ export const Signatory = ({ selectedWallet, }: Props) => { const { t } = useI18n(); - const [query, setQuery] = useState(''); - const [options, setOptions] = useState([]); const contacts = useUnit(contactModel.$contacts); const wallets = useUnit(walletModel.$wallets); const { fields: { chain }, } = useForm(formModel.$createMultisigForm); - const contactsFiltered = useMemo( - () => - performSearch({ - query, - records: contacts, - weights: { - name: 1, - address: 0.5, - }, - }), - [query, contacts], - ); + + const [query, setQuery] = useState(''); + const [options, setOptions] = useState([]); + + const contactsFiltered = useMemo(() => { + return performSearch({ + query, + records: contacts, + weights: { name: 1, address: 0.5 }, + }); + }, [query, contacts]); const ownAccountName = walletUtils.getWalletsFilteredAccounts(wallets, { @@ -218,9 +215,7 @@ export const Signatory = ({ query={query} value={toAddress(signatoryAddress, { prefix: chain.value.addressPrefix })} prefixElement={} - onChange={(data) => { - onAddressChange(data); - }} + onChange={onAddressChange} onInput={handleQueryChange} />