mirror of
https://github.com/pezkuwichain/pezkuwi-extension.git
synced 2026-07-15 23:45:45 +00:00
Update domain references to pezkuwichain.app and rebrand from polkadot
This commit is contained in:
@@ -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>;
|
||||
Reference in New Issue
Block a user