Update domain references to pezkuwichain.app and rebrand from polkadot

This commit is contained in:
2026-01-06 12:21:11 +03:00
commit c2913a65d9
401 changed files with 23179 additions and 0 deletions
@@ -0,0 +1,204 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import '@pezkuwi/extension-mocks/chrome';
import type { ReactWrapper } from 'enzyme';
import type * as _ from '@pezkuwi/dev-test/globals.d.ts';
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
import enzyme from 'enzyme';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { flushAllPromises } from '../testHelpers.js';
import BackButton from './BackButton.js';
import { AccountNamePasswordCreation, Input, InputWithLabel, NextStepButton } from './index.js';
// For this file, there are a lot of them
/* eslint-disable @typescript-eslint/no-unsafe-argument */
const { configure, mount } = enzyme;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call
configure({ adapter: new Adapter() });
const account = {
name: 'My Polkadot Account',
password: 'somepassword'
};
const buttonLabel = 'create account';
let wrapper: ReactWrapper;
const onBackClick = jest.fn();
const onCreate = jest.fn();
const onNameChange = jest.fn();
const type = async (input: ReactWrapper, value: string): Promise<void> => {
input.simulate('change', { target: { value } });
await act(flushAllPromises);
wrapper.update();
};
const capsLockOn = async (input: ReactWrapper): Promise<void> => {
input.simulate('keyPress', { getModifierState: () => true });
await act(flushAllPromises);
wrapper.update();
};
const enterName = (name: string): Promise<void> => type(wrapper.find('input').first(), name);
const password = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').first(), password);
const repeat = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').last(), password);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
const mountComponent = (isBusy = false): ReactWrapper => mount(
<AccountNamePasswordCreation
buttonLabel={buttonLabel}
isBusy={isBusy}
onBackClick={onBackClick}
onCreate={onCreate}
onNameChange={onNameChange}
/>
);
describe('AccountNamePasswordCreation', () => {
beforeEach(async () => {
wrapper = mountComponent();
await act(flushAllPromises);
wrapper.update();
});
it('only account name input is visible at first', () => {
expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input)).toHaveLength(1);
expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1);
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
});
it('next step button has the correct label', () => {
expect(wrapper.find(NextStepButton).text()).toBe(buttonLabel);
});
it('back button calls onBackClick', () => {
wrapper.find(BackButton).simulate('click');
expect(onBackClick).toHaveBeenCalledTimes(1);
});
it('input should not be highlighted as error until first interaction', () => {
expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(false);
});
it('after typing less than 3 characters into name input, password input is not visible', async () => {
await enterName('ab');
expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(true);
expect(wrapper.find('.warning-message').first().text()).toBe('Account name is too short');
expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1);
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
});
it('input should keep showing error when something has been typed but then erased', async () => {
await enterName(account.name);
await enterName('');
expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(true);
});
it('after typing 3 characters into name input, onNameChange is called', async () => {
await enterName(account.name);
expect(onNameChange).toHaveBeenLastCalledWith(account.name);
});
it('after typing 3 characters into name input, first password input is visible', async () => {
await enterName(account.name);
expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).first().prop('withError')).toBe(false);
expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1);
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
});
it('password with caps lock should show a warning', async () => {
await enterName('abc').then(password('abcde'));
await capsLockOn(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input));
expect(wrapper.find('.warning-message').first().text()).toBe('Warning: Caps lock is on');
});
it('password shorter than 6 characters should be not valid', async () => {
await enterName('abc').then(password('abcde'));
expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input).prop('withError')).toBe(true);
expect(wrapper.find('.warning-message').text()).toBe('Password is too short');
expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1);
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
});
it('submit button is not enabled until both passwords are equal', async () => {
await enterName('abc').then(password('abcdef')).then(repeat('abcdeg'));
expect(wrapper.find('.warning-message').text()).toBe('Passwords do not match');
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(true);
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
});
it('submit button is enabled when both passwords are equal', async () => {
await enterName('abc').then(password('abcdef')).then(repeat('abcdef'));
expect(wrapper.find('.warning-message')).toHaveLength(0);
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(false);
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false);
});
it('calls onCreate with provided name and password', async () => {
await enterName(account.name).then(password(account.password)).then(repeat(account.password));
wrapper.find('[data-button-action="add new root"] button').simulate('click');
await act(flushAllPromises);
expect(onCreate).toHaveBeenCalledWith(account.name, account.password);
});
describe('All fields are filled correctly, but then', () => {
beforeEach(async () => {
await enterName(account.name).then(password(account.password)).then(repeat(account.password));
});
it('first password input is cleared - second one disappears and button get disabled', async () => {
await type(wrapper.find('input[type="password"]').first(), '');
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
});
it('first password changes - button is disabled', async () => {
await type(wrapper.find('input[type="password"]').first(), 'abcdef');
expect(wrapper.find('.warning-message').text()).toBe('Passwords do not match');
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
});
it('first password changes, then second changes too - button is enabled', async () => {
await type(wrapper.find('input[type="password"]').first(), 'abcdef');
await type(wrapper.find('input[type="password"]').last(), 'abcdef');
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false);
});
it('second password changes, then first changes too - button is enabled', async () => {
await type(wrapper.find('input[type="password"]').last(), 'abcdef');
await type(wrapper.find('input[type="password"]').first(), 'abcdef');
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false);
});
it('name is removed - button is disabled', async () => {
await enterName('');
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
});
});
});
describe('AccountNamePasswordCreation busy button', () => {
beforeAll(async () => {
wrapper = mountComponent(true);
await act(flushAllPromises);
wrapper.update();
});
it('button is busy', () => {
expect(wrapper.find(NextStepButton).prop('isBusy')).toBe(true);
});
});
@@ -0,0 +1,83 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useState } from 'react';
import { Name, Password } from '../partials/index.js';
import { BackButton, ButtonArea, NextStepButton, VerticalSpace } from './index.js';
interface Props {
buttonLabel?: string;
isBusy: boolean;
onBackClick?: () => void;
onCreate: (name: string, password: string) => void | Promise<void | boolean>;
onNameChange: (name: string) => void;
onPasswordChange?: (password: string) => void;
}
function AccountNamePasswordCreation ({ buttonLabel, isBusy, onBackClick, onCreate, onNameChange, onPasswordChange }: Props): React.ReactElement<Props> {
const [name, setName] = useState<string | null>(null);
const [password, setPassword] = useState<string | null>(null);
const _onCreate = useCallback(
(): void => {
if (name && password) {
Promise
.resolve(onCreate(name, password))
.catch(console.error);
}
},
[name, password, onCreate]
);
const _onNameChange = useCallback(
(name: string | null) => {
onNameChange(name || '');
setName(name);
},
[onNameChange]
);
const _onPasswordChange = useCallback(
(password: string | null) => {
onPasswordChange && onPasswordChange(password || '');
setPassword(password);
},
[onPasswordChange]
);
const _onBackClick = useCallback(
() => {
_onNameChange(null);
setPassword(null);
onBackClick && onBackClick();
},
[_onNameChange, onBackClick]
);
return (
<>
<Name
isFocused
onChange={_onNameChange}
/>
<Password onChange={_onPasswordChange} />
<VerticalSpace />
{onBackClick && buttonLabel && (
<ButtonArea>
<BackButton onClick={_onBackClick} />
<NextStepButton
data-button-action='add new root'
isBusy={isBusy}
isDisabled={!password || !name}
onClick={_onCreate}
>
{buttonLabel}
</NextStepButton>
</ButtonArea>
)}
</>
);
}
export default React.memo(AccountNamePasswordCreation);
@@ -0,0 +1,35 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { styled } from '../styled.js';
interface Props {
children: React.ReactNode;
className?: string;
}
function ActionBar ({ children, className }: Props): React.ReactElement<Props> {
return (
<div className={className}>
{children}
</div>
);
}
export default styled(ActionBar)<Props>`
align-content: flex-end;
display: flex;
justify-content: space-between;
padding: 0.25rem;
text-align: right;
a {
cursor: pointer;
}
a+a {
margin-left: 0.75rem;
}
`;
@@ -0,0 +1,48 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
import type { MouseEventHandler } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { styled } from '../styled.js';
interface Props {
className?: string;
icon?: IconDefinition;
onClick: MouseEventHandler<HTMLDivElement>;
text: string;
}
function ActionText ({ className, icon, onClick, text }: Props): React.ReactElement<Props> {
return (
<div
className={className}
onClick={onClick}
>
{icon && <FontAwesomeIcon icon={icon} />}
<span>{text}</span>
</div>
);
}
export default styled(ActionText)<Props>`
cursor: pointer;
span {
color: var(--labelColor);
font-size: var(--labelFontSize);
line-height: var(--labelLineHeight);
text-decoration-line: underline;
}
.svg-inline--fa {
color: var(--iconNeutralColor);
display: inline-block;
margin-right: 0.3rem;
position: relative;
top: 2px;
}
`;
@@ -0,0 +1,351 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import '@pezkuwi/extension-mocks/chrome';
import type { ReactWrapper } from 'enzyme';
import type * as _ from '@pezkuwi/dev-test/globals.d.ts';
import type { AccountJson } from '@pezkuwi/extension-base/background/types';
import type { IconTheme } from '@pezkuwi/react-identicon/types';
import type { HexString } from '@pezkuwi/util/types';
import type { Props as AddressComponentProps } from './Address.js';
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
import enzyme from 'enzyme';
import React from 'react';
import { act } from 'react-dom/test-utils';
import * as messaging from '../messaging.js';
import * as MetadataCache from '../MetadataCache.js';
import { westendMetadata } from '../Popup/Signing/metadataMock.js';
import { flushAllPromises } from '../testHelpers.js';
import { buildHierarchy } from '../util/buildHierarchy.js';
import { DEFAULT_TYPE } from '../util/defaultType.js';
import getParentNameSuri from '../util/getParentNameSuri.js';
import { AccountContext, Address } from './index.js';
const { configure, mount } = enzyme;
// NOTE Required for spyOn when using @swc/jest
// https://github.com/swc-project/swc/issues/3843
// jest.mock('../messaging', (): Record<string, unknown> => ({
// __esModule: true,
// ...jest.requireActual('../messaging')
// }));
// jest.mock('../MetadataCache', (): Record<string, unknown> => ({
// __esModule: true,
// ...jest.requireActual('../MetadataCache')
// }));
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call
configure({ adapter: new Adapter() });
interface AccountTestJson extends AccountJson {
expectedIconTheme: IconTheme
}
interface AccountTestGenesisJson extends AccountTestJson {
expectedEncodedAddress: string;
expectedNetworkLabel: string;
genesisHash: HexString;
}
const externalAccount = { address: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s', expectedIconTheme: 'polkadot', isExternal: true, name: 'External Account', type: 'sr25519' } as AccountJson;
const hardwareAccount = {
address: 'HDE6uFdw53SwUyfKSsjwZNmS2sziWMPuY6uJhGHcFzLYRaJ',
expectedIconTheme: 'polkadot',
// Kusama genesis hash
genesisHash: '0xb0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe',
isExternal: true,
isHardware: true,
name: 'Hardware Account',
type: 'sr25519'
} as AccountJson;
const accounts = [
{ address: '5HSDXAC3qEMkSzZK377sTD1zJhjaPiX5tNWppHx2RQMYkjaJ', expectedIconTheme: 'polkadot', name: 'ECDSA Account', type: 'ecdsa' },
{ address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', expectedIconTheme: 'polkadot', name: 'Ed Account', type: 'ed25519' },
{ address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q', expectedIconTheme: 'polkadot', name: 'Parent Sr Account', type: 'sr25519' },
{ address: '0xd5D81CD4236a43F48A983fc5B895975c511f634D', expectedIconTheme: 'ethereum', name: 'Ethereum', type: 'ethereum' },
{ ...externalAccount },
{ ...hardwareAccount }
] as AccountTestJson[];
// With Westend genesis Hash
// This account isn't part of the generic test because Westend isn't a built in network
// The network would only be displayed if the corresponding metadata are known
const westEndAccount = {
address: 'Cs2LLqQ6DSRx8UPdVp6jny4DvwNqziBSowSu5Nb1u3R6Z7X',
expectedEncodedAddress: '5CMQg2VXTrRWCUewro13qqc45Lf93KtzzS6hWR6dY6pvMZNF',
expectedIconTheme: 'polkadot',
expectedNetworkLabel: 'Westend',
genesisHash: '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e',
name: 'acc',
type: 'ed25519'
} as AccountTestGenesisJson;
const accountsWithGenesisHash = [
// with Polkadot genesis Hash
{
address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q',
expectedEncodedAddress: '15csxS8s2AqrX1etYMMspzF6V7hM56KEjUqfjJvWHP7YWkoF',
expectedIconTheme: 'polkadot',
expectedNetworkLabel: 'Polkadot',
genesisHash: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3',
type: 'sr25519'
},
// with Kusama genesis Hash
{
address: '5DoYawpxt6aBy1pKAt1beLMrakqtbWMtG3NF6jwRR8uKJGqD',
expectedEncodedAddress: 'EKAFGAqWTb7ifdkwapeYHirjM88QBB4iRCzVQDNtw7p3bgF',
expectedIconTheme: 'polkadot',
expectedNetworkLabel: 'Kusama',
genesisHash: '0xb0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe',
type: 'sr25519'
},
// with Edgeware genesis Hash
{
address: '5GYQRJj3NUznYDzCduENRcocMsyxmb6tjb5xW87ZMErBe9R7',
expectedEncodedAddress: 'mzKNamvvJPM5ApxwGSYD5VjjtyfrB4g8fhMyCc29K37nuop',
expectedIconTheme: 'substrate',
expectedNetworkLabel: 'Edgeware',
genesisHash: '0x742a2ca70c2fda6cee4f8df98d64c4c670a052d9568058982dad9d5a7a135c5b',
type: 'sr25519'
}
] as AccountTestGenesisJson[];
const mountComponent = async (addressComponentProps: AddressComponentProps, contextAccounts: AccountJson[]): Promise<{
wrapper: ReactWrapper;
}> => {
const actionStub = jest.fn();
const { actions = actionStub } = addressComponentProps;
const wrapper = mount(
<AccountContext.Provider
value={{
accounts: contextAccounts,
hierarchy: buildHierarchy(contextAccounts)
}}
>
<Address
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
actions={actions}
{...addressComponentProps}
/>
</AccountContext.Provider>
);
await act(flushAllPromises);
wrapper.update();
return { wrapper };
};
const getWrapper = async (account: AccountJson, contextAccounts: AccountJson[], withAccountsInContext: boolean) => {
// the address component can query info about the account from the account context
// in this case, the account's address (any encoding) should suffice
// In case the account is not in the context, then more info are needed as props
// to display accurately
const mountedComponent = withAccountsInContext
// only the address is passed as props, the full acount info are loaded in the context
? await mountComponent({ address: account.address }, contextAccounts)
// the context is empty, all account's info are passed as props to the Address component
: await mountComponent(account, []);
return mountedComponent.wrapper;
};
const genericTestSuite = (account: AccountTestJson, withAccountsInContext = true) => {
let wrapper: ReactWrapper;
const { address, expectedIconTheme, name = '', type = DEFAULT_TYPE } = account;
describe(`Account ${withAccountsInContext ? 'in context from address' : 'from props'} (${name}) - ${type}`, () => {
beforeAll(async () => {
wrapper = await getWrapper(account, accounts, withAccountsInContext);
});
it('shows the account address and name', () => {
expect(wrapper.find('[data-field="address"]').text()).toEqual(address);
expect(wrapper.find('Name span').text()).toEqual(name);
});
it(`shows a ${expectedIconTheme} identicon`, () => {
expect(wrapper.find('Identicon').first().prop('iconTheme')).toEqual(expectedIconTheme);
});
it('can copy its address', () => {
// the first CopyToClipboard is from the identicon, the second from the copy button
expect(wrapper.find('CopyToClipboard').at(0).prop('text')).toEqual(address);
expect(wrapper.find('CopyToClipboard').at(1).prop('text')).toEqual(address);
});
it('has the account visiblity icon', () => {
expect(wrapper.find('FontAwesomeIcon.visibleIcon')).toHaveLength(1);
});
it('can hide the account', () => {
jest.spyOn(messaging, 'showAccount').mockImplementation(() => Promise.resolve(false));
const visibleIcon = wrapper.find('FontAwesomeIcon.visibleIcon');
const hiddenIcon = wrapper.find('FontAwesomeIcon.hiddenIcon');
expect(visibleIcon.exists()).toBe(true);
expect(hiddenIcon.exists()).toBe(false);
visibleIcon.simulate('click');
expect(messaging.showAccount).toHaveBeenCalledWith(address, false);
});
it('can show the account if hidden', async () => {
const additionalProps = { isHidden: true };
const mountedHiddenComponent = withAccountsInContext
? await mountComponent({ address, ...additionalProps }, accounts)
: await mountComponent({ ...account, ...additionalProps }, []);
const wrapperHidden = mountedHiddenComponent.wrapper;
jest.spyOn(messaging, 'showAccount').mockImplementation(() => Promise.resolve(true));
const visibleIcon = wrapperHidden.find('FontAwesomeIcon.visibleIcon');
const hiddenIcon = wrapperHidden.find('FontAwesomeIcon.hiddenIcon');
expect(visibleIcon.exists()).toBe(false);
expect(hiddenIcon.exists()).toBe(true);
hiddenIcon.simulate('click');
expect(messaging.showAccount).toHaveBeenCalledWith(address, true);
});
it('has settings button', () => {
expect(wrapper.find('.settings')).toHaveLength(1);
});
it('has no account hidding and settings button if no action is provided', async () => {
const additionalProps = { actions: null };
const mountedComponentWithoutAction = withAccountsInContext
? await mountComponent({ address, ...additionalProps }, accounts)
: await mountComponent({ ...account, ...additionalProps }, []);
wrapper = mountedComponentWithoutAction.wrapper;
expect(wrapper.find('.settings')).toHaveLength(0);
});
});
};
const genesisHashTestSuite = (account: AccountTestGenesisJson, withAccountsInContext = true) => {
const { expectedEncodedAddress, expectedIconTheme, expectedNetworkLabel } = account;
describe(`Account ${withAccountsInContext ? 'in context from address' : 'from props'} with ${expectedNetworkLabel} genesiHash`, () => {
let wrapper: ReactWrapper;
beforeAll(async () => {
wrapper = await getWrapper(account, accountsWithGenesisHash, withAccountsInContext);
});
it('shows the account address correctly encoded', () => {
expect(wrapper.find('[data-field="address"]').text()).toEqual(expectedEncodedAddress);
});
it(`shows a ${expectedIconTheme} identicon`, () => {
expect(wrapper.find('Identicon').first().prop('iconTheme')).toEqual(expectedIconTheme);
});
it('Copy buttons contain the encoded address', () => {
// the first CopyToClipboard is from the identicon, the second from the copy button
expect(wrapper.find('CopyToClipboard').at(0).prop('text')).toEqual(expectedEncodedAddress);
expect(wrapper.find('CopyToClipboard').at(1).prop('text')).toEqual(expectedEncodedAddress);
});
it('Network label shows the correct network', () => {
expect(wrapper.find('[data-field="chain"]').text()).toEqual(expectedNetworkLabel);
});
});
};
describe('Address', () => {
accounts.forEach((account) => {
genericTestSuite(account);
genericTestSuite(account, false);
});
accountsWithGenesisHash.forEach((account) => {
genesisHashTestSuite(account);
genesisHashTestSuite(account, false);
});
describe('External account', () => {
let wrapper: ReactWrapper;
beforeAll(async () => {
wrapper = await getWrapper(externalAccount, [], false);
});
it('has an icon in front of its name', () => {
expect(wrapper.find('Name').find('FontAwesomeIcon [data-icon="qrcode"]').exists()).toBe(true);
});
});
describe('Hardware wallet account', () => {
let wrapper: ReactWrapper;
beforeAll(async () => {
wrapper = await getWrapper(hardwareAccount, [], false);
});
it('has a usb icon in front of its name', () => {
expect(wrapper.find('Name').find('FontAwesomeIcon [data-icon="usb"]').exists()).toBe(true);
});
});
describe('Encoding and label based on Metadata', () => {
let wrapper: ReactWrapper;
beforeAll(async () => {
jest.spyOn(MetadataCache, 'getSavedMeta').mockImplementation(() => Promise.resolve(westendMetadata));
wrapper = await getWrapper(westEndAccount, [], false);
});
it('shows westend label with the correct color', () => {
const bannerChain = wrapper.find('[data-field="chain"]');
expect(bannerChain.text()).toEqual(westendMetadata.chain);
expect(bannerChain.prop('style')?.backgroundColor).toEqual(westendMetadata.color);
});
it('shows the account correctly reencoded', () => {
expect(wrapper.find('[data-field="address"]').text()).toEqual(westEndAccount.expectedEncodedAddress);
});
});
describe('Derived accounts', () => {
let wrapper: ReactWrapper;
const childAccount = {
address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q',
name: 'Luke',
parentName: 'Dark Vador',
suri: '//42',
type: 'sr25519'
} as AccountJson;
beforeAll(async () => {
wrapper = await getWrapper(childAccount, [], false);
});
it('shows the child\'s account address and name', () => {
expect(wrapper.find('[data-field="address"]').text()).toEqual(childAccount.address);
expect(wrapper.find('Name span').text()).toEqual(childAccount.name);
});
it('shows the parent account and suri', () => {
const expectedParentNameSuri = getParentNameSuri(childAccount.parentName, childAccount.suri);
expect(wrapper.find('.parentName').text()).toEqual(expectedParentNameSuri);
});
});
});
@@ -0,0 +1,481 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountJson, AccountWithChildren } from '@pezkuwi/extension-base/background/types';
import type { Chain } from '@pezkuwi/extension-chains/types';
import type { IconTheme } from '@pezkuwi/react-identicon/types';
import type { SettingsStruct } from '@pezkuwi/ui-settings/types';
import type { KeypairType } from '@pezkuwi/util-crypto/types';
import { faUsb } from '@fortawesome/free-brands-svg-icons';
import { faCopy, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
import { faCodeBranch, faQrcode } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
import CopyToClipboard from 'react-copy-to-clipboard';
import { hexToU8a, isHex } from '@pezkuwi/util';
import { decodeAddress, encodeAddress } from '@pezkuwi/util-crypto';
import details from '../assets/details.svg';
import { useMetadata, useOutsideClick, useToast, useTranslation } from '../hooks/index.js';
import { showAccount } from '../messaging.js';
import { styled } from '../styled.js';
import { DEFAULT_TYPE } from '../util/defaultType.js';
import getParentNameSuri from '../util/getParentNameSuri.js';
import { AccountContext, SettingsContext } from './contexts.js';
import Identicon from './Identicon.js';
import Menu from './Menu.js';
import Svg from './Svg.js';
export interface Props {
actions?: React.ReactNode;
address?: string | null;
children?: React.ReactNode;
className?: string;
genesisHash?: string | null;
isExternal?: boolean | null;
isHardware?: boolean | null;
isHidden?: boolean;
name?: string | null;
parentName?: string | null;
showVisibilityAction?: boolean
suri?: string;
toggleActions?: number;
type?: KeypairType;
}
interface Recoded {
account: AccountJson | null;
formatted: string | null;
genesisHash?: string | null;
prefix?: number;
type: KeypairType;
}
// find an account in our list
function findSubstrateAccount (accounts: AccountJson[], publicKey: Uint8Array): AccountJson | null {
const pkStr = publicKey.toString();
return accounts.find(({ address }): boolean =>
decodeAddress(address).toString() === pkStr
) || null;
}
// find an account in our list
function findAccountByAddress (accounts: AccountJson[], _address: string): AccountJson | null {
return accounts.find(({ address }): boolean =>
address === _address
) || null;
}
// recodes an supplied address using the prefix/genesisHash, include the actual saved account & chain
function recodeAddress (address: string, accounts: AccountWithChildren[], chain: Chain | null, settings: SettingsStruct): Recoded {
// decode and create a shortcut for the encoded address
const publicKey = isHex(address) ? hexToU8a(address) : decodeAddress(address);
// find our account using the actual publicKey, and then find the associated chain
const account = findSubstrateAccount(accounts, publicKey);
const prefix = chain ? chain.ss58Format : (settings.prefix === -1 ? 42 : settings.prefix);
// always allow the actual settings to override the display
return {
account,
formatted: account?.type === 'ethereum'
? address
: encodeAddress(publicKey, prefix),
genesisHash: account?.genesisHash,
prefix,
type: account?.type || DEFAULT_TYPE
};
}
const ACCOUNTS_SCREEN_HEIGHT = 550;
const defaultRecoded = { account: null, formatted: null, prefix: 42, type: DEFAULT_TYPE };
function Address ({ actions, address, children, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, showVisibilityAction = false, suri, toggleActions, type: givenType }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { accounts } = useContext(AccountContext);
const settings = useContext(SettingsContext);
const [{ account, formatted, genesisHash: recodedGenesis, prefix, type }, setRecoded] = useState<Recoded>(defaultRecoded);
const chain = useMetadata(genesisHash || recodedGenesis, true);
const [showActionsMenu, setShowActionsMenu] = useState(false);
const [moveMenuUp, setIsMovedMenu] = useState(false);
const actIconRef = useRef<HTMLDivElement>(null);
const actMenuRef = useRef<HTMLDivElement>(null);
const { show } = useToast();
useOutsideClick([actIconRef, actMenuRef], () => (showActionsMenu && setShowActionsMenu(!showActionsMenu)));
useEffect((): void => {
if (!address) {
return setRecoded(defaultRecoded);
}
const account = findAccountByAddress(accounts, address);
setRecoded(
(
chain?.definition.chainType === 'ethereum' ||
account?.type === 'ethereum' ||
(!account && givenType === 'ethereum')
)
? { account, formatted: address, type: 'ethereum' }
: recodeAddress(address, accounts, chain, settings)
);
}, [accounts, address, chain, givenType, settings]);
useEffect(() => {
if (!showActionsMenu) {
setIsMovedMenu(false);
} else if (actMenuRef.current) {
const { bottom } = actMenuRef.current.getBoundingClientRect();
if (bottom > ACCOUNTS_SCREEN_HEIGHT) {
setIsMovedMenu(true);
}
}
}, [showActionsMenu]);
useEffect((): void => {
setShowActionsMenu(false);
}, [toggleActions]);
const theme = (
type === 'ethereum'
? 'ethereum'
: (chain?.icon || 'polkadot')
) as IconTheme;
const _onClick = useCallback(
() => setShowActionsMenu(!showActionsMenu),
[showActionsMenu]
);
const _onCopy = useCallback(
() => show(t('Copied')),
[show, t]
);
const _toggleVisibility = useCallback(
(): void => {
address && showAccount(address, isHidden || false).catch(console.error);
},
[address, isHidden]
);
const Name = () => {
const accountName = name || account?.name;
const displayName = accountName || t('<unknown>');
return (
<>
{!!accountName && (account?.isExternal || isExternal) && (
(account?.isHardware || isHardware)
? (
<FontAwesomeIcon
className='hardwareIcon'
icon={faUsb}
rotation={270}
title={t('hardware wallet account')}
/>
)
: (
<FontAwesomeIcon
className='externalIcon'
icon={faQrcode}
title={t('external account')}
/>
)
)}
<span title={displayName}>{displayName}</span>
</>);
};
const parentNameSuri = getParentNameSuri(parentName, suri);
return (
<div className={className}>
<div className='infoRow'>
<Identicon
className='identityIcon'
iconTheme={theme}
isExternal={isExternal}
onCopy={_onCopy}
prefix={prefix}
value={formatted || address}
/>
<div className='info'>
{parentName
? (
<>
<div className='banner'>
<FontAwesomeIcon
className='deriveIcon'
icon={faCodeBranch}
/>
<div
className='parentName'
data-field='parent'
title = {parentNameSuri}
>
{parentNameSuri}
</div>
</div>
<div className='name displaced'>
<Name />
</div>
</>
)
: (
<div
className='name'
data-field='name'
>
<Name />
</div>
)
}
{chain?.genesisHash && chain?.name && (
<div
className='banner chain'
data-field='chain'
style={
chain.definition.color
? { backgroundColor: chain.definition.color }
: undefined
}
>
{chain.name.replace(' Relay Chain', '')}
</div>
)}
<div className='addressDisplay'>
<div
className='fullAddress'
data-field='address'
>
{formatted || address || t('<unknown>')}
</div>
<CopyToClipboard text={(formatted && formatted) || ''}>
<FontAwesomeIcon
className='copyIcon'
icon={faCopy}
onClick={_onCopy}
size='sm'
title={t('copy address')}
/>
</CopyToClipboard>
{(actions || showVisibilityAction) && (
<FontAwesomeIcon
className={isHidden ? 'hiddenIcon' : 'visibleIcon'}
icon={isHidden ? faEyeSlash : faEye}
onClick={_toggleVisibility}
size='sm'
title={t('account visibility')}
/>
)}
</div>
</div>
{actions && (
<>
<div
className='settings'
onClick={_onClick}
ref={actIconRef}
>
<Svg
className={`detailsIcon ${showActionsMenu ? 'active' : ''}`}
src={details}
/>
</div>
{showActionsMenu && (
<Menu
className={`movableMenu ${moveMenuUp ? 'isMoved' : ''}`}
ref={actMenuRef}
>
{actions}
</Menu>
)}
</>
)}
</div>
{children}
</div>
);
}
export default styled(Address)<Props>`
background: var(--boxBackground);
border: 1px solid var(--boxBorderColor);
box-sizing: border-box;
border-radius: 4px;
margin-bottom: 8px;
position: relative;
.banner {
font-size: 12px;
line-height: 16px;
position: absolute;
top: 0;
&.chain {
background: var(--primaryColor);
border-radius: 0 0 0 10px;
color: white;
padding: 0.1rem 0.5rem 0.1rem 0.75rem;
right: 0;
z-index: 1;
}
}
.addressDisplay {
display: flex;
justify-content: space-between;
position: relative;
.svg-inline--fa {
width: 14px;
height: 14px;
margin-right: 10px;
color: var(--accountDotsIconColor);
&:hover {
color: var(--labelColor);
cursor: pointer;
}
}
.hiddenIcon, .visibleIcon {
position: absolute;
right: 2px;
top: -18px;
}
.hiddenIcon {
color: var(--errorColor);
&:hover {
color: var(--accountDotsIconColor);
}
}
}
.externalIcon, .hardwareIcon {
margin-right: 0.3rem;
color: var(--labelColor);
width: 0.875em;
}
.identityIcon {
margin-left: 15px;
margin-right: 10px;
& svg {
width: 50px;
height: 50px;
}
}
.info {
width: 100%;
}
.infoRow {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
height: 72px;
border-radius: 4px;
}
img {
max-width: 50px;
max-height: 50px;
border-radius: 50%;
}
.name {
font-size: 16px;
line-height: 22px;
margin: 2px 0;
overflow: hidden;
text-overflow: ellipsis;
width: 300px;
white-space: nowrap;
&.displaced {
padding-top: 10px;
}
}
.parentName {
color: var(--labelColor);
font-size: var(--inputLabelFontSize);
line-height: 14px;
overflow: hidden;
padding: 0.25rem 0 0 0.8rem;
text-overflow: ellipsis;
width: 270px;
white-space: nowrap;
}
.fullAddress {
overflow: hidden;
text-overflow: ellipsis;
color: var(--labelColor);
font-size: 12px;
line-height: 16px;
}
.detailsIcon {
background: var(--accountDotsIconColor);
width: 3px;
height: 19px;
&.active {
background: var(--primaryColor);
}
}
.deriveIcon {
color: var(--labelColor);
position: absolute;
top: 5px;
width: 9px;
height: 9px;
}
.movableMenu {
margin-top: -20px;
right: 28px;
top: 0;
&.isMoved {
top: auto;
bottom: 0;
}
}
.settings {
position: relative;
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 40px;
&:before {
content: '';
position: absolute;
left: 0;
top: 25%;
bottom: 25%;
width: 1px;
background: var(--boxBorderColor);
}
&:hover {
cursor: pointer;
background: var(--readonlyInputBackground);
}
}
`;
@@ -0,0 +1,45 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { styled } from '../styled.js';
import Button from './Button.js';
interface Props {
className?: string;
onClick: () => void;
}
function BackButton ({ className, onClick }: Props): React.ReactElement<Props> {
return (
<Button
className={className}
onClick={onClick}
>
<FontAwesomeIcon
className='arrowLeft'
icon={faArrowLeft}
size='sm'
/>
</Button>
);
}
export default styled(BackButton)<Props>`
background: var(--backButtonBackground);
margin-right: 11px;
width: 42px;
.arrowLeft {
color: var(--backButtonTextColor);
display: block;
margin: auto;
}
&:not(:disabled):hover {
background: var(--backButtonBackgroundHover);
}
`;
@@ -0,0 +1,44 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { styled } from '../styled.js';
interface Props {
banner?: React.ReactNode;
children: React.ReactNode;
className?: string;
}
function Box ({ banner, children, className }: Props): React.ReactElement<Props> {
return (
<article className={className}>
{children}
{banner && <div className='banner'>{banner}</div>}
</article>
);
}
export default styled(Box)<Props>`
background: var(--readonlyInputBackground);
border: 1px solid var(--inputBorderColor);
border-radius: var(--borderRadius);
color: var(--subTextColor);
font-family: var(--fontFamily);
font-size: var(--fontSize);
margin: 0.75rem 24px;
padding: var(--boxPadding);
position: relative;
.banner {
background: darkorange;
border-radius: 0 var(--borderRadius) 0 var(--borderRadius);
color: white;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
position: absolute;
right: 0;
top: 0;
}
`;
@@ -0,0 +1,42 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import styled from 'styled-components';
import Label from './Label.js';
interface Props {
className?: string;
label: string;
value?: string;
}
function BoxWithLabel ({ className, label, value }: Props): React.ReactElement<Props> {
return (
<Label
className={className}
label={label}
>
<div className='seedBox'>
<span>{value}</span>
</div>
</Label>
);
}
export default styled(BoxWithLabel)<Props>`
.seedBox {
background: var(--readonlyInputBackground);
box-shadow: none;
border-radius: var(--borderRadius);
border: 1px solid var(--inputBorderColor);
border-color: var(--inputBorderColor);
box-sizing: border-box;
display: block;
font-family: var(--fontFamily);
outline: none;
resize: none;
width: 100%;
}
`;
@@ -0,0 +1,106 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback } from 'react';
import { styled } from '../styled.js';
import Spinner from './Spinner.js';
export interface ButtonProps {
className?: string;
children?: React.ReactNode;
isBusy?: boolean;
isDanger?: boolean;
isDisabled?: boolean;
onClick?: () => void | Promise<void | boolean> | null;
to?: string;
}
function Button ({ children, className = '', isBusy, isDisabled, onClick, to }: ButtonProps): React.ReactElement<ButtonProps> {
const _onClick = useCallback(
(): void => {
if (isBusy || isDisabled) {
return;
}
onClick && Promise.resolve(onClick()).catch(console.error);
if (to) {
window.location.hash = to;
}
},
[isBusy, isDisabled, onClick, to]
);
return (
<button
className={`${className}${(isDisabled || isBusy) ? ' isDisabled' : ''}${isBusy ? ' isBusy' : ''}`}
disabled={isDisabled || isBusy}
onClick={_onClick}
>
<div className='children'>{children}</div>
<div className='disabledOverlay' />
<Spinner className='busyOverlay' />
</button>
);
}
export default styled(Button)<ButtonProps>(({ isDanger }) => `
background: var(${isDanger ? '--buttonBackgroundDanger' : '--buttonBackground'});
cursor: pointer;
display: block;
width: 100%;
height: ${isDanger ? '40px' : '48px'};
box-sizing: border-box;
border: none;
border-radius: var(--borderRadius);
color: var(--buttonTextColor);
font-size: 15px;
line-height: 20px;
padding: 0 1rem;
position: relative;
text-align: center;
&:disabled {
cursor: default;
}
&:not(:disabled):hover {
background: var(${isDanger ? '--buttonBackgroundDangerHover' : '--buttonBackgroundHover'});
}
.busyOverlay,
.disabledOverlay {
visibility: hidden;
}
.disabledOverlay {
background: rgba(96,96,96,0.75);
border-radius: var(--borderRadius);
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
}
svg {
margin-right: 0.3rem;
}
&.isBusy {
background: rgba(96,96,96,0.15);
.children {
opacity: 0.25;
}
.busyOverlay {
visibility: visible;
}
}
&.isDisabled .disabledOverlay {
visibility: visible;
}
`);
@@ -0,0 +1,33 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { styled } from '../styled.js';
interface Props {
className?: string;
children: React.ReactNode;
}
function ButtonArea ({ children, className }: Props): React.ReactElement<Props> {
return (
<div className={className}>
{children}
</div>
);
}
export default styled(ButtonArea)<Props>`
display: flex;
flex-direction: row;
background: var(--highlightedAreaBackground);
border-top: 1px solid var(--inputBorderColor);
padding: 12px 24px;
margin-left: 0;
margin-right: 0;
& > button:not(:last-of-type) {
margin-right: 8px;
}
`;
@@ -0,0 +1,43 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { styled } from '../styled.js';
import { Button } from './index.js';
interface ButtonWithSubtitleProps {
title: string;
subTitle: string;
children?: string;
to: string;
}
export default function ButtonWithSubtitle ({ children, subTitle, title, to }: ButtonWithSubtitleProps): React.ReactElement<ButtonWithSubtitleProps> {
return (
<StyledButton to={to}>
<p>{title}</p>
<span>{subTitle}</span>
{children}
</StyledButton>
);
}
const StyledButton = styled(Button)`
button {
padding-top: 0;
padding-bottom: 0;
}
p {
margin: 0;
font-size: 15px;
line-height: 20px;
}
span {
display: block;
font-size: 12px;
line-height: 16px;
}
`;
@@ -0,0 +1,113 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useEffect } from 'react';
import Checkmark from '../assets/checkmark.svg';
import { styled } from '../styled.js';
interface Props {
checked: boolean;
indeterminate?: boolean;
className?: string;
label: string;
onChange?: (checked: boolean) => void;
onClick?: () => void;
}
function Checkbox ({ checked, className, indeterminate, label, onChange, onClick }: Props): React.ReactElement<Props> {
const checkboxRef = React.useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = !!indeterminate;
}
}, [indeterminate]);
const _onChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => onChange && onChange(event.target.checked),
[onChange]
);
const _onClick = useCallback(
() => onClick && onClick(),
[onClick]
);
return (
<div className={className}>
<label>
{label}
<input
checked={checked && !indeterminate}
onChange={_onChange}
onClick={_onClick}
ref={checkboxRef}
type='checkbox'
/>
<span />
</label>
</div>
);
}
export default styled(Checkbox)<Props>`
margin: var(--boxMargin);
label {
display: block;
position: relative;
cursor: pointer;
user-select: none;
padding-left: 24px;
padding-top: 1px;
color: var(--subTextColor);
font-size: var(--fontSize);
line-height: var(--lineHeight);
& input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
& span {
position: absolute;
top: 4px;
left: 0;
height: 16px;
width: 16px;
border-radius: var(--borderRadius);
background-color: var(--readonlyInputBackground);
border: 1px solid var(--inputBorderColor);
border: 1px solid var(--inputBorderColor);
&:after {
content: '';
display: none;
width: 13px;
height: 10px;
position: absolute;
left: 1px;
top: 2px;
mask: url(${Checkmark});
mask-size: cover;
background: var(--primaryColor);
}
}
&:hover input ~ span {
background-color: var(--inputBackground);
}
input:checked ~ span:after {
display: block;
}
input:indeterminate ~ span {
background: var(--primaryColor)
}
}
`;
@@ -0,0 +1,102 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback } from 'react';
import arrow from '../assets/arrow-down.svg';
import { styled } from '../styled.js';
import Label from './Label.js';
interface DropdownOption {
text: string | React.ReactNode;
value?: string | number | null;
}
interface Props {
className?: string;
defaultValue?: string | null;
isDisabled?: boolean
isError?: boolean;
isFocussed?: boolean;
label: string;
onBlur?: () => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange?: (value: any) => void;
options: DropdownOption[];
value?: number | string | null;
}
function Dropdown ({ className, defaultValue, isDisabled, isFocussed, label, onBlur, onChange, options, value }: Props): React.ReactElement<Props> {
const _onChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLSelectElement>) =>
onChange && onChange(value.trim()),
[onChange]
);
return (
<>
<Label
className={className}
label={label}
>
<select
autoFocus={isFocussed}
defaultValue={defaultValue || undefined}
disabled={isDisabled}
onBlur={onBlur}
onChange={_onChange}
value={value ?? undefined}
>
{options.map(({ text, value }): React.ReactNode => (
<option
key={value}
value={value ?? undefined}
>
{text}
</option>
))}
</select>
</Label>
</>
);
}
export default React.memo(styled(Dropdown)<Props>(({ isError, label }) => `
position: relative;
select {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background: var(--readonlyInputBackground);
border-color: var(${isError ? '--errorBorderColor' : '--inputBorderColor'});
border-radius: var(--borderRadius);
border-style: solid;
border-width: 1px;
box-sizing: border-box;
color: var(${isError ? '--errorBorderColor' : '--textColor'});
display: block;
font-family: var(--fontFamily);
font-size: var(--fontSize);
padding: 0.5rem 0.75rem;
width: 100%;
cursor: pointer;
&:read-only {
box-shadow: none;
outline: none;
}
}
label::after {
content: '';
position: absolute;
top: ${label ? 'calc(50% + 14px)' : '50%'};
transform: translateY(-50%);
right: 12px;
width: 8px;
height: 6px;
background: url(${arrow}) center no-repeat;
pointer-events: none;
}
`));
@@ -0,0 +1,62 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributor
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Header from '../partials/Header';
import Button from './Button';
import ButtonArea from './ButtonArea';
import VerticalSpace from './VerticalSpace';
interface ErrorBoundaryProps {
children: React.ReactNode;
className?: string;
error?: Error | null;
trigger?: string;
}
const ErrorBoundary: React.FC<ErrorBoundaryProps> = ({ children, error: propError, trigger }) => {
const { t } = useTranslation();
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (error !== null && trigger) {
setError(null);
}
}, [error, trigger]);
useEffect(() => {
if (propError) {
setError(propError);
}
}, [propError]);
const goHome = useCallback(() => {
setError(null);
window.location.hash = '/';
}, [setError]);
if (error) {
return (
<>
<Header text={t('An error occurred')} />
<div>
{t('Something went wrong with the query and rendering of this component. {{message}}', {
replace: { message: error.message }
})}
</div>
<VerticalSpace />
<ButtonArea>
<Button onClick={goHome}>
{t('Back to home')}
</Button>
</ButtonArea>
</>
);
}
return <>{children}</>;
};
export default ErrorBoundary;
@@ -0,0 +1,31 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { styled } from '../styled.js';
interface Props {
className?: string;
icon: string;
onClick?: () => void;
}
function Icon ({ className = '', icon, onClick }: Props): React.ReactElement<Props> {
return (
<div
className={`${className} icon`}
onClick={onClick}
>
{icon}
</div>
);
}
export default styled(Icon)<Props>(({ onClick }) => `
background: white;
border-radius: 50%;
box-sizing: border-box;
cursor: ${onClick ? 'pointer' : 'inherit'};
text-align: center;
`);
@@ -0,0 +1,52 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { IconTheme } from '@pezkuwi/react-identicon/types';
import React from 'react';
import { Identicon as Icon } from '@pezkuwi/react-identicon';
import { styled } from '../styled.js';
interface Props {
className?: string;
iconTheme?: IconTheme;
isExternal?: boolean | null;
onCopy?: () => void;
prefix?: number;
value?: string | null;
}
function Identicon ({ className, iconTheme, onCopy, prefix, value }: Props): React.ReactElement<Props> {
return (
<div className={className}>
<Icon
className='icon'
onCopy={onCopy}
prefix={prefix}
size={64}
theme={iconTheme}
value={value}
/>
</div>
);
}
export default styled(Identicon)<Props>`
background: rgba(192, 192, 292, 0.25);
border-radius: 50%;
display: flex;
justify-content: center;
.container:before {
box-shadow: none;
background: var(--identiconBackground);
}
svg {
circle:first-of-type {
display: none;
}
}
`;
@@ -0,0 +1,146 @@
// Copyright 2017-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { DropzoneRef } from 'react-dropzone';
import React, { createRef, useCallback, useState } from 'react';
import Dropzone from 'react-dropzone';
import { formatNumber, hexToU8a, isHex, u8aToString } from '@pezkuwi/util';
import { useTranslation } from '../hooks/index.js';
import { styled } from '../styled.js';
import Label from './Label.js';
function classes (...classNames: (boolean | null | string | undefined)[]): string {
return classNames
.filter((className): boolean => !!className)
.join(' ');
}
export interface InputFileProps {
// Reference Example Usage: https://github.com/react-dropzone/react-dropzone/tree/master/examples/Accept
// i.e. MIME types: 'application/json, text/plain', or '.json, .txt'
className?: string;
accept?: string;
clearContent?: boolean;
convertHex?: boolean;
help?: React.ReactNode;
isDisabled?: boolean;
isError?: boolean;
label: string;
onChange?: (contents: Uint8Array, name: string) => void;
placeholder?: React.ReactNode | null;
withEllipsis?: boolean;
withLabel?: boolean;
}
interface FileState {
name: string;
size: number;
}
const BYTE_STR_0 = '0'.charCodeAt(0);
const BYTE_STR_X = 'x'.charCodeAt(0);
const NOOP = (): void => undefined;
function convertResult (result: ArrayBuffer, convertHex?: boolean): Uint8Array {
const data = new Uint8Array(result);
// this converts the input (if detected as hex), vai the hex conversion route
if (convertHex && data[0] === BYTE_STR_0 && data[1] === BYTE_STR_X) {
const hex = u8aToString(data);
if (isHex(hex)) {
return hexToU8a(hex);
}
}
return data;
}
function InputFile ({ accept, className = '', clearContent, convertHex, isDisabled, isError = false, label, onChange, placeholder }: InputFileProps): React.ReactElement<InputFileProps> {
const { t } = useTranslation();
const dropRef = createRef<DropzoneRef>();
const [file, setFile] = useState<FileState | undefined>();
const _onDrop = useCallback(
(files: File[]): void => {
files.forEach((file): void => {
const reader = new FileReader();
reader.onabort = NOOP;
reader.onerror = NOOP;
reader.onload = ({ target }: ProgressEvent<FileReader>): void => {
if (target?.result) {
const name = file.name;
const data = convertResult(target.result as ArrayBuffer, convertHex);
onChange && onChange(data, name);
dropRef && setFile({
name,
size: data.length
});
}
};
reader.readAsArrayBuffer(file);
});
},
[convertHex, dropRef, onChange]
);
const dropZone = (
<Dropzone
accept={accept}
disabled={isDisabled}
multiple={false}
onDrop={_onDrop}
ref={dropRef}
>
{({ getInputProps, getRootProps }): React.ReactElement => (
<div {...getRootProps({ className: classes('ui--InputFile', isError ? 'error' : '', className) })}>
<input {...getInputProps()} />
<em className='label'>
{
!file || clearContent
? placeholder || t('click to select or drag and drop the file here')
: placeholder || t('{{name}} ({{size}} bytes)', {
replace: {
name: file.name,
size: formatNumber(file.size)
}
})
}
</em>
</div>
)}
</Dropzone>
);
return label
? (
<Label
label={label}
>
{dropZone}
</Label>
)
: dropZone;
}
export default React.memo(styled(InputFile)<InputFileProps>(({ isError }) => `
border: 1px solid var(${isError ? '--errorBorderColor' : '--inputBorderColor'});
background: var(--inputBackground);
border-radius: var(--borderRadius);
color: var(${isError ? '--errorBorderColor' : '--textColor'});
font-size: 1rem;
margin: 0.25rem 0;
overflow-wrap: anywhere;
padding: 0.5rem 0.75rem;
&:hover {
cursor: pointer;
}
`));
@@ -0,0 +1,67 @@
// Copyright 2017-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { faTimes } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { useCallback, useRef } from 'react';
import { styled } from '../styled.js';
import { Input } from './TextInputs.js';
interface Props {
className?: string;
onChange: (filter: string) => void;
placeholder: string;
value: string;
withReset?: boolean;
}
function InputFilter ({ className, onChange, placeholder, value, withReset = false }: Props) {
const inputRef: React.RefObject<HTMLInputElement | null> = useRef(null);
const onChangeFilter = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
}, [onChange]);
const onResetFilter = useCallback(() => {
onChange('');
inputRef?.current && inputRef.current.select();
}, [onChange]);
return (
<div className={className}>
<Input
autoCapitalize='off'
autoCorrect='off'
autoFocus
onChange={onChangeFilter}
placeholder={placeholder}
ref={inputRef}
spellCheck={false}
type='text'
value={value}
/>
{withReset && !!value && (
<FontAwesomeIcon
className='resetIcon'
icon={faTimes}
onClick={onResetFilter}
/>
)}
</div>
);
}
export default styled(InputFilter)<Props>`
padding-left: 1rem !important;
padding-right: 1rem !important;
position: relative;
.resetIcon {
position: absolute;
right: 28px;
top: 12px;
color: var(--iconNeutralColor);
cursor: pointer;
}
`;
@@ -0,0 +1,95 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useState } from 'react';
import { useTranslation } from '../hooks/index.js';
import { styled } from '../styled.js';
import Label from './Label.js';
import { Input } from './TextInputs.js';
import Warning from './Warning.js';
interface Props {
className?: string;
defaultValue?: string | null;
disabled?: boolean;
isError?: boolean;
isFocused?: boolean;
isReadOnly?: boolean;
label: string;
onBlur?: () => void;
onFocus?: () => void;
onChange?: (value: string) => void;
onEnter?: () => void;
placeholder?: string;
type?: 'text' | 'password';
value?: string;
withoutMargin?: boolean;
}
function InputWithLabel ({ className, defaultValue, disabled, isError, isFocused, isReadOnly, label = '', onBlur, onChange, onEnter, onFocus, placeholder, type = 'text', value, withoutMargin }: Props): React.ReactElement<Props> {
const [isCapsLock, setIsCapsLock] = useState(false);
const { t } = useTranslation();
const _checkKey = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>): void => {
onEnter && event.key === 'Enter' && onEnter();
if (type === 'password') {
if (event.getModifierState('CapsLock')) {
setIsCapsLock(true);
} else {
setIsCapsLock(false);
}
}
},
[onEnter, type]
);
const _onChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>): void => {
onChange && onChange(value);
},
[onChange]
);
return (
<Label
className={`${className || ''} ${withoutMargin ? 'withoutMargin' : ''}`}
label={label}
>
<Input
autoCapitalize='off'
autoCorrect='off'
autoFocus={isFocused}
defaultValue={defaultValue || undefined}
disabled={disabled}
onBlur={onBlur}
onChange={_onChange}
onFocus={onFocus}
onKeyPress={_checkKey}
placeholder={placeholder}
readOnly={isReadOnly}
spellCheck={false}
type={type}
value={value}
withError={isError}
/>
{ isCapsLock && (
<Warning isBelowInput>{t('Warning: Caps lock is on')}</Warning>
)}
</Label>
);
}
export default styled(InputWithLabel)<Props>`
margin-bottom: 16px;
&.withoutMargin {
margin-bottom: 0px;
+ .danger {
margin-top: 6px;
}
}
`;
@@ -0,0 +1,34 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { styled } from '../styled.js';
interface Props {
children: React.ReactNode;
className?: string;
label: string;
}
function Label ({ children, className, label }: Props): React.ReactElement<Props> {
return (
<div className={className}>
<label>{label}</label>
{children}
</div>
);
}
export default styled(Label)<Props>`
color: var(--textColor);
label {
font-size: var(--inputLabelFontSize);
line-height: 14px;
letter-spacing: 0.04em;
opacity: 0.65;
margin-bottom: 12px;
text-transform: uppercase;
}
`;
@@ -0,0 +1,74 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { styled } from '../styled.js';
interface Props {
children?: React.ReactNode;
className?: string;
isDanger?: boolean;
isDisabled?: boolean;
onClick?: () => void;
title?: string;
to?: string;
}
function Link ({ children, className = '', isDisabled, onClick, title, to }: Props): React.ReactElement<Props> {
if (isDisabled) {
return (
<div
className={`${className} isDisabled`}
title={title}
>
{children}
</div>
);
}
return to
? (
<RouterLink
className={className}
onClick={onClick}
title={title}
to={to}
>
{children}
</RouterLink>
)
: (
<a
className={className}
href='#'
onClick={onClick}
title={title}
>
{children}
</a>
);
}
export default styled(Link)<Props>(({ isDanger }) => `
align-items: center;
color: var(${isDanger ? '--textColorDanger' : '--textColor'});
display: flex;
opacity: 0.85;
text-decoration: none;
vertical-align: middle;
&:hover {
color: var(${isDanger ? '--textColorDanger' : '--textColor'});
opacity: 1.0;
}
&:visited {
color: var(${isDanger ? '--textColorDanger' : '--textColor'});
}
&.isDisabled {
opacity: 0.4;
}
`);
@@ -0,0 +1,38 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { styled } from '../styled.js';
interface Props {
className?: string;
children: React.ReactNode;
}
const List = ({ children, className }: Props) => (
<ul className={className}>
{children}
</ul>
);
export default styled(List)<Props>`
list-style: none;
padding-inline-start: 10px;
padding-inline-end: 10px;
text-indent: -22px;
margin-left: 21px;
li {
margin-bottom: 8px;
}
li::before {
content: '\\2022';
color: var(--primaryColor);
font-size: 30px;
font-weight: bold;
margin-right: 10px;
vertical-align: -20%;
}
`;
@@ -0,0 +1,24 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { useTranslation } from '../hooks/index.js';
interface Props {
children?: React.ReactNode;
}
export default function Loading ({ children }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
if (!children) {
return (
<div>{t('... loading ...')}</div>
);
}
return (
<>{children}</>
);
}
@@ -0,0 +1,39 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { styled } from '../styled.js';
interface Props {
children: React.ReactNode;
className?: string;
}
function Main ({ children, className }: Props): React.ReactElement<Props> {
return (
<main className={className}>
{children}
</main>
);
}
export default styled(Main)<Props>`
display: flex;
flex-direction: column;
height: calc(100vh - 2px);
background: var(--background);
color: var(--textColor);
font-size: var(--fontSize);
line-height: var(--lineHeight);
border: 1px solid var(--inputBorderColor);
* {
font-family: var(--fontFamily);
}
> * {
padding-left: 24px;
padding-right: 24px;
}
`;
@@ -0,0 +1,37 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { forwardRef } from 'react';
import { styled } from '../styled.js';
interface Props {
children: React.ReactNode;
className?: string;
}
const Menu = forwardRef<HTMLDivElement, Props>(({ children, className }, ref) => {
return (
<div
className={className}
ref={ref}
>
{children}
</div>
);
});
Menu.displayName = 'Menu';
export default styled(Menu)<Props>`
background: var(--popupBackground);
border-radius: 4px;
border: 1px solid var(--boxBorderColor);
box-sizing: border-box;
box-shadow: 0 0 10px var(--boxShadow);
margin-top: 60px;
padding: 16px 0;
position: absolute;
right: 0;
z-index: 2;
`;
@@ -0,0 +1,22 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { styled } from '../styled.js';
interface Props {
className?: string;
}
function MenuDivider ({ className }: Props): React.ReactElement<Props> {
return (
<div className={className} />
);
}
export default styled(MenuDivider)<Props>`
padding-top: 16px;
margin-bottom: 16px;
border-bottom: 1px solid var(--inputBorderColor);
`;
@@ -0,0 +1,45 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { styled } from '../styled.js';
interface Props {
children: React.ReactNode;
className?: string;
noBorder?: boolean;
title?: React.ReactNode;
}
function MenuItem ({ children, className = '', title }: Props): React.ReactElement<Props> {
return (
<div className={`${className}${title ? ' isTitled' : ''}`}>
{title && (
<div className='itemTitle'>{title}</div>
)}
{children}
</div>
);
}
export default styled(MenuItem)<Props>`
min-width: 13rem;
padding: 0 16px;
max-width: 100%;
> .itemTitle {
margin: 0;
width: 100%;
font-size: var(--inputLabelFontSize);
line-height: 14px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--textColor);
opacity: 0.65;
}
&+&.isTitled {
margin-top: 16px;
}
`;
@@ -0,0 +1,69 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { MouseEventHandler } from 'react';
import { faCopy } from '@fortawesome/free-regular-svg-icons';
import React from 'react';
import CopyToClipboard from 'react-copy-to-clipboard';
import { useTranslation } from '../hooks/index.js';
import { styled } from '../styled.js';
import ActionText from './ActionText.js';
import BoxWithLabel from './BoxWithLabel.js';
interface Props {
seed: string;
onCopy: MouseEventHandler<HTMLDivElement>;
className?: string;
}
function MnemonicSeed ({ className, onCopy, seed }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
return (
<div className={className}>
<BoxWithLabel
className='mnemonicDisplay'
label={t('Generated 12-word mnemonic seed:')}
value={seed}
/>
<div className='buttonsRow'>
<CopyToClipboard text={seed}>
<ActionText
className='copyBtn'
data-seed-action='copy'
icon={faCopy}
onClick={onCopy}
text={t('Copy to clipboard')}
/>
</CopyToClipboard>
</div>
</div>
);
}
export default styled(MnemonicSeed)<Props>`
margin-bottom: 21px;
.buttonsRow {
display: flex;
flex-direction: row;
.copyBtn {
margin-right: 32px;
}
}
.mnemonicDisplay {
.seedBox {
color: var(--primaryColor);
font-size: var(--fontSize);
height: unset;
letter-spacing: -0.01em;
line-height: var(--lineHeight);
margin-bottom: 10px;
padding: 14px;
}
}
`;
@@ -0,0 +1,33 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ButtonProps } from './Button.js';
import { faArrowRight } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { styled } from '../styled.js';
import Button from './Button.js';
function NextStepButton ({ children, ...props }: ButtonProps): React.ReactElement<ButtonProps> {
return (
<Button {...props}>
{children}
<FontAwesomeIcon
className='arrowRight'
icon={faArrowRight}
size='sm'
/>
</Button>
);
}
export default styled(NextStepButton)<ButtonProps>`
.arrowRight{
float: right;
margin-top: 4px;
margin-right: 1px;
color: var(--buttonTextColor);
}
`;
@@ -0,0 +1,74 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { PasswordStrength } from '../util/passwordValidation.js';
import React from 'react';
import { styled } from '../styled.js';
interface Props {
className?: string;
passwordStrength: PasswordStrength;
}
function PasswordStrengthIndicator ({ className, passwordStrength }: Props): React.ReactElement<Props> {
const { feedback, score } = passwordStrength;
const strengthColors = [
'#ff4444', // Very Weak - Red
'#ffa700', // Weak - Orange
'#ffeb3b', // Medium - Yellow
'#00C853', // Strong - Light Green
'#00695C' // Very Strong - Dark Green
];
return (
<div className={className}>
<div className='strength-meter'>
{[0, 1, 2, 3, 4].map((index) => (
<div
className={`strength-segment ${index <= score ? 'active' : ''}`}
key={index}
style={{
backgroundColor: index <= score ? strengthColors[score] : '#e0e0e0'
}}
/>
))}
</div>
{feedback.suggestions.length > 0 && (
<ul className='suggestions'>
{feedback.suggestions.map((suggestion, index) => (
<li key={index}>{suggestion}</li>
))}
</ul>
)}
</div>
);
}
export default styled(PasswordStrengthIndicator)`
.strength-meter {
margin-top: 0.5rem;
display: flex;
margin-bottom: 0.5rem;
}
.strength-segment {
flex: 1;
height: 4px;
margin: 0 2px;
}
.strength-label {
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.suggestions {
margin: 0.5rem 0;
padding-left: 1.5rem;
font-size: 0.85rem;
color: var(--warning-color);
}
`;
@@ -0,0 +1,34 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { faTrash } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { styled } from '../styled.js';
interface Props {
className?: string
onRemove: () => void
}
function RemoveAuth ({ className, onRemove }: Props): React.ReactElement {
return (
<FontAwesomeIcon
className={className}
icon={faTrash}
onClick={onRemove}
size='lg'
/>
);
}
export default styled(RemoveAuth)<Props>`
cursor: pointer;
color: var(--labelColor);
margin-right: 1rem;
&.selected {
color: var(--primaryColor);
}
`;
@@ -0,0 +1,31 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import spinnerSrc from '../assets/spinner.png';
import { styled } from '../styled.js';
interface Props {
className?: string;
size?: 'normal';
}
function Spinner ({ className = '', size = 'normal' }: Props): React.ReactElement<Props> {
return (
<img
className={`${className} ${size}Size`}
src={spinnerSrc}
/>
);
}
export default React.memo(styled(Spinner)<Props>`
bottom: 0rem;
height: 3rem;
left: 50%;
margin-left: -1.5rem;
position: absolute;
width: 3rem;
z-index:
`);
@@ -0,0 +1,20 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { styled } from '../styled.js';
interface Props {
className?: string;
src: string;
}
const Svg = ({ className }: Props) => <span className={`Comp--Svg ${className}`} />;
export default styled(Svg)<Props>(({ src }) => `
background: var(--textColor);
display: inline-block;
mask: url(${src});
mask-size: cover;
`);
@@ -0,0 +1,82 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback } from 'react';
import { styled } from '../styled.js';
interface Props {
checked: boolean;
onChange: (checked: boolean) => void;
uncheckedLabel: string;
checkedLabel: string;
className?: string;
}
function Switch ({ checked, checkedLabel, className, onChange, uncheckedLabel }: Props): React.ReactElement<Props> {
const _onChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => onChange(event.target.checked),
[onChange]
);
return (
<div className={className}>
<span>{uncheckedLabel}</span>
<label>
<input
checked={checked}
className='checkbox'
onChange={_onChange}
type='checkbox'
/>
<span className='slider' />
</label>
<span>{checkedLabel}</span>
</div>
);
}
export default styled(Switch)<Props>`
label {
position: relative;
display: inline-block;
width: 48px;
height: 24px;
margin: 8px;
}
.checkbox {
opacity: 0;
width: 0;
height: 0;
&:checked + .slider:before {
transform: translateX(24px);
}
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--readonlyInputBackground);
transition: 0.2s;
border-radius: 100px;
border: 1px solid var(--inputBorderColor);
&:before {
position: absolute;
content: '';
height: 16px;
width: 16px;
left: 4px;
bottom: 3px;
background-color: var(--primaryColor);
transition: 0.4s;
border-radius: 50%;
}
}
`;
@@ -0,0 +1,74 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { styled } from '../styled.js';
interface Props {
children: React.ReactNode;
className?: string;
isFull?: boolean;
}
function Table ({ children, className = '', isFull }: Props): React.ReactElement<Props> {
return (
<table className={`${className} ${isFull ? 'isFull' : ''}`}>
<tbody>
{children}
</tbody>
</table>
);
}
export default React.memo(styled(Table)<Props>`
border: 0;
display: block;
font-size: var(--labelFontSize);
line-height: var(--labelLineHeight);
margin-bottom: 1rem;
&.isFull {
height: 100%;
overflow: auto;
}
td.data {
max-width: 0;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
vertical-align: middle;
width: 100%;
pre {
font-family: inherit;
font-size: 0.75rem;
margin: 0;
}
}
td.label {
opacity: 0.5;
padding: 0 0.5rem;
text-align: right;
vertical-align: top;
white-space: nowrap;
}
details {
cursor: pointer;
max-width: 24rem;
summary {
text-overflow: ellipsis;
outline: 0;
overflow: hidden;
white-space: nowrap;
}
&[open] summary {
white-space: normal;
}
}
`);
@@ -0,0 +1,47 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback } from 'react';
import Label from './Label.js';
import { TextArea } from './TextInputs.js';
interface Props {
className?: string;
isError?: boolean;
isFocused?: boolean;
isReadOnly?: boolean;
rowsCount?: number;
label: string;
onChange?: (value: string) => void;
value?: string;
}
export default function TextAreaWithLabel ({ className, isError, isFocused, isReadOnly, label, onChange, rowsCount, value }: Props): React.ReactElement<Props> {
const _onChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLTextAreaElement>): void => {
onChange && onChange(value);
},
[onChange]
);
return (
<Label
className={className}
label={label}
>
<TextArea
autoCapitalize='off'
autoComplete='off'
autoCorrect='off'
autoFocus={isFocused}
onChange={_onChange}
readOnly={isReadOnly}
rows={rowsCount || 2}
spellCheck={false}
value={value}
withError={isError}
/>
</Label>
);
}
@@ -0,0 +1,36 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { css } from 'styled-components';
import { styled } from '../styled.js';
interface Props {
withError?: boolean;
}
const TextInput = css<Props>(({ withError }) => `
background: var(--inputBackground);
border-radius: var(--borderRadius);
border: 1px solid var(--inputBorderColor);
border-color: var(${withError ? '--errorBorderColor' : '--inputBorderColor'});
box-sizing: border-box;
color: var(${withError ? '--errorColor' : '--textColor'});
display: block;
font-family: var(--fontFamily);
font-size: var(--fontSize);
height: 40px;
outline: none;
padding: 0.5rem 0.75rem;
resize: none;
width: 100%;
&:read-only {
background: var(--readonlyInputBackground);
box-shadow: none;
outline: none;
}
`);
export const TextArea = styled.textarea<Props>`${TextInput}`;
export const Input = styled.input<Props>`${TextInput}`;
@@ -0,0 +1,36 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { styled } from '../../styled.js';
interface Props {
content: React.ReactNode;
className?: string;
visible?: boolean;
}
function Toast ({ className, content }: Props): React.ReactElement<Props> {
return (
<div className={className}>
<p className='snackbar-content'>{content}</p>
</div>
);
}
export default styled(Toast)<Props>`
position: fixed;
display: ${({ visible }): string => visible ? 'block' : 'none'};
height: 40px;
text-align: center;
vertical-align: middle;
line-height: 7px;
top: 460px;
left: calc(50% - 50px);
&& {
margin: auto;
border-radius: 25px;
background: var(--highlightedAreaBackground);
}
`;
@@ -0,0 +1,41 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useState } from 'react';
import { ToastContext } from '../index.js';
import Toast from './Toast.js';
interface ToastProviderProps {
children?: React.ReactNode;
}
const TOAST_TIMEOUT = 1500;
const ToastProvider = ({ children }: ToastProviderProps): React.ReactElement<ToastProviderProps> => {
const [content, setContent] = useState('');
const [visible, setVisible] = useState(false);
const show = useCallback((message: string): () => void => {
const timerId = setTimeout(() => setVisible(false), TOAST_TIMEOUT);
setContent(message);
setVisible(true);
return (): void => clearTimeout(timerId);
}, []);
return (
<ToastContext.Provider value={{ show }}>
{children}
<Toast
content={content}
visible={visible}
/>
</ToastContext.Provider>
);
};
export default ToastProvider;
ToastProvider.displayName = 'Toast';
@@ -0,0 +1,75 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ResultType, Validator } from '../util/validators.js';
import React, { useEffect, useState } from 'react';
import { useIsMounted } from '../hooks/index.js';
import { Result } from '../util/validators.js';
import Warning from './Warning.js';
interface BasicProps {
isError?: boolean;
value?: string | null;
onChange?: (value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
}
type Props<T extends BasicProps> = T & {
className?: string;
component: React.ComponentType<T>;
defaultValue?: string;
onValidatedChange: (value: string | null) => void;
validator: Validator<string>;
}
function ValidatedInput<T extends Record<string, unknown>> ({ className, component: Input, defaultValue, onValidatedChange, validator, ...props }: Props<T>): React.ReactElement<Props<T>> {
const [value, setValue] = useState(defaultValue || '');
const [validationResult, setValidationResult] = useState<ResultType<string>>(Result.ok(''));
const isMounted = useIsMounted();
useEffect(() => {
if (defaultValue) {
setValue(defaultValue);
}
}, [defaultValue]);
useEffect(() => {
// Do not show any error on first mount
if (!isMounted) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async (): Promise<void> => {
const result = await validator(value);
setValidationResult(result);
onValidatedChange(Result.isOk(result) ? value : null);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, validator, onValidatedChange]);
return (
<div className={className}>
<Input
{...props as unknown as T}
isError={Result.isError(validationResult)}
onChange={setValue}
value={value}
/>
{Result.isError(validationResult) && (
<Warning
isBelowInput
isDanger
>
{validationResult.error.errorDescription}
</Warning>
)}
</div>
);
}
export default ValidatedInput;
@@ -0,0 +1,18 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { styled } from '../styled.js';
interface Props {
className?: string;
}
function VerticalSpace ({ className }: Props): React.ReactElement<Props> {
return <div className={className} />;
}
export default styled(VerticalSpace)<Props>`
height: 100%;
`;
@@ -0,0 +1,53 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import React, { useEffect } from 'react';
import { createGlobalStyle } from 'styled-components';
// FIXME We should not import from index when this one is imported there as well
import { chooseTheme, Main, ThemeSwitchContext } from './index.js';
interface Props {
children: React.ReactNode;
className?: string;
}
function setGlobalTheme (theme: string): void {
const _theme = theme === 'dark'
? 'dark'
: 'light';
localStorage.setItem('theme', _theme);
document?.documentElement?.setAttribute('data-theme', _theme);
}
function View ({ children, className }: Props): React.ReactElement<Props> {
useEffect((): void => {
setGlobalTheme(chooseTheme());
}, []);
return (
<ThemeSwitchContext.Provider value={setGlobalTheme}>
<BodyTheme />
<Main className={className}>
{children}
</Main>
</ThemeSwitchContext.Provider>
);
}
const BodyTheme = createGlobalStyle`
body {
background-color: var(--bodyColor);
}
html {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
`;
export default View;
@@ -0,0 +1,60 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { styled } from '../styled.js';
interface Props {
children: React.ReactNode;
className?: string;
isBelowInput?: boolean;
isDanger?: boolean;
}
function Warning ({ children, className = '', isBelowInput, isDanger }: Props): React.ReactElement<Props> {
return (
<div className={`${className} ${isDanger ? 'danger' : ''} ${isBelowInput ? 'belowInput' : ''}`}>
<FontAwesomeIcon
className='warningImage'
icon={faExclamationTriangle}
/>
<div className='warning-message'>{children}</div>
</div>
);
}
export default React.memo(styled(Warning)<Props>(({ isDanger }) => `
display: flex;
flex-direction: row;
padding-left: 18px;
color: var(--subTextColor);
margin-right: 20px;
margin-top: 6px;
border-left: 0.25rem solid var(--iconWarningColor);
&.belowInput {
font-size: var(--labelFontSize);
line-height: var(--labelLineHeight);
&.danger {
margin-top: -10px;
}
}
&.danger {
border-left-color: var(--buttonBackgroundDanger);
}
.warning-message {
display: flex;
align-items: center;
}
.warningImage {
margin: 5px 10px 5px 0;
color: var(${isDanger ? '--iconDangerColor' : '--iconWarningColor'});
}
`));
@@ -0,0 +1,24 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest } from '@pezkuwi/extension-base/background/types';
import type { SettingsStruct } from '@pezkuwi/ui-settings/types';
import type { Theme } from './themes.js';
import React from 'react';
import { settings } from '@pezkuwi/ui-settings';
const noop = (): void => undefined;
const AccountContext = React.createContext<AccountsContext>({ accounts: [], hierarchy: [], master: undefined });
const ActionContext = React.createContext<(to?: string) => void>(noop);
const AuthorizeReqContext = React.createContext<AuthorizeRequest[]>([]);
const MediaContext = React.createContext<boolean>(false);
const MetadataReqContext = React.createContext<MetadataRequest[]>([]);
const SettingsContext = React.createContext<SettingsStruct>(settings.get());
const SigningReqContext = React.createContext<SigningRequest[]>([]);
const ThemeSwitchContext = React.createContext<(theme: Theme) => void>(noop);
const ToastContext = React.createContext<({show: (message: string) => void})>({ show: noop });
export { AccountContext, ActionContext, AuthorizeReqContext, MediaContext, MetadataReqContext, SettingsContext, SigningReqContext, ThemeSwitchContext, ToastContext };
@@ -0,0 +1,44 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
export { default as AccountNamePasswordCreation } from './AccountNamePasswordCreation.js';
export { default as ActionBar } from './ActionBar.js';
export { default as ActionText } from './ActionText.js';
export { default as Address } from './Address.js';
export { default as BackButton } from './BackButton.js';
export { default as Box } from './Box.js';
export { default as Button } from './Button.js';
export { default as ButtonArea } from './ButtonArea.js';
export { default as ButtonWithSubtitle } from './ButtonWithSubtitle.js';
export { default as Checkbox } from './Checkbox.js';
export * from './contexts.js';
export { default as Dropdown } from './Dropdown.js';
export { default as ErrorBoundary } from './ErrorBoundary.js';
export { default as Icon } from './Icon.js';
export { default as Identicon } from './Identicon.js';
export { default as InputFileWithLabel } from './InputFileWithLabel.js';
export { default as InputFilter } from './InputFilter.js';
export { default as InputWithLabel } from './InputWithLabel.js';
export { default as Label } from './Label.js';
export { default as Link } from './Link.js';
export { default as List } from './List.js';
export { default as Loading } from './Loading.js';
export { default as Main } from './Main.js';
export { default as Menu } from './Menu.js';
export { default as MenuDivider } from './MenuDivider.js';
export { default as MenuItem } from './MenuItem.js';
export { default as MnemonicSeed } from './MnemonicSeed.js';
export { default as NextStepButton } from './NextStepButton.js';
export { default as PasswordStrengthIndicator } from './PasswordStrengthIndicator.js';
export { default as RemoveAuth } from './RemoveAuth.js';
export { default as Spinner } from './Spinner.js';
export { default as Svg } from './Svg.js';
export { default as Switch } from './Switch.js';
export { default as Table } from './Table.js';
export { default as TextAreaWithLabel } from './TextAreaWithLabel.js';
export { Input, TextArea } from './TextInputs.js';
export * from './themes.js';
export { default as ValidatedInput } from './ValidatedInput.js';
export { default as VerticalSpace } from './VerticalSpace.js';
export { default as View } from './View.js';
export { default as Warning } from './Warning.js';
@@ -0,0 +1,20 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
export declare type Theme = 'light' | 'dark';
export const themes = ['light', 'dark'] as const;
export function chooseTheme (): Theme {
const preferredTheme = localStorage.getItem('theme');
if (preferredTheme) {
return preferredTheme === 'dark'
? 'dark'
: 'light';
}
return window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches
? 'light'
: 'dark';
}
@@ -0,0 +1,6 @@
// Copyright 2019-2025 @pezkuwi/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
export type OmitProps<T, K> = Pick<T, Exclude<keyof T, K>>;
export type SubtractProps<T, K> = OmitProps<T, keyof K>;