diff --git a/apps/minifront/src/components/staking/account/use-staking-tokens-and-filter.ts b/apps/minifront/src/components/staking/account/use-staking-tokens-and-filter.ts index f691b5cd96..3376ba36e7 100644 --- a/apps/minifront/src/components/staking/account/use-staking-tokens-and-filter.ts +++ b/apps/minifront/src/components/staking/account/use-staking-tokens-and-filter.ts @@ -63,7 +63,7 @@ export const useStakingTokensAndFilter = ( shouldReselect: (before, after) => before?.data !== after.data, }); - const stakingTokensByAccount = useMemo(() => { + const stakingTokensByAccount = useMemo>(() => { if (!stakingTokenMetadata || !balancesByAccount) { return new Map(); } @@ -71,7 +71,7 @@ export const useStakingTokensAndFilter = ( return balancesByAccount.reduce( (acc: Map, cur: BalancesByAccount) => toStakingTokensByAccount(acc, cur, stakingTokenMetadata), - new Map(), + new Map(), ); }, [stakingTokenMetadata, balancesByAccount]); diff --git a/apps/minifront/tsconfig.json b/apps/minifront/tsconfig.json index f52bee8c2d..f70cb69ec0 100644 --- a/apps/minifront/tsconfig.json +++ b/apps/minifront/tsconfig.json @@ -4,7 +4,8 @@ "exactOptionalPropertyTypes": false, "lib": ["ESNext", "DOM", "DOM.Iterable", "DOM.AsyncIterable"], "noEmit": true, - "target": "ESNext" + "target": "ESNext", + "types": ["vitest/globals", "@testing-library/jest-dom"] }, "extends": ["@tsconfig/strictest/tsconfig.json", "@tsconfig/vite-react/tsconfig.json"], "include": ["src", "tests-setup.ts", "__mocks__", "vite.config.ts", "vitest.config.ts"] diff --git a/package.json b/package.json index b5a2c82268..ffc962f9ac 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@eslint/js": "^9.6.0", "@microsoft/api-extractor": "^7.47.0", "@repo/tailwind-config": "workspace:*", - "@storybook/react-vite": "8.1.1", + "@storybook/react-vite": "^8.4.2", "@testing-library/jest-dom": "^6.4.5", "@testing-library/react": "^15.0.7", "@tsconfig/strictest": "^2.0.5", @@ -55,7 +55,7 @@ "@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react-swc": "^3.6.0", "@vitest/browser": "^1.6.0", - "autoprefixer": "^10.4.19", + "autoprefixer": "^10.4.20", "eslint": "^9.6.0", "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-typescript": "^3.6.1", diff --git a/packages/ui-tailwind/.storybook/main.ts b/packages/ui-tailwind/.storybook/main.ts new file mode 100644 index 0000000000..2a6ad3473e --- /dev/null +++ b/packages/ui-tailwind/.storybook/main.ts @@ -0,0 +1,35 @@ +import type { StorybookConfig } from '@storybook/react-vite'; + +import { join, dirname } from 'path'; + +/** + * This function is used to resolve the absolute path of a package. + * It is needed in projects that use Yarn PnP or are set up within a monorepo. + */ +function getAbsolutePath(value: string): any { + return dirname(require.resolve(join(value, 'package.json'))); +} +const config: StorybookConfig = { + stories: [ + { + directory: '../src', + files: '**/@(*.stories.@(js|jsx|mjs|ts|tsx)|*.mdx)', + titlePrefix: 'UI library', + }, + ], + addons: [ + getAbsolutePath('@storybook/addon-links'), + getAbsolutePath('@storybook/addon-essentials'), + getAbsolutePath('@chromatic-com/storybook'), + getAbsolutePath('@storybook/addon-interactions'), + getAbsolutePath('@storybook/preview-api'), + ], + framework: { + name: getAbsolutePath('@storybook/react-vite'), + options: {}, + }, + typescript: { + reactDocgen: 'react-docgen-typescript', + }, +}; +export default config; diff --git a/packages/ui-tailwind/.storybook/penumbra-theme.ts b/packages/ui-tailwind/.storybook/penumbra-theme.ts new file mode 100644 index 0000000000..70bf9ffcfc --- /dev/null +++ b/packages/ui-tailwind/.storybook/penumbra-theme.ts @@ -0,0 +1,20 @@ +import { create } from '@storybook/theming/create'; +import logo from './public/logo.svg'; + +const penumbraTheme = create({ + appBg: 'black', + appContentBg: 'black', + appPreviewBg: 'black', + barBg: 'black', + base: 'dark', + brandImage: logo, + brandTitle: 'Penumbra UI library', + colorPrimary: '#8d5728', + colorSecondary: '#629994', + fontBase: 'Poppins', + fontCode: '"Iosevka Term",monospace', + textColor: 'white', + textMutedColor: '#e3e3e3', +}); + +export default penumbraTheme; diff --git a/packages/ui-tailwind/.storybook/preview.tsx b/packages/ui-tailwind/.storybook/preview.tsx new file mode 100644 index 0000000000..77b9f12fa0 --- /dev/null +++ b/packages/ui-tailwind/.storybook/preview.tsx @@ -0,0 +1,63 @@ +import type { Preview } from '@storybook/react'; +import penumbraTheme from './penumbra-theme'; +import { useState } from 'react'; +import { ConditionalWrap } from '../src/ConditionalWrap'; +import { Density } from '../src/Density'; +import { Tabs } from '../src/Tabs'; + +import './tailwind.css'; +import '../src/theme/fonts.css'; +import '../src/theme/globals.css'; + +/** + * Utility component to let users control the density, for components whose + * stories include the `density` tag. + */ +const DensityWrapper = ({ children, showDensityControl }) => { + const [density, setDensity] = useState('sparse'); + + return ( + {children}} + else={children => {children}} + > +
+ {showDensityControl && ( + + + + )} + + {children} +
+
+ ); +}; + +const preview: Preview = { + tags: ['autodocs'], + parameters: { + docs: { + theme: penumbraTheme, + }, + }, + decorators: [ + (Story, { title, tags }) => { + return ( + + + + ); + }, + ], +}; + +export default preview; diff --git a/packages/ui-tailwind/.storybook/public/logo.svg b/packages/ui-tailwind/.storybook/public/logo.svg new file mode 100644 index 0000000000..0293e288a9 --- /dev/null +++ b/packages/ui-tailwind/.storybook/public/logo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/ui-tailwind/.storybook/tailwind.css b/packages/ui-tailwind/.storybook/tailwind.css new file mode 100644 index 0000000000..b5c61c9567 --- /dev/null +++ b/packages/ui-tailwind/.storybook/tailwind.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/packages/ui-tailwind/.storybook/typings.d.ts b/packages/ui-tailwind/.storybook/typings.d.ts new file mode 100644 index 0000000000..68f8b2215d --- /dev/null +++ b/packages/ui-tailwind/.storybook/typings.d.ts @@ -0,0 +1,14 @@ +declare module '*.jpg' { + const value: string; + export default value; +} + +declare module '*.png' { + const value: string; + export default value; +} + +declare module '*.svg' { + const value: string; + export default value; +} diff --git a/packages/ui-tailwind/eslint.config.js b/packages/ui-tailwind/eslint.config.js new file mode 100644 index 0000000000..c04ada5a3d --- /dev/null +++ b/packages/ui-tailwind/eslint.config.js @@ -0,0 +1,20 @@ +// eslint-disable-next-line -- ignore +import config from '../../eslint.config.js'; +import tailwindcss from 'eslint-plugin-tailwindcss'; +import { resolve } from 'node:path'; + +config.push({ + name: 'custom:tailwindcss-config', + plugins: { tailwindcss }, + settings: { + tailwindcss: { + config: resolve('tailwind.config.ts'), + }, + }, + rules: { + ...tailwindcss.configs.recommended.rules, + 'tailwindcss/no-custom-classname': ['error', { callees: ['cn', 'cva', 'clsx'] }], + }, +}); + +export default config; diff --git a/packages/ui-tailwind/package.json b/packages/ui-tailwind/package.json new file mode 100644 index 0000000000..f0b4100531 --- /dev/null +++ b/packages/ui-tailwind/package.json @@ -0,0 +1,80 @@ +{ + "name": "@penumbra-zone/ui-tailwind", + "version": "0.1.0", + "license": "(MIT OR Apache-2.0)", + "description": "UI components for Penumbra", + "type": "module", + "engine": { + "node": ">=22" + }, + "scripts": { + "build": "vite build", + "build-storybook": "storybook build", + "dev:pack": "VITE_WATCH=true vite build --watch", + "lint": "eslint src", + "lint:fix": "eslint src --fix", + "lint:strict": "tsc --noEmit && eslint src --max-warnings 0", + "storybook": "storybook dev -p 6006" + }, + "files": [ + "dist" + ], + "exports": { + "./*": "./src/*/index.tsx" + }, + "publishConfig": { + "exports": { + "./style.css": { + "default": "./dist/style.css" + }, + "./*": { + "types": "./dist/src/*/index.d.ts", + "default": "./dist/src/*/index.js" + } + } + }, + "dependencies": { + "@penumbra-zone/bech32m": "workspace:*", + "@penumbra-zone/getters": "workspace:*", + "@penumbra-zone/protobuf": "workspace:*", + "@penumbra-zone/types": "workspace:*", + "@radix-ui/react-dialog": "1.0.5", + "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-radio-group": "^1.2.0", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-tooltip": "^1.0.7", + "clsx": "^2.1.1", + "lucide-react": "^0.378.0", + "murmurhash3js": "^3.0.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "sonner": "1.4.3", + "tinycolor2": "^1.6.0" + }, + "devDependencies": { + "@chromatic-com/storybook": "^3.2.2", + "@storybook/addon-essentials": "^8.4.2", + "@storybook/addon-interactions": "^8.4.2", + "@storybook/addon-links": "^8.1.1", + "@storybook/blocks": "^8.4.2", + "@storybook/manager-api": "^8.1.11", + "@storybook/preview-api": "^8.1.1", + "@storybook/react": "^8.4.2", + "@storybook/react-vite": "^8.4.2", + "@storybook/test": "^8.4.2", + "@storybook/theming": "^8.1.11", + "@types/murmurhash3js": "^3.0.7", + "@types/react": "^18.3.2", + "@types/react-dom": "^18.3.0", + "@types/tinycolor2": "^1.4.6", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.38", + "storybook": "^8.4.2", + "tailwindcss": "^3.4.3", + "typescript": "5.5.3", + "vite": "^5.2.11", + "vite-plugin-dts": "^4.0.3" + } +} diff --git a/packages/ui-tailwind/postcss.config.js b/packages/ui-tailwind/postcss.config.js new file mode 100644 index 0000000000..2aa7205d4b --- /dev/null +++ b/packages/ui-tailwind/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/ui-tailwind/src/AddressView/AddressIcon.tsx b/packages/ui-tailwind/src/AddressView/AddressIcon.tsx new file mode 100644 index 0000000000..04937b9bb7 --- /dev/null +++ b/packages/ui-tailwind/src/AddressView/AddressIcon.tsx @@ -0,0 +1,15 @@ +import { Address } from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys_pb'; +import { bech32mAddress } from '@penumbra-zone/bech32m/penumbra'; +import { Identicon } from '../Identicon'; + +export interface AddressIconProps { + address: Address; + size: number; +} + +/** + * A simple component to display a consistently styled icon for a given address. + */ +export const AddressIcon = ({ address, size }: AddressIconProps) => ( + +); diff --git a/packages/ui-tailwind/src/AddressView/index.stories.tsx b/packages/ui-tailwind/src/AddressView/index.stories.tsx new file mode 100644 index 0000000000..cafe0b3a25 --- /dev/null +++ b/packages/ui-tailwind/src/AddressView/index.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { AddressViewComponent } from '.'; +import { ADDRESS_VIEW_DECODED, ADDRESS_VIEW_OPAQUE } from '../utils/bufs'; + +const meta: Meta = { + component: AddressViewComponent, + tags: ['autodocs', '!dev'], + argTypes: { + addressView: { + options: ['Sample decoded address view', 'Sample opaque address view'], + mapping: { + 'Sample decoded address view': ADDRESS_VIEW_DECODED, + 'Sample opaque address view': ADDRESS_VIEW_OPAQUE, + }, + }, + }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + addressView: ADDRESS_VIEW_DECODED, + copyable: true, + }, +}; diff --git a/packages/ui-tailwind/src/AddressView/index.tsx b/packages/ui-tailwind/src/AddressView/index.tsx new file mode 100644 index 0000000000..a93e95605d --- /dev/null +++ b/packages/ui-tailwind/src/AddressView/index.tsx @@ -0,0 +1,58 @@ +import { AddressView } from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys_pb'; +import { getAddressIndex } from '@penumbra-zone/getters/address-view'; +import { bech32mAddress } from '@penumbra-zone/bech32m/penumbra'; +import { CopyToClipboardButton } from '../CopyToClipboardButton'; +import { AddressIcon } from './AddressIcon'; +import { Text } from '../Text'; + +export interface AddressViewProps { + addressView: AddressView | undefined; + copyable?: boolean; + hideIcon?: boolean; +} + +// Renders an address or an address view. +// If the view is given and is "visible", the account information will be displayed instead. +export const AddressViewComponent = ({ + addressView, + copyable = true, + hideIcon, +}: AddressViewProps) => { + if (!addressView?.addressView.value?.address) { + return null; + } + + const addressIndex = getAddressIndex.optional(addressView); + + // a randomized index has nonzero randomizer bytes + const isRandomized = addressIndex?.randomizer.some(v => v); + + const encodedAddress = bech32mAddress(addressView.addressView.value.address); + + return ( +
+ {!hideIcon && ( +
+ +
+ )} + + {addressIndex ? ( + + {isRandomized && 'IBC Deposit Address for '} + {`Sub-Account #${addressIndex.account}`} + + ) : ( + + {encodedAddress} + + )} + + {copyable && !isRandomized && ( +
+ +
+ )} +
+ ); +}; diff --git a/packages/ui-tailwind/src/AssetIcon/DelegationTokenIcon.tsx b/packages/ui-tailwind/src/AssetIcon/DelegationTokenIcon.tsx new file mode 100644 index 0000000000..f81f006418 --- /dev/null +++ b/packages/ui-tailwind/src/AssetIcon/DelegationTokenIcon.tsx @@ -0,0 +1,104 @@ +import { assetPatterns } from '@penumbra-zone/types/assets'; + +const getFirstEightCharactersOfValidatorId = (displayDenom = ''): [string, string] => { + const id = (assetPatterns.delegationToken.capture(displayDenom)?.id ?? '').substring(0, 8); + + const firstFour = id.substring(0, 4); + const lastFour = id.substring(4); + + return [firstFour, lastFour]; +}; + +export interface DelegationTokenIconProps { + displayDenom?: string; +} + +export const DelegationTokenIcon = ({ displayDenom }: DelegationTokenIconProps) => { + const [firstFour, lastFour] = getFirstEightCharactersOfValidatorId(displayDenom); + + return ( + + + + + + + + + + + + + + {firstFour} + + + {lastFour} + + + + + + + + + ); +}; diff --git a/packages/ui-tailwind/src/AssetIcon/UnbondingTokenIcon.tsx b/packages/ui-tailwind/src/AssetIcon/UnbondingTokenIcon.tsx new file mode 100644 index 0000000000..5ebd251ef1 --- /dev/null +++ b/packages/ui-tailwind/src/AssetIcon/UnbondingTokenIcon.tsx @@ -0,0 +1,104 @@ +import { assetPatterns } from '@penumbra-zone/types/assets'; + +const getFirstEightCharactersOfValidatorId = (displayDenom = ''): [string, string] => { + const id = (assetPatterns.unbondingToken.capture(displayDenom)?.id ?? '').substring(0, 8); + + const firstFour = id.substring(0, 4); + const lastFour = id.substring(4); + + return [firstFour, lastFour]; +}; + +export interface UnbondingTokenIconProps { + displayDenom?: string; +} + +export const UnbondingTokenIcon = ({ displayDenom }: UnbondingTokenIconProps) => { + const [firstFour, lastFour] = getFirstEightCharactersOfValidatorId(displayDenom); + + return ( + + + + + + + + + + + + + + {firstFour} + + + {lastFour} + + + + + + + + + ); +}; diff --git a/packages/ui-tailwind/src/AssetIcon/index.stories.tsx b/packages/ui-tailwind/src/AssetIcon/index.stories.tsx new file mode 100644 index 0000000000..aa0a66e713 --- /dev/null +++ b/packages/ui-tailwind/src/AssetIcon/index.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { AssetIcon } from '.'; +import { + PENUMBRA_METADATA, + DELEGATION_TOKEN_METADATA, + UNBONDING_TOKEN_METADATA, + UNKNOWN_TOKEN_METADATA, + PIZZA_METADATA, +} from '../utils/bufs'; + +const meta: Meta = { + component: AssetIcon, + tags: ['autodocs', '!dev'], + argTypes: { + metadata: { + options: ['Penumbra', 'Pizza', 'Delegation token', 'Unbonding token', 'Unknown asset'], + mapping: { + Penumbra: PENUMBRA_METADATA, + Pizza: PIZZA_METADATA, + 'Delegation token': DELEGATION_TOKEN_METADATA, + 'Unbonding token': UNBONDING_TOKEN_METADATA, + 'Unknown asset': UNKNOWN_TOKEN_METADATA, + }, + }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + size: 'md', + metadata: PENUMBRA_METADATA, + }, +}; diff --git a/packages/ui-tailwind/src/AssetIcon/index.tsx b/packages/ui-tailwind/src/AssetIcon/index.tsx new file mode 100644 index 0000000000..cf7a1cf222 --- /dev/null +++ b/packages/ui-tailwind/src/AssetIcon/index.tsx @@ -0,0 +1,50 @@ +import { ReactNode } from 'react'; +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { getDisplay } from '@penumbra-zone/getters/metadata'; +import { assetPatterns } from '@penumbra-zone/types/assets'; +import { Identicon } from '../Identicon'; +import { DelegationTokenIcon } from './DelegationTokenIcon'; +import { UnbondingTokenIcon } from './UnbondingTokenIcon'; +import cn from 'clsx'; + +type Size = 'lg' | 'md' | 'sm'; + +const sizeMap: Record = { + lg: cn('w-8 h-8'), + md: cn('w-6 h-6'), + sm: cn('w-4 h-4'), +}; + +export interface AssetIconProps { + size?: Size; + metadata?: Metadata; +} + +export const AssetIcon = ({ metadata, size = 'md' }: AssetIconProps) => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- possibly empty string + const icon = metadata?.images[0]?.png || metadata?.images[0]?.svg; + const display = getDisplay.optional(metadata); + const isDelegationToken = display ? assetPatterns.delegationToken.matches(display) : false; + const isUnbondingToken = display ? assetPatterns.unbondingToken.matches(display) : false; + + let assetIcon: ReactNode; + if (icon) { + assetIcon = Asset icon; + } else if (isDelegationToken) { + assetIcon = ; + } else if (isUnbondingToken) { + /** + * @todo: Render a custom unbonding token for validators that have a + * logo -- e.g., with the validator ID superimposed over the validator logo. + */ + assetIcon = ; + } else { + assetIcon = ; + } + + return ( +
*]:w-full [&>*]:h-full')}> + {assetIcon} +
+ ); +}; diff --git a/packages/ui-tailwind/src/AssetSelector/Custom.tsx b/packages/ui-tailwind/src/AssetSelector/Custom.tsx new file mode 100644 index 0000000000..f9df9516b8 --- /dev/null +++ b/packages/ui-tailwind/src/AssetSelector/Custom.tsx @@ -0,0 +1,126 @@ +import { ReactNode, useId, useState } from 'react'; +import { Dialog } from '../Dialog'; +import { getHash } from './shared/helpers'; +import { AssetSelectorContext } from './shared/Context'; +import { AssetSelectorSearchFilter } from './SearchFilter'; +import { AssetSelectorTrigger } from './Trigger'; +import { AssetSelectorBaseProps } from './shared/types'; + +interface ChildrenArguments { + onClose: VoidFunction; + /** + * Takes the `Metadata` or `BalancesResponse` and returns + * a unique key string to be used within map in React + */ + getKeyHash: typeof getHash; +} + +export interface AssetSelectorCustomProps extends AssetSelectorBaseProps { + /** A value of the search filter inside the selector dialog */ + search?: string; + + /** Fires when user inputs the value into the search filter inside the selector dialog */ + onSearchChange?: (newValue: string) => void; + + /** + * Use children as a function to get assistance with keying + * the `ListItem`s and implement you own closing logic. + * + * Example: + * ```tsx + * + * {({ getKeyHash, onClose }) => ( + * <> + * {options.map(option => ( + * + * ))} + * + * + * )} + * + * ``` + * */ + children?: ReactNode | ((args: ChildrenArguments) => ReactNode); +} + +/** + * A custom version of the `AssetSelector` that lets you customize the contents of the selector dialog. + * + * Use `AssetSelector.ListItem` inside the `AssetSelector.Custom` to render the options + * of the selector. It is up for you to sort or group the options however you want. + * + * Example usage: + * + * ```tsx + * const [value, setValue] = useState(); + * const [search, setSearch] = useState(''); + * + * const filteredOptions = useMemo( + * () => mixedOptions.filter(filterMetadataOrBalancesResponseByText(search)), + * [search], + * ); + * + * return ( + * + * {({ getKeyHash }) => + * filteredOptions.map(option => ( + * + * )) + * } + * + * ); + * ``` + */ +export const AssetSelectorCustom = ({ + value, + onChange, + dialogTitle = 'Select Asset', + actionType, + disabled, + children, + search, + onSearchChange, +}: AssetSelectorCustomProps) => { + const layoutId = useId(); + + const [isOpen, setIsOpen] = useState(false); + + const onClose = () => setIsOpen(false); + + return ( + setIsOpen(false)}> + + setIsOpen(true)} + /> + + + ) + } + > + +
+ {typeof children === 'function' + ? children({ onClose, getKeyHash: getHash }) + : children} +
+
+
+
+
+ ); +}; diff --git a/packages/ui-tailwind/src/AssetSelector/SearchFilter.tsx b/packages/ui-tailwind/src/AssetSelector/SearchFilter.tsx new file mode 100644 index 0000000000..7b179303a7 --- /dev/null +++ b/packages/ui-tailwind/src/AssetSelector/SearchFilter.tsx @@ -0,0 +1,21 @@ +import { Search } from 'lucide-react'; +import { Icon } from '../Icon'; +import { TextInput } from '../TextInput'; + +export interface AssetSelectorSearchFilterProps { + value?: string; + onChange?: (newValue: string) => void; +} + +export const AssetSelectorSearchFilter = ({ value, onChange }: AssetSelectorSearchFilterProps) => { + const handleSearch = (newValue: string) => onChange?.(newValue); + + return ( + } + value={value ?? ''} + onChange={handleSearch} + placeholder='Search...' + /> + ); +}; diff --git a/packages/ui-tailwind/src/AssetSelector/SelectItem.tsx b/packages/ui-tailwind/src/AssetSelector/SelectItem.tsx new file mode 100644 index 0000000000..8f17139c83 --- /dev/null +++ b/packages/ui-tailwind/src/AssetSelector/SelectItem.tsx @@ -0,0 +1,84 @@ +import { AssetIcon } from '../AssetIcon'; +import { Text } from '../Text'; +import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view'; +import { + getAddressIndex, + getBalanceView, + getMetadataFromBalancesResponse, +} from '@penumbra-zone/getters/balances-response'; +import { ActionType } from '../utils/action-type'; +import { AssetSelectorValue } from './shared/types'; +import { getHash, isBalancesResponse } from './shared/helpers'; +import { RadioItem } from '../Dialog/RadioItem'; +import { useAssetsSelector } from './shared/Context'; + +export interface AssetSelectorItemProps { + /** + * A `BalancesResponse` or `Metadata` protobuf message type. Renders the asset + * icon name and, depending on the type, the value of the asset in the account. + * */ + value: AssetSelectorValue; + disabled?: boolean; + actionType?: ActionType; +} + +/** A radio button that selects an asset or a balance from the `AssetSelector` */ +export const Item = ({ value, disabled, actionType = 'default' }: AssetSelectorItemProps) => { + const { onClose, onChange } = useAssetsSelector(); + + const hash = getHash(value); + + const metadata = isBalancesResponse(value) + ? getMetadataFromBalancesResponse.optional(value) + : value; + + const balance = isBalancesResponse(value) + ? { + addressIndexAccount: getAddressIndex.optional(value)?.account, + valueView: getBalanceView.optional(value), + } + : undefined; + + // click is triggered by radix-ui on focus, click, arrow selection, etc. – basically always + const onSelect = () => { + onChange?.(value); + }; + + return ( + } + title={ + <> + {balance?.valueView && ( + + {getFormattedAmtFromValueView(balance.valueView, true)}{' '} + + )} + + + {metadata?.symbol ?? 'Unknown'} + + + + } + endAdornment={ + balance?.addressIndexAccount !== undefined && ( +
+ + #{balance.addressIndexAccount} + + + Account + +
+ ) + } + /> + ); +}; diff --git a/packages/ui-tailwind/src/AssetSelector/Trigger.tsx b/packages/ui-tailwind/src/AssetSelector/Trigger.tsx new file mode 100644 index 0000000000..396806ce44 --- /dev/null +++ b/packages/ui-tailwind/src/AssetSelector/Trigger.tsx @@ -0,0 +1,74 @@ +import { forwardRef, MouseEventHandler } from 'react'; +import { ChevronsUpDownIcon } from 'lucide-react'; +import cn from 'clsx'; +import { getMetadataFromBalancesResponse } from '@penumbra-zone/getters/balances-response'; +import { ActionType, getFocusOutlineColorByActionType } from '../utils/action-type'; +import { useDensity } from '../utils/density'; +import { Icon } from '../Icon'; +import { Text } from '../Text'; +import { AssetIcon } from '../AssetIcon'; +import { isMetadata } from './shared/helpers.ts'; +import { Dialog } from '../Dialog'; +import { AssetSelectorValue } from './shared/types'; + +export interface AssetSelectorTriggerProps { + value?: AssetSelectorValue; + actionType?: ActionType; + disabled?: boolean; + onClick?: MouseEventHandler; + layoutId?: string; +} + +export const AssetSelectorTrigger = forwardRef( + ({ value, actionType = 'default', disabled, onClick }, ref) => { + const density = useDensity(); + + const metadata = isMetadata(value) ? value : getMetadataFromBalancesResponse.optional(value); + + return ( + + + + ); + }, +); +AssetSelectorTrigger.displayName = 'AssetSelectorTrigger'; diff --git a/packages/ui-tailwind/src/AssetSelector/index.stories.tsx b/packages/ui-tailwind/src/AssetSelector/index.stories.tsx new file mode 100644 index 0000000000..f11608b0e2 --- /dev/null +++ b/packages/ui-tailwind/src/AssetSelector/index.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { AssetSelector, AssetSelectorValue } from '.'; +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; +import { useState } from 'react'; +import { + OSMO_BALANCE, + OSMO_METADATA, + PENUMBRA2_BALANCE, + PENUMBRA_BALANCE, + PENUMBRA_METADATA, + PIZZA_METADATA, +} from '../utils/bufs'; + +const balanceOptions: BalancesResponse[] = [PENUMBRA_BALANCE, PENUMBRA2_BALANCE, OSMO_BALANCE]; +const assetOptions: Metadata[] = [PIZZA_METADATA, PENUMBRA_METADATA, OSMO_METADATA]; + +const meta: Meta = { + component: AssetSelector, + tags: ['autodocs', '!dev', 'density'], + argTypes: { + value: { control: false }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const MixedBalancesResponsesAndMetadata: Story = { + args: { + dialogTitle: 'Transfer Assets', + assets: assetOptions, + balances: balanceOptions, + }, + + render: function Render(props) { + const [value, setValue] = useState(); + + return ; + }, +}; diff --git a/packages/ui-tailwind/src/AssetSelector/index.tsx b/packages/ui-tailwind/src/AssetSelector/index.tsx new file mode 100644 index 0000000000..c249155751 --- /dev/null +++ b/packages/ui-tailwind/src/AssetSelector/index.tsx @@ -0,0 +1,148 @@ +import { useMemo, useState } from 'react'; +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; + +import { isBalancesResponse, isMetadata } from './shared/helpers'; +import { filterMetadataOrBalancesResponseByText } from './shared/filterMetadataOrBalancesResponseByText'; +import { AssetSelectorBaseProps, AssetSelectorValue } from './shared/types'; +import { AssetSelectorCustom, AssetSelectorCustomProps } from './Custom'; +import { Item, AssetSelectorItemProps } from './SelectItem'; +import { Text } from '../Text'; +import { filterAssets, groupAndSortBalances } from './shared/groupAndSort'; + +export interface AssetSelectorProps extends AssetSelectorBaseProps { + /** + * An array of `Metadata` – protobuf message types describing the asset: + * its name, symbol, id, icons, and more + */ + assets?: Metadata[]; + /** + * An array of `BalancesResponse` – protobuf message types describing the balance of an asset: + * the account containing the asset, the value of this asset and its description (has `Metadata` inside it) + */ + balances?: BalancesResponse[]; +} +/** + * Allows users to choose an asset for e.g., the swap and send forms. Note that + * it can render an array of just `Metadata`s, or a mixed array of + * both `Metadata`s and `BalancesResponse`s. The latter is useful for e.g., + * letting the user estimate a swap of an asset they don't hold. + * + * The component has two ways of using it: + * + * ### 1. + * + * A default way with pre-defined grouping, sorting, searching and rendering algorithms. Renders the list of balances on top of the dialog with account index grouping and priority sorting within each group. When searching, it filters the assets by name, symbol, display name and base name. + * + * Example: + * + * ```tsx + * const [value, setValue] = useState(); + * + * + * ``` + * + * ### 2. + * + * A custom way. You can use the `AssetSelector.Custom` with `AssetSelector.ListItem` to render the options of the selector. It is up to the consumer to sort or group the options however they want. + * + * Example: + * + * ```tsx + * const [value, setValue] = useState(); + * const [search, setSearch] = useState(''); + * + * const filteredOptions = useMemo( + * () => mixedOptions.filter(filterMetadataOrBalancesResponseByText(search)), + * [search], + * ); + * + * return ( + * + * {({ getKeyHash }) => + * filteredOptions.map(option => ( + * + * )) + * } + * + * ); + * ``` + */ +export const AssetSelector = ({ + assets = [], + balances = [], + value, + onChange, + dialogTitle = 'Select Asset', + actionType, + disabled, +}: AssetSelectorProps) => { + const [search, setSearch] = useState(''); + + const { filteredAssets, filteredBalances } = useMemo( + () => ({ + filteredAssets: filterAssets(assets).filter(filterMetadataOrBalancesResponseByText(search)), + filteredBalances: groupAndSortBalances( + balances.filter(filterMetadataOrBalancesResponseByText(search)), + ), + }), + [assets, balances, search], + ); + + return ( + + {({ getKeyHash }) => ( +
+ {!!filteredBalances.length && ( + + Your Tokens + + )} + + {filteredBalances.map(([account, balances]) => ( +
+ {balances.map(balance => ( + + ))} +
+ ))} + + {!!filteredAssets.length && ( + + All Tokens + + )} + + {filteredAssets.map(asset => ( + + ))} +
+ )} +
+ ); +}; + +AssetSelector.Custom = AssetSelectorCustom; +AssetSelector.Item = Item; + +export { isBalancesResponse, isMetadata, groupAndSortBalances, filterAssets }; + +export type { AssetSelectorValue, AssetSelectorCustomProps, AssetSelectorItemProps }; diff --git a/packages/ui-tailwind/src/AssetSelector/shared/Context.tsx b/packages/ui-tailwind/src/AssetSelector/shared/Context.tsx new file mode 100644 index 0000000000..43255a1d22 --- /dev/null +++ b/packages/ui-tailwind/src/AssetSelector/shared/Context.tsx @@ -0,0 +1,19 @@ +import { createContext, useContext } from 'react'; +import { AssetSelectorValue } from './types.ts'; + +export interface AssetSelectorContextValue { + onClose: VoidFunction; + onChange?: (value: AssetSelectorValue) => void; + value: AssetSelectorValue | undefined; +} + +/** + * Provides helper functions to be consumed from `ListItem` component, only for inner usage. + * These components must be rendered by the user to provide custom sorting or grouping but + * the selection logic is standardized by this context. + */ +export const AssetSelectorContext = createContext( + {} as AssetSelectorContextValue, +); + +export const useAssetsSelector = () => useContext(AssetSelectorContext); diff --git a/packages/ui-tailwind/src/AssetSelector/shared/filterMetadataOrBalancesResponseByText.ts b/packages/ui-tailwind/src/AssetSelector/shared/filterMetadataOrBalancesResponseByText.ts new file mode 100644 index 0000000000..7e5b9599dc --- /dev/null +++ b/packages/ui-tailwind/src/AssetSelector/shared/filterMetadataOrBalancesResponseByText.ts @@ -0,0 +1,22 @@ +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; +import { isMetadata } from './helpers.ts'; +import { getMetadataFromBalancesResponse } from '@penumbra-zone/getters/balances-response'; + +export const filterMetadataOrBalancesResponseByText = + (textSearch: string) => + (value: Metadata | BalancesResponse): boolean => { + if (!textSearch.trim()) { + return true; + } + + const lowerCaseTextSearch = textSearch.toLocaleLowerCase(); + const metadata = isMetadata(value) ? value : getMetadataFromBalancesResponse(value); + + return ( + metadata.name.toLocaleLowerCase().includes(lowerCaseTextSearch) || + metadata.display.toLocaleLowerCase().includes(lowerCaseTextSearch) || + metadata.base.toLocaleLowerCase().includes(lowerCaseTextSearch) || + metadata.symbol.toLocaleLowerCase().includes(lowerCaseTextSearch) + ); + }; diff --git a/packages/ui-tailwind/src/AssetSelector/shared/groupAndSort.ts b/packages/ui-tailwind/src/AssetSelector/shared/groupAndSort.ts new file mode 100644 index 0000000000..4791965684 --- /dev/null +++ b/packages/ui-tailwind/src/AssetSelector/shared/groupAndSort.ts @@ -0,0 +1,92 @@ +import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { + getAmount, + getMetadataFromBalancesResponse, + getAddressIndex, + getValueViewCaseFromBalancesResponse, +} from '@penumbra-zone/getters/balances-response'; +import { joinLoHiAmount, multiplyAmountByNumber } from '@penumbra-zone/types/amount'; +import { assetPatterns } from '@penumbra-zone/types/assets'; +import { getDisplay } from '@penumbra-zone/getters/metadata'; + +const nonSwappableAssetPatterns = [ + assetPatterns.lpNft, + assetPatterns.proposalNft, + assetPatterns.votingReceipt, + assetPatterns.auctionNft, + assetPatterns.lpNft, + + // In theory, these asset types are swappable, but we have removed them for now to get a better UX + assetPatterns.delegationToken, + assetPatterns.unbondingToken, +]; + +const isSwappableMetadata = (metadata: Metadata): boolean => { + return !nonSwappableAssetPatterns.some(pattern => pattern.matches(getDisplay(metadata))); +}; + +const isUnswappableBalance = (balance: BalancesResponse): boolean => { + const metadata = getMetadataFromBalancesResponse.optional(balance); + if (!metadata) { + return true; + } + return nonSwappableAssetPatterns.some(pattern => pattern.matches(getDisplay(metadata))); +}; + +const isUnknownBalance = (balance: BalancesResponse): boolean => { + return getValueViewCaseFromBalancesResponse.optional(balance) !== 'knownAssetId'; +}; + +const groupByAccount = ( + acc: Record, + curr: BalancesResponse, +): Record => { + const index = getAddressIndex.optional(curr)?.account; + + if (index === undefined || isUnknownBalance(curr) || isUnswappableBalance(curr)) { + return acc; + } + + if (acc[index]) { + acc[index].push(curr); + } else { + acc[index] = [curr]; + } + + return acc; +}; + +const sortByAccountIndex = (a: [string, BalancesResponse[]], b: [string, BalancesResponse[]]) => { + return Number(a[0]) - Number(b[0]); +}; + +const sortbyPriorityScore = (a: BalancesResponse, b: BalancesResponse) => { + const aScore = getMetadataFromBalancesResponse.optional(a)?.priorityScore ?? 1n; + const bScore = getMetadataFromBalancesResponse.optional(b)?.priorityScore ?? 1n; + + const aAmount = getAmount.optional(a); + const bAmount = getAmount.optional(b); + + const aPriority = aAmount + ? joinLoHiAmount(multiplyAmountByNumber(aAmount, Number(aScore))) + : aScore; + const bPriority = bAmount + ? joinLoHiAmount(multiplyAmountByNumber(bAmount, Number(bScore))) + : bScore; + + return Number(bPriority - aPriority); +}; + +export const groupAndSortBalances = ( + balances: BalancesResponse[], +): [string, BalancesResponse[]][] => { + const grouped = balances.reduce(groupByAccount, {}); + return Object.entries(grouped) + .sort(sortByAccountIndex) + .map(([index, balances]) => [index, balances.sort(sortbyPriorityScore)]); +}; + +export const filterAssets = (assets: Metadata[]): Metadata[] => { + return assets.filter(isSwappableMetadata); +}; diff --git a/packages/ui-tailwind/src/AssetSelector/shared/helpers.ts b/packages/ui-tailwind/src/AssetSelector/shared/helpers.ts new file mode 100644 index 0000000000..2da039b413 --- /dev/null +++ b/packages/ui-tailwind/src/AssetSelector/shared/helpers.ts @@ -0,0 +1,26 @@ +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; +import { uint8ArrayToHex } from '@penumbra-zone/types/hex'; +import { AssetSelectorValue } from './types.ts'; + +/** Type predicate to check if a value is a `Metadata`. */ +export const isMetadata = (value?: AssetSelectorValue): value is Metadata => + value?.getType().typeName === Metadata.typeName; + +/** Type predicate to check if a value is a `BalancesResponse`. */ +export const isBalancesResponse = (value?: AssetSelectorValue): value is BalancesResponse => + value?.getType().typeName === BalancesResponse.typeName; + +/** returns a unique id of a specific Metadata or BalancesResponse */ +export const getHash = (value: AssetSelectorValue) => { + return uint8ArrayToHex(value.toBinary()); +}; + +/** compares Metadata or BalancesResponse with another option */ +export const isEqual = (value1: AssetSelectorValue, value2: AssetSelectorValue | undefined) => { + if (isMetadata(value1)) { + return isMetadata(value2) && value1.equals(value2); + } + + return isBalancesResponse(value2) && value1.equals(value2); +}; diff --git a/packages/ui-tailwind/src/AssetSelector/shared/types.ts b/packages/ui-tailwind/src/AssetSelector/shared/types.ts new file mode 100644 index 0000000000..cd328b6f64 --- /dev/null +++ b/packages/ui-tailwind/src/AssetSelector/shared/types.ts @@ -0,0 +1,29 @@ +import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; +import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { ActionType } from '../../utils/action-type'; + +export type AssetSelectorValue = BalancesResponse | Metadata; + +export interface AssetSelectorBaseProps { + /** The value of the selected asset or balance */ + value?: AssetSelectorValue; + + /** Callback when the selected asset or balance changes */ + onChange?: (value: AssetSelectorValue) => void; + + /** The title of the dialog */ + dialogTitle?: string; + + /** + * What type of action is this component related to? Leave as `default` for most + * buttons, set to `accent` for the single most important action on a given + * page, set to `unshield` for actions that will unshield the user's funds, + * and set to `destructive` for destructive actions. + * + * Default: `default` + */ + actionType?: ActionType; + + /** Whether the asset selector is disabled */ + disabled?: boolean; +} diff --git a/packages/ui-tailwind/src/Button/index.stories.tsx b/packages/ui-tailwind/src/Button/index.stories.tsx new file mode 100644 index 0000000000..112b4e2baa --- /dev/null +++ b/packages/ui-tailwind/src/Button/index.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Button } from '.'; +import { ArrowLeftRight, Check, Copy } from 'lucide-react'; + +const meta: Meta = { + component: Button, + tags: ['autodocs', '!dev', 'density'], + argTypes: { + icon: { + control: 'select', + options: ['None', 'Copy', 'Check', 'ArrowLeftRight'], + mapping: { None: undefined, Copy, Check, ArrowLeftRight }, + }, + iconOnly: { + options: ['true', 'false', 'adornment'], + mapping: { true: true, false: false, adornment: 'adornment' }, + }, + onClick: { control: false }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + children: 'Save', + actionType: 'default', + disabled: false, + icon: Copy, + iconOnly: false, + type: 'button', + }, +}; diff --git a/packages/ui-tailwind/src/Button/index.tsx b/packages/ui-tailwind/src/Button/index.tsx new file mode 100644 index 0000000000..1963a51c18 --- /dev/null +++ b/packages/ui-tailwind/src/Button/index.tsx @@ -0,0 +1,155 @@ +import { FC, forwardRef, MouseEventHandler, ReactNode } from 'react'; +import { LucideIcon } from 'lucide-react'; +import cn from 'clsx'; +import { getOutlineColorByActionType, ActionType } from '../utils/action-type'; +import { Priority, buttonBase, getBackground, getFocusOutline, getOverlays } from '../utils/button'; +import { button } from '../utils/typography'; +import { useDensity } from '../utils/density'; + +const iconOnlyAdornment = cn('rounded-full p-1 w-max'); +const sparse = (iconOnly?: boolean | 'adornment') => + cn('rounded-sm h-12', iconOnly ? 'w-12 min-w-12 pl-0 pr-0' : 'w-full pl-4 pr-4'); + +const compact = (iconOnly?: boolean | 'adornment') => + cn('rounded-full h-8 min-w-8 w-max', iconOnly ? 'pl-2 pr-2' : 'pl-4 pr-4'); + +interface BaseButtonProps { + type?: HTMLButtonElement['type']; + /** + * The button label. If `iconOnly` is `true` or `adornment`, this will be used + * as the `aria-label` attribute. + */ + children: ReactNode; + /** + * What type of action is this button related to? Leave as `default` for most + * buttons, set to `accent` for the single most important action on a given + * page, set to `unshield` for actions that will unshield the user's funds, + * and set to `destructive` for destructive actions. + * + * Default: `default` + */ + actionType?: ActionType; + disabled?: boolean; + onClick?: MouseEventHandler; + priority?: Priority; +} + +interface IconOnlyProps { + /** + * When set to `true`, will render just an icon button. When set to + * `adornment`, will render an icon button without the fill or outline of a + * normal button. This latter case is useful when the button is an adornment + * to another component (e.g., when it's a copy icon attached to an + * `AddressViewComponent`). + * + * In both of these cases, the label text passed via `children` will be used + * as the `aria-label`. + * + * Note that, when `iconOnly` is `adornment`, density has no impact on the + * button: it will render at the same size in either a `compact` or `sparse` + * context. + */ + iconOnly: true | 'adornment'; + /** + * The icon import from `lucide-react` to render. If `iconOnly` is `true`, no + * label will be rendered -- just the icon. Otherwise, the icon will be + * rendered to the left of the label. + * + * ```tsx + * import { ChevronRight } from 'lucide-react'; + * + * + * ``` + */ + icon: LucideIcon | FC; +} + +interface RegularProps { + iconOnly?: false; + /** + * The icon import from `lucide-react` to render. If `iconOnly` is `true`, no + * label will be rendered -- just the icon. Otherwise, the icon will be + * rendered to the left of the label. + * + * ```tsx + * import { ChevronRight } from 'lucide-react'; + * + * + * ``` + */ + icon?: LucideIcon | FC; +} + +export type ButtonProps = BaseButtonProps & (IconOnlyProps | RegularProps); + +/** + * A component for all your button needs! + * + * See individual props for how to use ` + ); + }, +); +Button.displayName = 'Button'; diff --git a/packages/ui-tailwind/src/Card/index.stories.tsx b/packages/ui-tailwind/src/Card/index.stories.tsx new file mode 100644 index 0000000000..cbefd33980 --- /dev/null +++ b/packages/ui-tailwind/src/Card/index.stories.tsx @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Card } from '.'; + +// import storiesBg from './storiesBg.jpg'; +import { Text } from '../Text'; +import { useState } from 'react'; +import { Button } from '../Button'; +import { Tabs } from '../Tabs'; +import { Send } from 'lucide-react'; + +const meta: Meta = { + component: Card, + tags: ['autodocs', '!dev'], + decorators: [ + Story => ( +
+ +
+ ), + ], + argTypes: { + as: { + options: ['section', 'div', 'main'], + }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + as: 'section', + title: 'Card title', + }, + + render: function Render({ as, title }) { + const [tab, setTab] = useState('one'); + // const [textInput, setTextInput] = useState(''); + + return ( + + + +
+ + This is the card content. Note that each top-level item inside the card is spaced apart + with a spacing of 4. Hence the distance between the tabs and this + paragraph, and the distance between this paragraph and the stack below. + +
+ + + + + This is a <Card.Stack /> comprised of several{' '} + <Card.Section />s. Note that the top and bottom of the + entire stack have rounded corners. + + + + + Card sections in a stack are useful for forms: each field of the form can be wrapped + in a <Card.Section />. + + + + +
+ ); + }, +}; diff --git a/packages/ui-tailwind/src/Card/index.tsx b/packages/ui-tailwind/src/Card/index.tsx new file mode 100644 index 0000000000..0683e9381e --- /dev/null +++ b/packages/ui-tailwind/src/Card/index.tsx @@ -0,0 +1,72 @@ +import { ReactNode, ElementType } from 'react'; +import cn from 'clsx'; +import { large } from '../utils/typography'; + +export interface CardProps { + children?: ReactNode; + /** + * Which component or HTML element to render this card as. + * + * @example + * ```tsx + * This is a main element with card styling + * ``` + */ + as?: ElementType; + title?: ReactNode; +} + +/** + * ``s are rectangular sections of a page set off from the rest of the + * page by a background and an optional title. They're useful for presenting + * data, or for wrapping a form. + * + * A `` wraps its children in a flex column with a spacing of `4` + * between each top-level HTML element. This results in a standard card layout + * no matter what its contents are. + * + * If you wish to pass children to `` that should not be spaced apart in + * that way, simply pass a single HTML element as the root of the ``'s + * children. That way, the built-in flex column will have no effect: + * + * ```tsx + * + *
+ * These two elements... + * ...will not appear in a flex column, but rather inline beside each + * other. + *
+ *
+ * ``` + * + * You can also use `` and `` to create a stack of + * sections, which are useful for wrapping individual form fields. + * + * ```tsx + * + * + * Section one + * Section two + * + * + * ``` + */ +export const Card = ({ children, as: Wrapper = 'section', title }: CardProps) => { + return ( + + {title &&

{title}

} + +
{children}
+
+ ); +}; + +const Stack = ({ children }: { children?: ReactNode }) => { + return
{children}
; +}; +Card.Stack = Stack; + +const Section = ({ children }: { children?: ReactNode }) => ( +
{children}
+); +Card.Section = Section; diff --git a/packages/ui-tailwind/src/ConditionalWrap/index.tsx b/packages/ui-tailwind/src/ConditionalWrap/index.tsx new file mode 100644 index 0000000000..878b2af924 --- /dev/null +++ b/packages/ui-tailwind/src/ConditionalWrap/index.tsx @@ -0,0 +1,61 @@ +import { ReactNode } from 'react'; + +export interface ConditionalWrapProps { + if: boolean; + then: (children: ReactNode) => ReactNode; + else?: (children: ReactNode) => ReactNode; + children: ReactNode; +} + +/** + * Utility component to optionally wrap a React component with another React + * component, depending on a condition. + * + * @example + * ```tsx + * ( + * + * {children} + * Here is the tooltip text. + * + * )} + * > + * Here is the content that may or may not need a tooltip. + * + * ``` + * + * You can also pass an `else` prop to wrap the `children` if the condition is + * _not_ met. + * + * @example + * ```tsx + * ( + * + * {children} + * Here is the tooltip text. + * + * )} + * else={(children) => ( + * {children} + * )} + * > + * Here is the content that may or may not need a tooltip. + * + * ``` + * + * @see https://stackoverflow.com/a/56870316/974981 + */ +export const ConditionalWrap = ({ + children, + + // Rename these to avoid using reserved words + if: condition, + then: thenWrapper, + else: elseWrapper, +}: ConditionalWrapProps) => + // eslint-disable-next-line no-nested-ternary -- simple nested ternary + condition ? thenWrapper(children) : elseWrapper ? elseWrapper(children) : children; diff --git a/packages/ui-tailwind/src/CopyToClipboardButton/index.stories.tsx b/packages/ui-tailwind/src/CopyToClipboardButton/index.stories.tsx new file mode 100644 index 0000000000..64edef298f --- /dev/null +++ b/packages/ui-tailwind/src/CopyToClipboardButton/index.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { CopyToClipboardButton } from '.'; + +const meta: Meta = { + component: CopyToClipboardButton, + tags: ['autodocs', '!dev'], +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + text: 'This is sample text copied by the PenumbraUI component.', + disabled: false, + }, +}; diff --git a/packages/ui-tailwind/src/CopyToClipboardButton/index.tsx b/packages/ui-tailwind/src/CopyToClipboardButton/index.tsx new file mode 100644 index 0000000000..4c6b27f127 --- /dev/null +++ b/packages/ui-tailwind/src/CopyToClipboardButton/index.tsx @@ -0,0 +1,43 @@ +import { useState } from 'react'; +import { Copy, Check, LucideIcon } from 'lucide-react'; +import { Button } from '../Button'; + +const useClipboardButton = (text: string) => { + const [icon, setIcon] = useState(Copy); + const [label, setLabel] = useState('Copy'); + + const onClick = () => { + setIcon(Check); + setLabel('Copied'); + setTimeout(() => { + setIcon(Copy); + setLabel('Copy'); + }, 2000); + void navigator.clipboard.writeText(text); + }; + + return { onClick, icon, label }; +}; + +export interface CopyToClipboardButtonProps { + /** + * The text that should be copied to the clipboard when the user presses this + * button. + */ + text: string; + disabled?: boolean; +} + +/** + * A simple icon button for copying some text to the clipboard. Use it alongside + * text that the user may want to copy. + */ +export const CopyToClipboardButton = ({ text, disabled = false }: CopyToClipboardButtonProps) => { + const { onClick, icon, label } = useClipboardButton(text); + + return ( + + ); +}; diff --git a/packages/ui-tailwind/src/Density/index.tsx b/packages/ui-tailwind/src/Density/index.tsx new file mode 100644 index 0000000000..452d389bb5 --- /dev/null +++ b/packages/ui-tailwind/src/Density/index.tsx @@ -0,0 +1,82 @@ +import { ReactNode } from 'react'; +import { Density as TDensity, DensityContext } from '../utils/density'; + +export type DensityProps = { + children?: ReactNode; +} & (SelectedDensity extends 'sparse' + ? { sparse: true; compact?: never } + : { compact: true; sparse?: never }); + +/** + * Use the `` component to set the density for all descendants in the + * component tree that support density variants. + * + * In Penumbra UI, density is never set as a prop directly on a component. + * Instead, it's set indirectly via a React context, so that entire regions of a + * UI can have a matching density. + * + * For example, imagine you have a `` (which supports density + * variants), which contain a bunch of ``s (which also + * support density variants). You may also have other components in the table + * which contain nested components with density variants. If we used a `density` + * prop, you'd need to set that prop on every single component in that tree. + * + * Instead, you can simply wrap the entire `
` with `` + * or ``, and it will set a density context value for all + * descendant components: + * + * ```tsx + * + *
+ * + * + * This will be rendered with compact spacing. + * + * + *
+ *
+ * ``` + * + * Components that support density variants are recognizable because the use the + * `useDensity()` hook, and then style their elements based on the value they + * receive from that hook: + * + * ```tsx + * const SomeStyledComponent = styled.div<{ $density: Density }>` + * padding: ${props => props.theme.spacing(props.$density === 'sparse' ? 4 : 2)}; + * ` + * + * const MyComponent = () => { + * const density = useDensity(); + * + * return + * } + * ``` + * + * In some specific situations, you may want to make sure that a given component + * that supports density variants always is rendered at a specific density. For + * example, let's say you have an icon-only button as the `startAdornment` for a + * ``, and you want to make sure that icon-only button always + * renders as `compact` density. In that case, simply wrap the button in + * ``. Then, it will always be compact, even if there's a + * higher-up ``: + * + * ```tsx + * + * + * + * } + * /> + * ``` + */ +export const Density = ({ + children, + sparse, +}: DensityProps) => ( + + {children} + +); diff --git a/packages/ui-tailwind/src/Dialog/Content.tsx b/packages/ui-tailwind/src/Dialog/Content.tsx new file mode 100644 index 0000000000..9ffe439910 --- /dev/null +++ b/packages/ui-tailwind/src/Dialog/Content.tsx @@ -0,0 +1,80 @@ +import { Title as RadixDialogTitle, Close as RadixDialogClose } from '@radix-ui/react-dialog'; +import { ReactNode, useContext } from 'react'; +import { X } from 'lucide-react'; +import { DialogContext } from './Context.tsx'; +import { EmptyContent } from './EmptyContent.tsx'; +import { Display } from '../Display'; +import { Grid } from '../Grid'; +import { Text } from '../Text'; +import { Density } from '../Density'; +import { Button } from '../Button'; + +export interface DialogContentProps { + children?: ReactNode; + /** Renders the element after the dialog title. These elements will be sticky to the top of the dialog */ + headerChildren?: ReactNode; + title: string; + /** Buttons rendered in the footer of a dialog */ + buttons?: ReactNode; + /** @deprecated this prop will be removed in the future */ + zIndex?: number; +} + +export const Content = ({ + children, + headerChildren, + title, + buttons, + zIndex, +}: DialogContentProps) => { + const { showCloseButton } = useContext(DialogContext); + + return ( + + + + + + +
+
+
+ + + {title} + + + {headerChildren} +
+ +
+ {children} + + {buttons &&
{buttons}
} +
+ + {/** + * Opening the dialog focuses the first focusable element in the dialog. That's why the Close button + * should be positioned absolutely and rendered as the last element in the dialog content. + */} + {showCloseButton && ( + + +
+ +
+
+
+ )} +
+
+
+ + + +
+
+ ); +}; diff --git a/packages/ui-tailwind/src/Dialog/Context.tsx b/packages/ui-tailwind/src/Dialog/Context.tsx new file mode 100644 index 0000000000..2a897d0cfc --- /dev/null +++ b/packages/ui-tailwind/src/Dialog/Context.tsx @@ -0,0 +1,6 @@ +import { createContext } from 'react'; + +/** Internal use only. */ +export const DialogContext = createContext<{ showCloseButton: boolean }>({ + showCloseButton: true, +}); diff --git a/packages/ui-tailwind/src/Dialog/EmptyContent.tsx b/packages/ui-tailwind/src/Dialog/EmptyContent.tsx new file mode 100644 index 0000000000..5f17d510ba --- /dev/null +++ b/packages/ui-tailwind/src/Dialog/EmptyContent.tsx @@ -0,0 +1,26 @@ +import { ReactNode } from 'react'; +import { + Overlay as RadixDialogOverlay, + Portal as RadixDialogPortal, + Content as RadixDialogContent, +} from '@radix-ui/react-dialog'; + +export interface DialogEmptyContentProps { + children?: ReactNode; + /** @deprecated this prop will be removed in the future */ + zIndex?: number; +} + +export const EmptyContent = ({ children, zIndex }: DialogEmptyContentProps) => { + return ( + + + + +
+ {children} +
+
+
+ ); +}; diff --git a/packages/ui-tailwind/src/Dialog/RadioItem.tsx b/packages/ui-tailwind/src/Dialog/RadioItem.tsx new file mode 100644 index 0000000000..8ef98f8c75 --- /dev/null +++ b/packages/ui-tailwind/src/Dialog/RadioItem.tsx @@ -0,0 +1,100 @@ +import React, { ReactNode, useMemo } from 'react'; +import cn from 'clsx'; +import { RadioGroupItem } from '@radix-ui/react-radio-group'; +import { Text } from '../Text'; +import { + ActionType, + getAriaCheckedOutlineColorByActionType, + getFocusOutlineColorByActionType, +} from '../utils/action-type'; + +export interface DialogRadioItemProps { + /** A required unique string value defining the radio item */ + value: string; + title: ReactNode; + description?: ReactNode; + /** A component rendered on the left side of the item */ + endAdornment?: ReactNode; + /** A component rendered on the right side of the item */ + startAdornment?: ReactNode; + disabled?: boolean; + actionType?: ActionType; + /** A function that closes the dialog on select of the item */ + onClose?: VoidFunction; + /** Fires when the item is clicked or focused using the keyboard */ + onSelect?: VoidFunction; +} + +/** A radio button that selects an asset or a balance from the `AssetSelector` */ +export const RadioItem = ({ + value, + title, + description, + startAdornment, + endAdornment, + disabled, + actionType = 'default', + onClose, + onSelect, +}: DialogRadioItemProps) => { + const handleClick = (event: React.MouseEvent) => { + // Is a click and not an arrow key up/down + if (event.detail > 0) { + onSelect?.(); + onClose?.(); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onSelect?.(); + onClose?.(); + } + }; + + const descriptionText = useMemo(() => { + if (!description) { + return null; + } + + if (typeof description === 'string') { + return ( + + {description} + + ); + } + + return description; + }, [description]); + + return ( + + + + ); +}; diff --git a/packages/ui-tailwind/src/Dialog/RadioItemGroup.tsx b/packages/ui-tailwind/src/Dialog/RadioItemGroup.tsx new file mode 100644 index 0000000000..4e116b2311 --- /dev/null +++ b/packages/ui-tailwind/src/Dialog/RadioItemGroup.tsx @@ -0,0 +1,12 @@ +import { RadioGroup as RadixRadioGroup, RadioGroupProps } from '@radix-ui/react-radio-group'; + +export type DialogRadioGroupProps = Omit; + +/** + * `Dialog.RadioGroup` – a wrapper around the list of `Dialog.RadioItem` that controls + * the selection of the radio items. Doesn't have any UI or HTML elements, – provide your own styles + * as children of this component. + */ +export const RadioGroup = (props: DialogRadioGroupProps) => { + return ; +}; diff --git a/packages/ui-tailwind/src/Dialog/Trigger.tsx b/packages/ui-tailwind/src/Dialog/Trigger.tsx new file mode 100644 index 0000000000..fa578c3f4a --- /dev/null +++ b/packages/ui-tailwind/src/Dialog/Trigger.tsx @@ -0,0 +1,19 @@ +import { ReactNode } from 'react'; +import * as RadixDialog from '@radix-ui/react-dialog'; + +export interface DialogTriggerProps { + children: ReactNode; + /** + * Change the default rendered element for the one passed as a child, merging + * their props and behavior. + * + * Uses Radix UI's `asChild` prop under the hood. + * + * @see https://www.radix-ui.com/primitives/docs/guides/composition + */ + asChild?: boolean; +} + +export const Trigger = ({ children, asChild }: DialogTriggerProps) => ( + {children} +); diff --git a/packages/ui-tailwind/src/Dialog/index.stories.tsx b/packages/ui-tailwind/src/Dialog/index.stories.tsx new file mode 100644 index 0000000000..6442007ddc --- /dev/null +++ b/packages/ui-tailwind/src/Dialog/index.stories.tsx @@ -0,0 +1,111 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Dialog } from '.'; +import { Button } from '../Button'; +import { ComponentType } from 'react'; +import { Text } from '../Text'; +import { AssetIcon } from '../AssetIcon'; +import { Ban, Handshake, ThumbsUp } from 'lucide-react'; +import { OSMO_METADATA, PENUMBRA_METADATA, PIZZA_METADATA } from '../utils/bufs'; + +const meta: Meta = { + component: Dialog, + tags: ['autodocs', '!dev'], + argTypes: { + isOpen: { control: false }, + onClose: { control: false }, + }, + subcomponents: { + // Re: type coercion, see + // https://github.com/storybookjs/storybook/issues/23170#issuecomment-2241802787 + 'Dialog.Content': Dialog.Content as ComponentType, + 'Dialog.Trigger': Dialog.Trigger as ComponentType, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + render: function Render() { + return ( + + + + + + + + + + + } + > +
+ + This is a subheading + + + This is description information. Lorem ipsum dolor sit amet, consectetur adipiscing + elit. Ut et massa mi. + + + This is a subheading + + + This is description information. Lorem ipsum dolor sit amet, consectetur adipiscing + elit. Ut et massa mi. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et + massa mi. + +
+
+
+ ); + }, +}; + +export const WithRadioItems: Story = { + render: function Render() { + return ( + + + + + + + +
+ } + /> + } + /> + } + /> +
+
+
+
+ ); + }, +}; diff --git a/packages/ui-tailwind/src/Dialog/index.tsx b/packages/ui-tailwind/src/Dialog/index.tsx new file mode 100644 index 0000000000..7b4fc0def9 --- /dev/null +++ b/packages/ui-tailwind/src/Dialog/index.tsx @@ -0,0 +1,151 @@ +import { ReactNode } from 'react'; +import * as RadixDialog from '@radix-ui/react-dialog'; +import { DialogContext } from './Context.tsx'; +import { EmptyContent, DialogEmptyContentProps } from './EmptyContent.tsx'; +import { Content, DialogContentProps } from './Content.tsx'; +import { Trigger, DialogTriggerProps } from './Trigger.tsx'; +import { RadioGroup, DialogRadioGroupProps } from './RadioItemGroup.tsx'; +import { RadioItem, DialogRadioItemProps } from './RadioItem'; + +interface ControlledDialogProps { + /** + * Whether the dialog is currently open. If left `undefined`, this will be + * treated as an uncontrolled dialog — that is, it will open and close based + * on user interactions rather than on state variables. + */ + isOpen: boolean; + /** + * Callback for when the user closes the dialog. Should update the state + * variable being passed in via `isOpen`. If left `undefined`, users will not + * be able to close it -- that is, it will only be able to be closed + * programmatically, and no Close button will be rendered. + */ + onClose?: VoidFunction; +} + +interface UncontrolledDialogProps { + isOpen?: false | undefined; + onClose?: undefined; +} + +export type DialogProps = { + children?: ReactNode; +} & (ControlledDialogProps | UncontrolledDialogProps); + +/** + * A dialog box that appears over other content. + * + * To render a dialog, compose it using a few components: ``, + * ``, and ``. The latter two must be + * descendents of `` in the component tree, and siblings to each + * other. (`` is optional, though — more on that in a moment.) + * + * ```tsx + * + * + * + * + * + * Dialog content here + * + * ``` + * + * Depending on your use case, you may want to use `` either as a + * controlled component, or as an uncontrolled component. + * + * ## Usage as a controlled component + * Use `` as a controlled component when you want to control its + * open/closed state yourself (e.g., via a state management solution like + * Zustand or Redux). You can accomplish this by passing `isOpen` and `onClose` + * props to the `` component, and omitting ``: + * + * ```tsx + * + * + * setIsOpen(false)}> + * Dialog content here + * + * ``` + * + * Note that, in the example above, the ` + * + * setIsOpen(false)}> + * + * This dialog can not be closed by the user. + * + * + * ``` + * + * ## Usage as an uncontrolled component + * If you want to render `` as an uncontrolled component, don't pass + * `isOpen` or `onClose` to ``, and make sure to include a + * `` component inside the ``: + + * ```tsx + * + * + * + * + * + * Dialog content here + * + * ``` + * + * ## Animating a dialog out of its trigger + * + * You can use the `motion` prop with a layout ID to make a dialog appear to + * animate out of the trigger button: + * + * ```tsx + * const layoutId = useId(); + * + * return ( + * + * + * + * + * + * ... + * + * + * ); + * ``` + */ +export const Dialog = ({ children, onClose, isOpen }: DialogProps) => { + const isControlledComponent = isOpen !== undefined; + const showCloseButton = (isControlledComponent && !!onClose) || !isControlledComponent; + + return ( + + onClose && !value && onClose()}> + {children} + + + ); +}; + +Dialog.EmptyContent = EmptyContent; +Dialog.Content = Content; +Dialog.Trigger = Trigger; +Dialog.RadioGroup = RadioGroup; +Dialog.RadioItem = RadioItem; + +export type { + DialogTriggerProps, + DialogEmptyContentProps, + DialogContentProps, + DialogRadioGroupProps, + DialogRadioItemProps, +}; diff --git a/packages/ui-tailwind/src/Display/index.stories.tsx b/packages/ui-tailwind/src/Display/index.stories.tsx new file mode 100644 index 0000000000..fab620c930 --- /dev/null +++ b/packages/ui-tailwind/src/Display/index.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Display } from '.'; +import { Text } from '../Text'; + +const meta: Meta = { + component: Display, + tags: ['autodocs'], + argTypes: { + children: { control: false }, + }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; + +type Story = StoryObj; + +export const FullWidth: Story = { + args: { + children: ( +
+ + The white background that this text sits inside of represents the{' '} + inside width of the <Display />{' '} + component. The white border to the left and right of this white bar represent the{' '} + outside width of the <Display />{' '} + component. + + + You can resize your window to see how the margins at left and right change depending on + the size of the browser window. + + + To test <Display /> at full width, click the "Full + Width" item in the left sidebar, and try resizing your browser. + +
+ ), + }, +}; diff --git a/packages/ui-tailwind/src/Display/index.tsx b/packages/ui-tailwind/src/Display/index.tsx new file mode 100644 index 0000000000..f428f9d2f7 --- /dev/null +++ b/packages/ui-tailwind/src/Display/index.tsx @@ -0,0 +1,27 @@ +import { ReactNode } from 'react'; + +export interface DisplayProps { + children?: ReactNode; +} + +/** + * Wrap your top-level component for a given page (usually a ``) in + * `` to adhere to PenumbraUI guidelines regarding maximum layouts + * widths, horizontal margins, etc. + * + * ```tsx + * + * + * Column one + * Column two + * + * + * ``` + */ +export const Display = ({ children }: DisplayProps) => { + return ( +
+
{children}
+
+ ); +}; diff --git a/packages/ui-tailwind/src/DropdownMenu/CheckboxItem.tsx b/packages/ui-tailwind/src/DropdownMenu/CheckboxItem.tsx new file mode 100644 index 0000000000..8b215708ba --- /dev/null +++ b/packages/ui-tailwind/src/DropdownMenu/CheckboxItem.tsx @@ -0,0 +1,39 @@ +import { + CheckboxItem as RadixDropdownMenuCheckboxItem, + ItemIndicator as RadixDropdownMenuItemIndicator, +} from '@radix-ui/react-dropdown-menu'; +import { ReactNode } from 'react'; +import { Check } from 'lucide-react'; +import { Text } from '../Text'; +import { DropdownMenuItemBase, getMenuItem } from '../utils/menu-item.ts'; + +export interface DropdownMenuCheckboxItemProps extends DropdownMenuItemBase { + children?: ReactNode; + checked?: boolean; + onChange?: (value: boolean) => void; +} + +export const CheckboxItem = ({ + children, + actionType = 'default', + disabled, + checked, + onChange, +}: DropdownMenuCheckboxItemProps) => { + return ( + +
+ + + + + {children} +
+
+ ); +}; diff --git a/packages/ui-tailwind/src/DropdownMenu/Content.tsx b/packages/ui-tailwind/src/DropdownMenu/Content.tsx new file mode 100644 index 0000000000..c0b0c3a825 --- /dev/null +++ b/packages/ui-tailwind/src/DropdownMenu/Content.tsx @@ -0,0 +1,29 @@ +import { ReactNode } from 'react'; +import { + Content as RadixDropdownMenuContent, + Portal as RadixDropdownMenuPortal, + DropdownMenuContentProps as RadixDropdownMenuContentProps, +} from '@radix-ui/react-dropdown-menu'; +import { getPopoverContent, PopoverContext } from '../utils/popover.ts'; + +export interface DropdownMenuContentProps { + children?: ReactNode; + side?: RadixDropdownMenuContentProps['side']; + align?: RadixDropdownMenuContentProps['align']; + context?: PopoverContext; +} + +export const Content = ({ + children, + side, + align, + context = 'default', +}: DropdownMenuContentProps) => { + return ( + + +
{children}
+
+
+ ); +}; diff --git a/packages/ui-tailwind/src/DropdownMenu/Item.tsx b/packages/ui-tailwind/src/DropdownMenu/Item.tsx new file mode 100644 index 0000000000..bb6f6dbe71 --- /dev/null +++ b/packages/ui-tailwind/src/DropdownMenu/Item.tsx @@ -0,0 +1,27 @@ +import { ReactNode } from 'react'; +import { Item as RadixDropdownMenuItem } from '@radix-ui/react-dropdown-menu'; +import { Text } from '../Text'; +import { DropdownMenuItemBase, getMenuItem } from '../utils/menu-item.ts'; + +export interface DropdownMenuItemProps extends DropdownMenuItemBase { + children?: ReactNode; + onSelect?: (event: Event) => void; + icon?: ReactNode; +} + +export const Item = ({ + children, + icon, + actionType = 'default', + disabled, + onSelect, +}: DropdownMenuItemProps) => { + return ( + +
+ {icon} + {children} +
+
+ ); +}; diff --git a/packages/ui-tailwind/src/DropdownMenu/RadioGroup.tsx b/packages/ui-tailwind/src/DropdownMenu/RadioGroup.tsx new file mode 100644 index 0000000000..c15a3caa83 --- /dev/null +++ b/packages/ui-tailwind/src/DropdownMenu/RadioGroup.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from 'react'; +import { RadioGroup as RadixDropdownMenuRadioGroup } from '@radix-ui/react-dropdown-menu'; + +export interface DropdownMenuRadioGroupProps { + children?: ReactNode; + value?: string; + onChange?: (value: string) => void; +} + +export const RadioGroup = ({ children, value, onChange }: DropdownMenuRadioGroupProps) => { + return ( + + {children} + + ); +}; diff --git a/packages/ui-tailwind/src/DropdownMenu/RadioItem.tsx b/packages/ui-tailwind/src/DropdownMenu/RadioItem.tsx new file mode 100644 index 0000000000..47f0187d9c --- /dev/null +++ b/packages/ui-tailwind/src/DropdownMenu/RadioItem.tsx @@ -0,0 +1,32 @@ +import { + RadioItem as RadixDropdownMenuRadioItem, + ItemIndicator as RadixDropdownMenuItemIndicator, +} from '@radix-ui/react-dropdown-menu'; +import { ReactNode } from 'react'; +import { Check } from 'lucide-react'; +import { Text } from '../Text'; +import { DropdownMenuItemBase, getMenuItem } from '../utils/menu-item.ts'; + +export interface DropdownMenuRadioItemProps extends DropdownMenuItemBase { + children?: ReactNode; + value: string; +} + +export const RadioItem = ({ + children, + value, + actionType = 'default', + disabled, +}: DropdownMenuRadioItemProps) => { + return ( + +
+ + + + + {children} +
+
+ ); +}; diff --git a/packages/ui-tailwind/src/DropdownMenu/Root.tsx b/packages/ui-tailwind/src/DropdownMenu/Root.tsx new file mode 100644 index 0000000000..d27a292fb7 --- /dev/null +++ b/packages/ui-tailwind/src/DropdownMenu/Root.tsx @@ -0,0 +1,90 @@ +import { ReactNode } from 'react'; +import { Root as RadixDropdownMenuRoot } from '@radix-ui/react-dropdown-menu'; +import { Trigger } from './Trigger.tsx'; +import { Content } from './Content.tsx'; +import { RadioGroup } from './RadioGroup.tsx'; +import { RadioItem } from './RadioItem.tsx'; +import { CheckboxItem } from './CheckboxItem.tsx'; +import { Item } from './Item.tsx'; + +interface ControlledDropdownMenuProps { + /** + * Whether the popover is currently open. If left `undefined`, this will be + * treated as an uncontrolled popover — that is, it will open and close based + * on user interactions rather than on state variables. + */ + isOpen: boolean; + /** + * Callback for when the user closes the popover. Should update the state + * variable being passed in via `isOpen`. If left `undefined`, users will not + * be able to close it -- that is, it will only be able to be closed programmatically + */ + onClose?: VoidFunction; +} + +interface UncontrolledDropdownMenuProps { + isOpen?: undefined; + onClose?: undefined; +} + +export type DropdownMenuProps = { + children?: ReactNode; +} & (ControlledDropdownMenuProps | UncontrolledDropdownMenuProps); + +/** + * A dropdown menu with a set of subcomponents for composing complex menus. + * + * `` can be controlled or uncontrolled. If `isOpen` is not provided + * but `` is present, it will open itself. + * + * You can nest multiple components inside the ``: + * - `` as an action button in the dropdown + * - `` with `` as a group of radio buttons + * - `` as a checkbox + * + * Example: + * + * ```tsx + * const [radioValue, setRadioValue] = useState('1'); + * const [apple, setApple] = useState(false); + * const [banana, setBanana] = useState(false); + * + * + * + * + * + * + * + * Default item + * Destructive item + * + * + * Default + * Disabled + * + * + * Apple + * Banana + * + * + * ``` + */ +export const DropdownMenu = ({ children, onClose, isOpen }: DropdownMenuProps) => { + return ( + !value && onClose() : undefined} + > + {children} + + ); +}; + +DropdownMenu.Trigger = Trigger; +DropdownMenu.Content = Content; +DropdownMenu.RadioGroup = RadioGroup; +DropdownMenu.RadioItem = RadioItem; +DropdownMenu.CheckboxItem = CheckboxItem; +DropdownMenu.Item = Item; diff --git a/packages/ui-tailwind/src/DropdownMenu/Trigger.tsx b/packages/ui-tailwind/src/DropdownMenu/Trigger.tsx new file mode 100644 index 0000000000..b056446e87 --- /dev/null +++ b/packages/ui-tailwind/src/DropdownMenu/Trigger.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react'; +import { Trigger as RadixDropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'; + +export interface DropdownMenuTriggerProps { + children: ReactNode; +} + +export const Trigger = ({ children }: DropdownMenuTriggerProps) => ( + {children} +); diff --git a/packages/ui-tailwind/src/DropdownMenu/index.stories.tsx b/packages/ui-tailwind/src/DropdownMenu/index.stories.tsx new file mode 100644 index 0000000000..6250dbb2f5 --- /dev/null +++ b/packages/ui-tailwind/src/DropdownMenu/index.stories.tsx @@ -0,0 +1,117 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { DropdownMenu } from '.'; +import { Button } from '../Button'; +import { ComponentType, useState } from 'react'; +import { Filter } from 'lucide-react'; + +const meta: Meta = { + component: DropdownMenu, + tags: ['autodocs', '!dev'], + argTypes: { + isOpen: { control: false }, + onClose: { control: false }, + }, + subcomponents: { + // Re: type coercion, see + // https://github.com/storybookjs/storybook/issues/23170#issuecomment-2241802787 + 'DropdownMenu.Content': DropdownMenu.Content as ComponentType, + 'DropdownMenu.Trigger': DropdownMenu.Trigger as ComponentType, + 'DropdownMenu.RadioGroup': DropdownMenu.RadioGroup as ComponentType, + 'DropdownMenu.RadioItem': DropdownMenu.RadioItem as ComponentType, + 'DropdownMenu.CheckboxItem': DropdownMenu.CheckboxItem as ComponentType, + 'DropdownMenu.Item': DropdownMenu.Item as ComponentType, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + render: function Render() { + return ( + + + + + + + Destructive + Accent + Unshield + Default + + Disabled + + + + ); + }, +}; + +export const Radio: Story = { + render: function Render() { + const [isOpen, setIsOpen] = useState(false); + const [value, setValue] = useState('1'); + + return ( + setIsOpen(false)}> + + + + + + + + Destructive + + + Accent + + + Unshield + + + Default + + + Disabled + + + + + ); + }, +}; + +export const Checkbox: Story = { + render: function Render() { + const [isOpen, setIsOpen] = useState(false); + + const [apple, setApple] = useState(false); + const [banana, setBanana] = useState(false); + + return ( + setIsOpen(false)}> + + + + + + + Apple + + + Banana + + + + ); + }, +}; diff --git a/packages/ui-tailwind/src/DropdownMenu/index.tsx b/packages/ui-tailwind/src/DropdownMenu/index.tsx new file mode 100644 index 0000000000..d0959d4f1d --- /dev/null +++ b/packages/ui-tailwind/src/DropdownMenu/index.tsx @@ -0,0 +1,9 @@ +export { DropdownMenu } from './Root.tsx'; + +export type { DropdownMenuProps } from './Root.tsx'; +export type { DropdownMenuTriggerProps } from './Trigger.tsx'; +export type { DropdownMenuContentProps } from './Content.tsx'; +export type { DropdownMenuRadioGroupProps } from './RadioGroup.tsx'; +export type { DropdownMenuRadioItemProps } from './RadioItem.tsx'; +export type { DropdownMenuCheckboxItemProps } from './CheckboxItem.tsx'; +export type { DropdownMenuItemProps } from './Item.tsx'; diff --git a/packages/ui-tailwind/src/Grid/index.stories.tsx b/packages/ui-tailwind/src/Grid/index.stories.tsx new file mode 100644 index 0000000000..a69f42c628 --- /dev/null +++ b/packages/ui-tailwind/src/Grid/index.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Grid } from '.'; +import { Text } from '../Text'; + +const meta: Meta = { + component: Grid, + title: 'Grid', + tags: ['autodocs', '!dev'], + argTypes: { + container: { control: false }, + mobile: { control: false }, + tablet: { control: false }, + desktop: { control: false }, + lg: { control: false }, + xl: { control: false }, + as: { control: false }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Demo: Story = { + render: function Render() { + return ( + + +
+ mobile=12 +
+
+ + {Array(2) + .fill(null) + .map((_, index) => ( + +
+ mobile=12 tablet=6 +
+
+ ))} + + {Array(4) + .fill(null) + .map((_, index) => ( + +
+ mobile=6 tablet=6 desktop=3 +
+
+ ))} + + {Array(48) + .fill(null) + .map((_, index) => ( + +
+ lg=1 +
+
+ ))} +
+ ); + }, +}; diff --git a/packages/ui-tailwind/src/Grid/index.tsx b/packages/ui-tailwind/src/Grid/index.tsx new file mode 100644 index 0000000000..3a9abb0f20 --- /dev/null +++ b/packages/ui-tailwind/src/Grid/index.tsx @@ -0,0 +1,169 @@ +import { PropsWithChildren } from 'react'; +import cn from 'clsx'; + +type GridElement = 'div' | 'main' | 'section'; + +interface BaseGridProps extends Record { + /** Which element to use. Defaults to `'div'`. */ + as?: GridElement; +} + +interface GridContainerProps extends BaseGridProps { + /** Whether this is a grid container, vs. an item. */ + container: true; + + // For some reason, Storybook needs these properties to be defined on the + // container props interface in order to show their typings properly. + mobile?: undefined; + tablet?: undefined; + desktop?: undefined; + lg?: undefined; + xl?: undefined; +} + +interface GridItemProps extends BaseGridProps { + /** Whether this is a grid container, vs. an item. */ + container?: false; + /** + * The number of columns this grid item should span on mobile. + * + * The mobile grid layout can only be split in half, so you can only set a + * grid item to 6 or 12 columns on mobile. 0 hides the container from mobile screens. + */ + mobile?: 0 | 6 | 12; + /** + * The number of columns this grid item should span on tablet. + * + * The tablet grid layout can only be split into six columns. + */ + tablet?: 0 | 2 | 4 | 6 | 8 | 10 | 12; + /** The number of columns this grid item should span on desktop. */ + desktop?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + /** The number of columns this grid item should span on large screens. */ + lg?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + /** The number of columns this grid item should span on XL screens. */ + xl?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; +} + +const MOBILE_MAP: Record['mobile'], string> = { + 0: 'hidden', + 6: 'col-span-6', + 12: 'col-span-12', +}; + +const TABLET_MAP: Record['tablet'], string> = { + 0: 'tablet:hidden', + 2: 'tablet:col-span-2', + 4: 'tablet:col-span-4', + 6: 'tablet:col-span-6', + 8: 'tablet:col-span-8', + 10: 'tablet:col-span-10', + 12: 'tablet:col-span-12', +}; + +const DESKTOP_MAP: Record['desktop'], string> = { + 0: 'desktop:hidden', + 1: 'desktop:col-span-1', + 2: 'desktop:col-span-2', + 3: 'desktop:col-span-3', + 4: 'desktop:col-span-4', + 5: 'desktop:col-span-5', + 6: 'desktop:col-span-6', + 7: 'desktop:col-span-7', + 8: 'desktop:col-span-8', + 9: 'desktop:col-span-9', + 10: 'desktop:col-span-10', + 11: 'desktop:col-span-11', + 12: 'desktop:col-span-12', +}; + +const LG_MAP: Record['lg'], string> = { + 0: 'lg:hidden', + 1: 'lg:col-span-1', + 2: 'lg:col-span-2', + 3: 'lg:col-span-3', + 4: 'lg:col-span-4', + 5: 'lg:col-span-5', + 6: 'lg:col-span-6', + 7: 'lg:col-span-7', + 8: 'lg:col-span-8', + 9: 'lg:col-span-9', + 10: 'lg:col-span-10', + 11: 'lg:col-span-11', + 12: 'lg:col-span-12', +}; + +const XL_MAP: Record['xl'], string> = { + 0: 'xl:hidden', + 1: 'xl:col-span-1', + 2: 'xl:col-span-2', + 3: 'xl:col-span-3', + 4: 'xl:col-span-4', + 5: 'xl:col-span-5', + 6: 'xl:col-span-6', + 7: 'xl:col-span-7', + 8: 'xl:col-span-8', + 9: 'xl:col-span-9', + 10: 'xl:col-span-10', + 11: 'xl:col-span-11', + 12: 'xl:col-span-12', +}; + +export type GridProps = PropsWithChildren; + +/** + * A responsive grid component that makes 12-column layouts super easy to build. + * + * Pass the `container` prop to the root `` component; then, any nested + * children ``s will be treated as grid items. You can customize which + * HTML element to use for each grid container or item by passing the element's + * name via the optional `as` prop. + * + * Use the `` component — rather than styling your own HTML elements + * with `display: grid` — to ensure consistent behavior (such as grid gutters) + * throughout your app. + * + * ```tsx + * + * This will span the full width on all screen sizes. + * + * So will this. + * + * + * These will span the full width on mobile... + * + * + * + * ...but half the width on desktop. + * + * + * + * These will... + * + * + * + * ...take up... + * + * + * + * ...one third each. + * + * + * ``` + */ +export const Grid = ({ container, children, as: Container = 'div', ...props }: GridProps) => + container ? ( + {children} + ) : ( + + {children} + + ); diff --git a/packages/ui-tailwind/src/Icon/index.stories.ts b/packages/ui-tailwind/src/Icon/index.stories.ts new file mode 100644 index 0000000000..b7f494b781 --- /dev/null +++ b/packages/ui-tailwind/src/Icon/index.stories.ts @@ -0,0 +1,25 @@ +import { ArrowRightLeft, Send, Wallet } from 'lucide-react'; +import { Meta, StoryObj } from '@storybook/react'; + +import { Icon } from '.'; + +const meta: Meta = { + component: Icon, + tags: ['autodocs', '!dev'], + argTypes: { + IconComponent: { + options: ['ArrowRightLeft', 'Send', 'Wallet'], + mapping: { ArrowRightLeft, Send, Wallet }, + }, + }, +}; + +export default meta; + +export const Basic: StoryObj = { + args: { + IconComponent: ArrowRightLeft, + size: 'sm', + color: 'text.primary', + }, +}; diff --git a/packages/ui-tailwind/src/Icon/index.tsx b/packages/ui-tailwind/src/Icon/index.tsx new file mode 100644 index 0000000000..9746156fab --- /dev/null +++ b/packages/ui-tailwind/src/Icon/index.tsx @@ -0,0 +1,66 @@ +import { LucideIcon } from 'lucide-react'; +import { ComponentProps, FC } from 'react'; +import { ThemeColor, getThemeColorClass } from '../utils/color'; +import cn from 'clsx'; + +export type IconSize = 'sm' | 'md' | 'lg'; + +export interface IconProps { + /** + * The icon import from `lucide-react` to render. + * + * ```tsx + * import { ChevronRight } from 'lucide-react'; + * + * ``` + */ + IconComponent: LucideIcon | FC; + /** + * - `sm`: 16px square + * - `md`: 24px square + * - `lg`: 32px square + */ + size: IconSize; + /** A string representing the color key from the Tailwind theme (e.g. 'primary.light') */ + color?: ThemeColor; +} + +const PROPS_BY_SIZE: Record> = { + sm: { + size: 16, + strokeWidth: 1, + }, + md: { + size: 24, + strokeWidth: 1.5, + }, + lg: { + size: 32, + strokeWidth: 2, + }, +}; + +/** + * Renders the Lucide icon passed in via the `IconComponent` prop. Use this + * component rather than rendering Lucide icon components directly, since this + * component standardizes the stroke width and sizes throughout the Penumbra + * ecosystem. + * + * ```tsx + * + * ``` + */ +export const Icon = ({ IconComponent, size = 'sm', color }: IconProps) => { + return ( + + ); +}; diff --git a/packages/ui-tailwind/src/Identicon/generate.ts b/packages/ui-tailwind/src/Identicon/generate.ts new file mode 100644 index 0000000000..296ee3c0d9 --- /dev/null +++ b/packages/ui-tailwind/src/Identicon/generate.ts @@ -0,0 +1,43 @@ +// Inspired by: https://github.com/vercel/avatar + +import color from 'tinycolor2'; +import Murmur from 'murmurhash3js'; + +// Deterministically getting a gradient from a string for use as an identicon +export const generateGradient = (str: string) => { + // Get first color + const hash = Murmur.x86.hash32(str); + const c = color({ h: hash % 360, s: 0.95, l: 0.5 }); + + const tetrad = c.tetrad(); // 4 colors spaced around the color wheel, the first being the input + const secondColorOptions = tetrad.slice(1); + const index = hash % 3; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- TODO: justify non-null assertion + const toColor = secondColorOptions[index]!.toHexString(); + + return { + fromColor: c.toHexString(), + toColor, + }; +}; + +export const generateSolidColor = (str: string) => { + // Get color + const hash = Murmur.x86.hash32(str); + const c = color({ h: hash % 360, s: 0.95, l: 0.5 }) + .saturate(0) + .darken(20); + return { + bg: c.toHexString(), + // get readable text color + text: color + .mostReadable(c, ['white', 'black'], { + includeFallbackColors: true, + level: 'AAA', + size: 'small', + }) + .saturate() + .darken(20) + .toHexString(), + }; +}; diff --git a/packages/ui-tailwind/src/Identicon/index.stories.tsx b/packages/ui-tailwind/src/Identicon/index.stories.tsx new file mode 100644 index 0000000000..48bb6ccb25 --- /dev/null +++ b/packages/ui-tailwind/src/Identicon/index.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Identicon } from '.'; + +const meta: Meta = { + component: Identicon, + tags: ['autodocs', '!dev'], +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + uniqueIdentifier: 'abc123', + type: 'solid', + size: 24, + }, +}; diff --git a/packages/ui-tailwind/src/Identicon/index.tsx b/packages/ui-tailwind/src/Identicon/index.tsx new file mode 100644 index 0000000000..fb518243e2 --- /dev/null +++ b/packages/ui-tailwind/src/Identicon/index.tsx @@ -0,0 +1,94 @@ +import { useMemo } from 'react'; +import { generateGradient, generateSolidColor } from './generate'; + +/** + * The view box size is separate from the passed-in `size` prop. + * + * The view box controls how the elements inside the SVG are sized in relation + * to the SVG as a whole. The passed-in `size` prop controls how big the SVG as + * a whole is. + */ +const VIEW_BOX_SIZE = 24; + +export interface IdenticonProps { + /** + * The ID or other string representation of the object you want an identicon + * for. `` will deterministically generate a solid color or + * gradient (depending on the value of `type`) based on the value of + * `uniqueIdentifier`. + */ + uniqueIdentifier: string; + /** The identicon size, in pixels. */ + size?: number; + /** + * When `solid`, will render a solid color along with the (upper-cased) first + * character of `uniqueIdentifier`. When `gradient`, will render just a + * gradient. + */ + type: 'gradient' | 'solid'; +} + +/** + * Renders an SVG icon whose color or gradient is deterministically generated + * based on the value of the `uniqueIdentifier` prop. + * + * Use this for assets, addresses, etc. that don't otherwise have an icon. + */ +export const Identicon = (props: IdenticonProps) => { + if (props.type === 'gradient') { + return ; + } + return ; +}; + +const IdenticonGradient = ({ uniqueIdentifier, size = 120 }: IdenticonProps) => { + const gradient = useMemo(() => generateGradient(uniqueIdentifier), [uniqueIdentifier]); + const gradientId = useMemo(() => `gradient-${uniqueIdentifier}`, [uniqueIdentifier]); + + return ( + + + + + + + + + + + + ); +}; + +const IdenticonSolid = ({ uniqueIdentifier, size = 120 }: IdenticonProps) => { + const color = useMemo(() => generateSolidColor(uniqueIdentifier), [uniqueIdentifier]); + + return ( + + + + {uniqueIdentifier[0]} + + + ); +}; diff --git a/packages/ui-tailwind/src/MenuItem/index.stories.tsx b/packages/ui-tailwind/src/MenuItem/index.stories.tsx new file mode 100644 index 0000000000..e9647c1917 --- /dev/null +++ b/packages/ui-tailwind/src/MenuItem/index.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { MenuItem } from '.'; +import { ArrowLeftRight, Check, Copy } from 'lucide-react'; + +const meta: Meta = { + component: MenuItem, + tags: ['autodocs', '!dev'], + argTypes: { + icon: { + control: 'select', + options: ['None', 'Copy', 'Check', 'ArrowLeftRight'], + mapping: { None: undefined, Copy, Check, ArrowLeftRight }, + }, + label: { + type: 'string', + }, + onClick: { control: false }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + label: 'Menu Item', + icon: Check, + disabled: false, + }, +}; diff --git a/packages/ui-tailwind/src/MenuItem/index.tsx b/packages/ui-tailwind/src/MenuItem/index.tsx new file mode 100644 index 0000000000..1e661de27d --- /dev/null +++ b/packages/ui-tailwind/src/MenuItem/index.tsx @@ -0,0 +1,32 @@ +import type { LucideIcon } from 'lucide-react'; +import type { FC, MouseEventHandler } from 'react'; +import { getMenuItem, DropdownMenuItemBase } from '../utils/menu-item'; +import { Text } from '../Text'; + +export interface MenuItemProps extends DropdownMenuItemBase { + label: string; + icon?: LucideIcon | FC; + onClick?: MouseEventHandler; +} + +/** + * A button generally used in menus or selectable lists + */ +export const MenuItem = ({ + actionType = 'default', + icon: IconComponent, + label, + onClick, + disabled, +}: MenuItemProps) => { + return ( + + ); +}; diff --git a/packages/ui-tailwind/src/Pill/index.stories.tsx b/packages/ui-tailwind/src/Pill/index.stories.tsx new file mode 100644 index 0000000000..0b5d3c8ad4 --- /dev/null +++ b/packages/ui-tailwind/src/Pill/index.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Pill } from '.'; + +const meta: Meta = { + component: Pill, + tags: ['autodocs', '!dev', 'density'], +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + children: 'Label', + priority: 'primary', + }, +}; diff --git a/packages/ui-tailwind/src/Pill/index.tsx b/packages/ui-tailwind/src/Pill/index.tsx new file mode 100644 index 0000000000..76bd17acb5 --- /dev/null +++ b/packages/ui-tailwind/src/Pill/index.tsx @@ -0,0 +1,86 @@ +import { ReactNode } from 'react'; +import { body, technical, detail, detailTechnical } from '../utils/typography'; +import { Density, useDensity } from '../utils/density'; +import cn from 'clsx'; + +type Priority = 'primary' | 'secondary'; +type Context = + | 'default' + | 'technical-default' + | 'technical-success' + | 'technical-caution' + | 'technical-destructive'; + +const getFont = (context: Context, density: Density) => { + if (context === 'default') { + return density === 'sparse' ? body : detail; + } + return density === 'sparse' ? technical : detailTechnical; +}; + +const getXPadding = (priority: Priority, density: Density): string => { + if (priority === 'secondary') { + return density === 'sparse' ? 'pr-[10px] pl-[10px]' : 'pr-[6px] pl-[6px]'; + } + return density === 'sparse' ? 'pr-3 pl-3' : 'pr-2 pl-2'; +}; + +const getBackgroundColor = (priority: Priority, context: Context) => { + if (priority === 'secondary') { + return 'bg-transparent'; + } + + const colorMap: Record = { + default: cn('bg-other-tonalFill10'), + 'technical-default': cn('bg-other-tonalFill10'), + 'technical-success': cn('bg-secondary-light'), + 'technical-caution': cn('bg-caution-light'), + 'technical-destructive': cn('bg-destructive-light'), + }; + return colorMap[context]; +}; + +const getColor = (priority: Priority, context: Context): string => { + if (priority === 'primary') { + return context === 'default' || context === 'technical-default' + ? cn('text-text-primary') + : cn('text-secondary-dark'); + } + + const colorMap: Record = { + default: cn('text-text-primary'), + 'technical-default': cn('text-text-primary'), + 'technical-success': cn('text-secondary-light'), + 'technical-caution': cn('text-caution-light'), + 'technical-destructive': cn('text-destructive-light'), + }; + return colorMap[context]; +}; + +export interface PillProps { + children: ReactNode; + priority?: Priority; + context?: Context; +} + +export const Pill = ({ children, priority = 'primary', context = 'default' }: PillProps) => { + const density = useDensity(); + + return ( + + {children} + + ); +}; diff --git a/packages/ui-tailwind/src/Popover/index.stories.tsx b/packages/ui-tailwind/src/Popover/index.stories.tsx new file mode 100644 index 0000000000..8bb09f83cf --- /dev/null +++ b/packages/ui-tailwind/src/Popover/index.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Popover } from '.'; +import { Button } from '../Button'; +import { ComponentType, useState } from 'react'; +import { Text } from '../Text'; +import { Shield } from 'lucide-react'; +import { Density } from '../Density'; + +const meta: Meta = { + component: Popover, + tags: ['autodocs', '!dev'], + argTypes: { + isOpen: { control: false }, + onClose: { control: false }, + }, + subcomponents: { + // Re: type coercion, see + // https://github.com/storybookjs/storybook/issues/23170#issuecomment-2241802787 + 'Popover.Content': Popover.Content as ComponentType, + 'Popover.Trigger': Popover.Trigger as ComponentType, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + render: function Render() { + const [isOpen, setIsOpen] = useState(false); + + return ( + setIsOpen(false)}> + + + + + +
+ + This is a heading + + + This is description information. Lorem ipsum dolor sit amet, consectetur adipiscing + elit. Ut et massa mi. + +
+ + + +
+
+
+
+ ); + }, +}; diff --git a/packages/ui-tailwind/src/Popover/index.tsx b/packages/ui-tailwind/src/Popover/index.tsx new file mode 100644 index 0000000000..c19f573f2a --- /dev/null +++ b/packages/ui-tailwind/src/Popover/index.tsx @@ -0,0 +1,127 @@ +import { ReactNode } from 'react'; +import * as RadixPopover from '@radix-ui/react-popover'; +import type { PopoverContentProps as RadixPopoverContentProps } from '@radix-ui/react-popover'; +import { getPopoverContent, PopoverContext } from '../utils/popover.ts'; + +interface ControlledPopoverProps { + /** + * Whether the popover is currently open. If left `undefined`, this will be + * treated as an uncontrolled popover — that is, it will open and close based + * on user interactions rather than on state variables. + */ + isOpen: boolean; + /** + * Callback for when the user closes the popover. Should update the state + * variable being passed in via `isOpen`. If left `undefined`, users will not + * be able to close it -- that is, it will only be able to be closed programmatically + */ + onClose?: VoidFunction; +} + +interface UncontrolledPopoverProps { + isOpen?: undefined; + onClose?: undefined; +} + +export type PopoverProps = { + children?: ReactNode; +} & (ControlledPopoverProps | UncontrolledPopoverProps); + +/** + * A popover box that appears next to the trigger element. + * + * To render a popover, compose it using a few components: ``, + * ``, and ``. The latter two must be + * descendents of `` in the component tree, and siblings to each + * other. (`` is optional, though — more on that in a moment.) + * + * ```tsx + * + * + * + * + * + * Popover content here + * + * ``` + * + * Depending on your use case, you may want to use `` either as a + * controlled component, or as an uncontrolled component. + * + * ## Usage as a controlled component + * + * Use `` as a controlled component when you want to control its + * open/closed state yourself (e.g., via a state management solution like + * Zustand or Redux). You can accomplish this by passing `isOpen` and `onClose` + * props to the `` component, and omitting ``: + * + * ```tsx + * + * + * setIsOpen(false)}> + * Popover content here + * + * ``` + * + * Note that, in the example above, the ` + * + * + * Popover content here + * + * ``` + */ +export const Popover = ({ children, onClose, isOpen }: PopoverProps) => { + return ( + onClose && !value && onClose()}> + {children} + + ); +}; + +export interface PopoverTriggerProps { + children: ReactNode; +} + +const Trigger = ({ children }: PopoverTriggerProps) => ( + {children} +); +Popover.Trigger = Trigger; + +export interface PopoverContentProps { + children?: ReactNode; + side?: RadixPopoverContentProps['side']; + align?: RadixPopoverContentProps['align']; + context?: PopoverContext; +} + +/** + * Popover content. Must be a child of ``. + * + * Control the position of the Popover relative to the trigger element by passing + * `side` and `align` props. + */ +const Content = ({ children, side, align, context = 'default' }: PopoverContentProps) => { + return ( + + +
{children}
+
+
+ ); +}; +Popover.Content = Content; + +export type { PopoverContext }; diff --git a/packages/ui-tailwind/src/Progress/index.stories.tsx b/packages/ui-tailwind/src/Progress/index.stories.tsx new file mode 100644 index 0000000000..de42b78acd --- /dev/null +++ b/packages/ui-tailwind/src/Progress/index.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Progress } from '.'; + +const meta: Meta = { + component: Progress, + tags: ['autodocs', '!dev'], +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + value: 0.3, + loading: false, + error: false, + }, +}; diff --git a/packages/ui-tailwind/src/Progress/index.tsx b/packages/ui-tailwind/src/Progress/index.tsx new file mode 100644 index 0000000000..871aebb6f4 --- /dev/null +++ b/packages/ui-tailwind/src/Progress/index.tsx @@ -0,0 +1,57 @@ +import * as ProgressPrimitive from '@radix-ui/react-progress'; +import cn from 'clsx'; + +export const getIndicatorColor = (value: number, error: boolean): string => { + if (error) { + return cn('bg-destructive-light'); + } + + if (value === 1) { + return cn('bg-secondary-light'); + } + + return cn('bg-caution-light'); +}; + +export interface ProgressProps { + /** Percentage value from 0 to 1 */ + value: number; + /** Displays the skeleton-like moving shade */ + loading?: boolean; + /** Renders red indicator while the progress continues */ + error?: boolean; +} + +/** + * Progress bar with loading and error states + */ +export const Progress = ({ value, loading, error = false }: ProgressProps) => ( + + +
+ {loading && ( +
+ )} +
+ + +); diff --git a/packages/ui-tailwind/src/Table/index.stories.tsx b/packages/ui-tailwind/src/Table/index.stories.tsx new file mode 100644 index 0000000000..4509586ad0 --- /dev/null +++ b/packages/ui-tailwind/src/Table/index.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Table } from '.'; +import { Text } from '../Text'; +import { ComponentType } from 'react'; + +const meta: Meta = { + component: Table, + tags: ['autodocs', '!dev', 'density'], + subcomponents: { + // Re: type coercion, see + // https://github.com/storybookjs/storybook/issues/23170#issuecomment-2241802787 + 'Table.Thead': Table.Thead as ComponentType, + 'Table.Tbody': Table.Tbody as ComponentType, + 'Table.Tr': Table.Tr as ComponentType, + 'Table.Th': Table.Th as ComponentType, + 'Table.Td': Table.Td as ComponentType, + }, +}; +export default meta; + +type Story = StoryObj; + +const DATA = [ + [32768, 'Send', '9d80ffa9113f7eed74ddeff8eddfda6a89106c6cdf336565f9cbaf90810396bf'], + [16384, 'Receive', '9d80ffa9113f7eed74ddeff8eddfda6a89106c6cdf336565f9cbaf90810396bf'], + [8192, 'Swap Claim', '9d80ffa9113f7eed74ddeff8eddfda6a89106c6cdf336565f9cbaf90810396bf'], + [4096, 'Swap', '9d80ffa9113f7eed74ddeff8eddfda6a89106c6cdf336565f9cbaf90810396bf'], + [2048, 'Internal Transfer', '9d80ffa9113f7eed74ddeff8eddfda6a89106c6cdf336565f9cbaf90810396bf'], + [1024, 'Delegate', '9d80ffa9113f7eed74ddeff8eddfda6a89106c6cdf336565f9cbaf90810396bf'], +]; + +export const Basic: Story = { + render: function Render() { + return ( + Recent transactions}> + + + Block height + Description + Transaction hash + + + + {DATA.map(([blockHeight, description, hash]) => ( + + {blockHeight} + {description} + + {hash} + + + ))} + +
+ ); + }, +}; diff --git a/packages/ui-tailwind/src/Table/index.tsx b/packages/ui-tailwind/src/Table/index.tsx new file mode 100644 index 0000000000..f4c23a75db --- /dev/null +++ b/packages/ui-tailwind/src/Table/index.tsx @@ -0,0 +1,161 @@ +import { PropsWithChildren, ReactNode } from 'react'; +import cn from 'clsx'; +import { tableHeading, tableItem } from '../utils/typography'; +import { Density, useDensity } from '../utils/density'; +import { ConditionalWrap } from '../ConditionalWrap'; + +export interface TableProps { + /** Content that will appear above the table. */ + title?: ReactNode; + children: ReactNode; + /** Which CSS `table-layout` property to use. */ + tableLayout?: 'fixed' | 'auto'; +} + +/** + * A styled HTML table. + * + * To build a table, you only need to import the `` component. All + * other components are properties on `Table`. + * + * ```tsx + *
+ * + * + * Header cell + * Header cell 2 + * + * + * + * + * Body cell + * Body cell 2 + * + * + *
+ * ``` + * + * By design, `` elements have limited props. No styling or + * customization is permitted. This ensures that all tables look consistent + * throughout the Penumbra UI. + * + * To render title content above the table, pass a `title` prop: + * + * ```tsx + * + * ... + *
+ * + * // or... + * + * Here is some rich table title content}> + * ... + *
+ * ``` + */ +export const Table = ({ title, children, tableLayout }: TableProps) => ( + ( +
+
{title}
+ {children} +
+ )} + > + + {children} +
+
+); + +const Thead = ({ children }: PropsWithChildren) => {children}; +Table.Thead = Thead; + +const Tbody = ({ children }: PropsWithChildren) => {children}; +Table.Tbody = Tbody; + +const Tr = ({ children }: PropsWithChildren) => ( + {children} +); +Table.Tr = Tr; + +type HAlign = 'left' | 'center' | 'right'; +type VAlign = 'top' | 'middle' | 'bottom'; + +const getCell = (density: Density) => + cn('box-border', 'pl-3 pr-3', density === 'sparse' ? 'pt-4 pb-4' : 'pt-3 pb-3'); + +const Th = ({ + children, + colSpan, + hAlign, + vAlign, + width, +}: PropsWithChildren<{ + colSpan?: number; + /** A CSS `width` value to use for this cell. */ + width?: string; + /** Controls the CSS `text-align` property for this cell. */ + hAlign?: HAlign; + /** Controls the CSS `vertical-align` property for this cell. */ + vAlign?: VAlign; +}>) => { + const density = useDensity(); + + return ( + + {children} + + ); +}; +Table.Th = Th; + +const Td = ({ + children, + colSpan, + hAlign, + vAlign, + width, +}: PropsWithChildren<{ + colSpan?: number; + /** A CSS `width` value to use for this cell. */ + width?: string; + /** Controls the CSS `text-align` property for this cell. */ + hAlign?: HAlign; + /** Controls the CSS `vertical-align` property for this cell. */ + vAlign?: VAlign; +}>) => { + const density = useDensity(); + + return ( + + {children} + + ); +}; +Table.Td = Td; diff --git a/packages/ui-tailwind/src/Tabs/index.stories.tsx b/packages/ui-tailwind/src/Tabs/index.stories.tsx new file mode 100644 index 0000000000..83bc0e3ec1 --- /dev/null +++ b/packages/ui-tailwind/src/Tabs/index.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useArgs } from '@storybook/preview-api'; + +import { Tabs } from '.'; + +const meta: Meta = { + component: Tabs, + tags: ['autodocs', '!dev', 'density'], + argTypes: { + value: { control: false }, + options: { control: false }, + onChange: { control: false }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + actionType: 'default', + value: 'first', + options: [ + { label: 'First', value: 'first' }, + { label: 'Second', value: 'second' }, + { label: 'Third', value: 'third' }, + { label: 'Fourth (disabled)', value: 'fourth', disabled: true }, + ], + }, + + render: function Render(props) { + const [, updateArgs] = useArgs(); + + const onChange = (value: { toString: () => string }) => updateArgs({ value }); + + return ; + }, +}; diff --git a/packages/ui-tailwind/src/Tabs/index.tsx b/packages/ui-tailwind/src/Tabs/index.tsx new file mode 100644 index 0000000000..f58afd9b78 --- /dev/null +++ b/packages/ui-tailwind/src/Tabs/index.tsx @@ -0,0 +1,113 @@ +import { tab, tabSmall } from '../utils/typography'; +import { buttonBase, getOverlays } from '../utils/button'; +import * as RadixTabs from '@radix-ui/react-tabs'; +import { ActionType } from '../utils/action-type'; +import { useDensity } from '../utils/density'; +import cn from 'clsx'; + +type LimitedActionType = Exclude; + +const getIndicatorColor = (actionType: LimitedActionType): string => { + if (actionType === 'accent') { + return cn('bg-tabAccent'); + } + if (actionType === 'unshield') { + return cn('bg-tabUnshield'); + } + return cn('bg-tabNeutral'); +}; + +const getBorderColor = (actionType: LimitedActionType): string => { + if (actionType === 'accent') { + return cn('border-action-primaryFocusOutline'); + } + if (actionType === 'unshield') { + return cn('border-action-unshieldFocusOutline'); + } + return cn('border-action-neutralFocusOutline'); +}; + +export interface TabsTab { + value: string; + label: string; + disabled?: boolean; +} + +export interface TabsProps { + value: string; + onChange: (value: string) => void; + options: TabsTab[]; + actionType?: LimitedActionType; +} + +/** + * Use tabs for switching between related pages or views. + * + * Built atop Radix UI's `` component, so it's fully accessible and + * supports keyboard navigation. + * + * ```TSX + * + * ``` + */ +export const Tabs = ({ value, onChange, options, actionType = 'default' }: TabsProps) => { + const density = useDensity(); + + return ( + + +
+ {options.map(option => ( + + + + ))} +
+
+
+ ); +}; diff --git a/packages/ui-tailwind/src/Text/index.stories.tsx b/packages/ui-tailwind/src/Text/index.stories.tsx new file mode 100644 index 0000000000..5756524324 --- /dev/null +++ b/packages/ui-tailwind/src/Text/index.stories.tsx @@ -0,0 +1,151 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Text } from '.'; +import { useArgs } from '@storybook/preview-api'; + +const meta: Meta = { + component: Text, + tags: ['autodocs', '!dev'], + argTypes: { + h1: { control: false }, + h2: { control: false }, + h3: { control: false }, + h4: { control: false }, + xxl: { control: false }, + large: { control: false }, + body: { control: false }, + p: { control: false }, + strong: { control: false }, + detail: { control: false }, + small: { control: false }, + technical: { control: false }, + detailTechnical: { control: false }, + + as: { + options: ['span', 'div', 'h1', 'h2', 'h3', 'h4', 'p', 'main', 'section'], + }, + }, +}; +export default meta; + +const OPTIONS = [ + 'h1', + 'h2', + 'h3', + 'h4', + 'large', + 'body', + 'p', + 'strong', + 'detail', + 'small', + 'technical', + 'detailTechnical', +] as const; + +const Option = ({ + value, + checked, + onSelect, +}: { + value: (typeof OPTIONS)[number]; + checked: boolean; + onSelect: (value: (typeof OPTIONS)[number]) => void; +}) => ( + +); +export const KitchenSink: StoryObj = { + args: { + children: 'The quick brown fox jumps over the lazy dog.', + h1: true, + as: 'span', + truncate: false, + }, + + render: function Render(props) { + const [, updateArgs] = useArgs(); + + const onSelect = (option: (typeof OPTIONS)[number]) => + updateArgs( + OPTIONS.reduce( + (prev, curr) => ({ + ...prev, + [curr]: curr === option ? true : undefined, + }), + {}, + ), + ); + + return ( +
+
+ Text style: + {OPTIONS.map(option => ( +
+ + + + ); + }, +}; + +export const UsageExample: StoryObj = { + render: function Render() { + return ( +
+ h1. Typography + h2. This is a section + + Here is some filler text: Giggster kickstarter painting with light + academy award charlie kaufman shotdeck breakdown services indie white balance. Student + emmys sound design ots character arc low angle coming-of-age composition. Storyboard beat + sheet greenlight cowboy shot margarita shot blocking foley stage seed&spark. + + + + Shot list low angle mit out sound telephoto rec.709 high angle eyeline assembly cut 8 1/2 + dga. Post-viz circle of confusion location scout unpaid internship reality of doing genre + film. Jean-luc godard ilm symbolism alexa mini white balance margarita shot. Jordan peele + log line ryan coogler actors access. + + + h2. Section two + + Silent film conflict sound design blocking script treatment. Teal and orange composition + fotokem third act blackmagic ingmar bergman jordan peele rembrandt lighting critical + darling silent film. Wes anderson arthouse diegetic sound after effects. + + + This is some large text. + + + White balance crafty debut film pan up 180-degree rule academy award exposure triangle + director's vision. Lavs led wall the actor prepares wrylies character arc stinger + sanford meisner. Given circumstances under-exposed jordan peele color grade nomadland team + deakins crafty dogme 95. French new wave pan up save the cat contrast ratio blue filter + cinema studies super 16 jump cut cannes unreal engine. + + + + Establishing shot stella adler ludwig göransson first-time director shotdeck fotokem + over-exposed flashback reality of doing color grade. Fetch coffee student emmys indie key + light rembrandt lighting. Undercranking beat beat scriptnotes podcast. Sound design + academy award day-for-night christopher nolan undercranking. Unreal engine visionary match + cut grain vs. noise 35mm anti-hero production design. + +
+ ); + }, +}; diff --git a/packages/ui-tailwind/src/Text/index.tsx b/packages/ui-tailwind/src/Text/index.tsx new file mode 100644 index 0000000000..b8b08fdc94 --- /dev/null +++ b/packages/ui-tailwind/src/Text/index.tsx @@ -0,0 +1,199 @@ +import cn from 'clsx'; + +import { + body, + detail, + h1, + h2, + h3, + h4, + large, + small, + detailTechnical, + strong, + technical, + xxl, + p, + getTextBase, +} from '../utils/typography'; +import { ElementType, ReactNode } from 'react'; +import { ThemeColor } from '../utils/color'; +import { TextType } from './types'; + +export type TextProps = TextType & { + children?: ReactNode; + /** + * Which component or HTML element to render this text as. + * + * @example + * ```tsx + * This is a span with H1 styling + * ``` + */ + as?: ElementType; + /** + * When `true`, will apply styles that 1) prevent text wrapping, 2) hide + * overflow, 3) add an ellpsis when the text overflows. + */ + truncate?: boolean; + /** A string representing the color key from the Tailwind theme (e.g. 'primary.light') */ + color?: ThemeColor; + /** + * The text alignment + */ + align?: 'left' | 'center' | 'right'; + /** + * The text decoration + */ + decoration?: 'line-through' | 'underline'; + /** + * The text transform + */ + transform?: 'uppercase' | 'lowercase' | 'capitalize'; + /** + * Controls how the text breaks. + */ + break?: 'words' | 'all' | 'keep'; + /** + * Controls how whitespace is handled. + */ + whitespace?: 'nowrap' | 'pre' | 'pre-line' | 'pre-wrap' | 'break-spaces'; +}; + +const ALIGN_MAP: Record['align'], string> = { + center: cn('text-center'), + left: cn('text-left'), + right: cn('text-right'), +}; + +const DECORATION_MAP: Record['decoration'], string> = { + 'line-through': cn('line-through'), + underline: cn('underline'), +}; + +const TRANSFORM_MAP: Record['transform'], string> = { + uppercase: cn('uppercase'), + lowercase: cn('lowercase'), + capitalize: cn('capitalize'), +}; + +const BREAK_MAP: Record['break'], string> = { + all: cn('break-all'), + words: cn('break-words'), + keep: cn('break-keep'), +}; + +const WHITESPACE_MAP: Record['whitespace'], string> = { + nowrap: cn('whitespace-nowrap'), + pre: cn('whitespace-pre'), + 'pre-line': cn('whitespace-pre-line'), + 'pre-wrap': cn('whitespace-pre-wrap'), + 'break-spaces': cn('whitespace-break-spaces'), +}; + +// Composes all props to the Tailwind class list +const getTextOptionClasses = ({ + color, + truncate, + align, + decoration, + transform, + break: breakProp, + whitespace, +}: TextProps): string => { + const truncateClass = truncate ? cn('truncate') : ''; + const alignClass = align && ALIGN_MAP[align]; + const decorationClass = decoration && DECORATION_MAP[decoration]; + const transformClass = transform && TRANSFORM_MAP[transform]; + const breakClass = breakProp && BREAK_MAP[breakProp]; + const whitespaceClass = whitespace && WHITESPACE_MAP[whitespace]; + + return cn( + getTextBase(color), + truncateClass, + alignClass, + decorationClass, + transformClass, + breakClass, + whitespaceClass, + ); +}; + +/** + * All-purpose text wrapper for quickly styling text per the Penumbra UI + * guidelines. + * + * Use with a _single_ text style name: + * + * ```tsx + * This will be rendered with the `h1` style. + * This will be rendered with the `body` style. + * + * INCORRECT: This will result in a TypeScript error. Only use one text style + * at a time. + * + * ``` + * + * When no text style is passed, it will render using the `body` style. + * + * The heading text styles are rendered as their corresponding heading tags + * (`

`, `

`, etc.), and the `p` style is rendered as a `

` tag. + * All other styles are rendered as ``s. To customize which tag is + * rendered without affecting its appearance, use the `as` prop: + * + * ```tsx + * + * This will render with the h1 style, but inside an inline span tag. + * + * ``` + */ +export const Text = (props: TextProps) => { + const classes = getTextOptionClasses(props); + const SpanElement = props.as ?? 'span'; + + if (props.h1) { + const Element = props.as ?? 'h1'; + return {props.children}; + } + if (props.h2) { + const Element = props.as ?? 'h2'; + return {props.children}; + } + if (props.h3) { + const Element = props.as ?? 'h3'; + return {props.children}; + } + if (props.h4) { + const Element = props.as ?? 'h4'; + return {props.children}; + } + + if (props.xxl) { + return {props.children}; + } + if (props.large) { + return {props.children}; + } + if (props.strong) { + return {props.children}; + } + if (props.detail) { + return {props.children}; + } + if (props.small) { + return {props.children}; + } + if (props.detailTechnical) { + return {props.children}; + } + if (props.technical) { + return {props.children}; + } + + if (props.p) { + const Element = props.as ?? 'p'; + return {props.children}; + } + + return {props.children}; +}; diff --git a/packages/ui-tailwind/src/Text/types.ts b/packages/ui-tailwind/src/Text/types.ts new file mode 100644 index 0000000000..a1ab764c5f --- /dev/null +++ b/packages/ui-tailwind/src/Text/types.ts @@ -0,0 +1,132 @@ +/** + * Utility interface to be used below to ensure that only one text type is used + * at a time. + */ +interface NeverTextTypes { + h1?: never; + h2?: never; + h3?: never; + h4?: never; + xxl?: never; + large?: never; + p?: never; + strong?: never; + detail?: never; + small?: never; + detailTechnical?: never; + technical?: never; + body?: never; +} + +export type TextType = + | (Omit & { + /** + * Renders a styled `

`. Pass the `as` prop to use a different HTML + * element with the same styling. + */ + h1: true; + }) + | (Omit & { + /** + * Renders a styled `

`. Pass the `as` prop to use a different HTML + * element with the same styling. + */ + h2: true; + }) + | (Omit & { + /** + * Renders a styled `

`. Pass the `as` prop to use a different HTML + * element with the same styling. + */ + h3: true; + }) + | (Omit & { + /** + * Renders a styled `

`. Pass the `as` prop to use a different HTML + * element with the same styling. + */ + h4: true; + }) + | (Omit & { + /** + * Renders bigger text used for section titles. Renders a `` by + * default; pass the `as` prop to use a different HTML element with the + * same styling. + */ + xxl: true; + }) + | (Omit & { + /** + * Renders big text used for section titles. Renders a `` by + * default; pass the `as` prop to use a different HTML element with the + * same styling. + */ + large: true; + }) + | (Omit & { + /** + * Renders a styled `

` tag with a bottom-margin (unless it's the last + * child). Aside from the margin, `

` is identical to ``. + * + * Note that this is the only component in the entire Penumbra UI library + * that renders an external margin. It's a convenience for developers who + * don't want to wrap each `` in a `

` with the + * appropriate margin, or a flex columnn with a gap. + */ + p: true; + }) + | (Omit & { + /** + * Emphasized body text. + * + * Renders a `` by default; pass the `as` prop to use a different + * HTML element with the same styling. + */ + strong: true; + }) + | (Omit & { + /** + * Detail text used for small bits of tertiary information. + * + * Renders a `` by default; pass the `as` prop to use a different + * HTML element with the same styling. + */ + detail: true; + }) + | (Omit & { + /** + * Small text used for secondary information. + * + * Renders a `` by default; pass the `as` prop to use a different + * HTML element with the same styling. + */ + small: true; + }) + | (Omit & { + /** + * Small monospaced text used for code, values, and other technical + * information. + * + * Renders a `` by default; pass the `as` prop to use a different + * HTML element with the same styling. + */ + detailTechnical: true; + }) + | (Omit & { + /** + * Monospaced text used for code, values, and other technical information. + * + * Renders a `` by default; pass the `as` prop to use a different + * HTML element with the same styling. + */ + technical: true; + }) + | (Omit & { + /** + * Body text used throughout most of our UIs. + * + * Renders a `` by default; pass the `as` prop to use a different + * HTML element with the same styling. + */ + body?: true; + }); diff --git a/packages/ui-tailwind/src/TextInput/index.stories.tsx b/packages/ui-tailwind/src/TextInput/index.stories.tsx new file mode 100644 index 0000000000..c3812d2312 --- /dev/null +++ b/packages/ui-tailwind/src/TextInput/index.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useArgs } from '@storybook/preview-api'; + +import { TextInput } from '.'; +import { Icon } from '../Icon'; +import { BookUser, Send } from 'lucide-react'; +import { Button } from '../Button'; +import { Density } from '../Density'; + +const SampleButton = () => ( + + + +); + +const addressBookIcon = ; + +const meta: Meta = { + component: TextInput, + tags: ['autodocs', '!dev'], + argTypes: { + startAdornment: { + options: ['Address book icon', 'None'], + mapping: { + 'Address book icon': addressBookIcon, + None: undefined, + }, + }, + endAdornment: { + options: ['Sample button', 'None'], + mapping: { + 'Sample button': , + None: undefined, + }, + }, + max: { control: false }, + min: { control: false }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + actionType: 'default', + placeholder: 'penumbra1abc123...', + value: '', + disabled: false, + type: 'text', + startAdornment: addressBookIcon, + endAdornment: , + }, + + render: function Render(props) { + const [, updateArgs] = useArgs(); + + const onChange = (value: string) => updateArgs({ value }); + + return ; + }, +}; diff --git a/packages/ui-tailwind/src/TextInput/index.tsx b/packages/ui-tailwind/src/TextInput/index.tsx new file mode 100644 index 0000000000..10410210e8 --- /dev/null +++ b/packages/ui-tailwind/src/TextInput/index.tsx @@ -0,0 +1,90 @@ +import { forwardRef, ReactNode } from 'react'; +import { small } from '../utils/typography'; +import { ActionType, getFocusWithinOutlineColorByActionType } from '../utils/action-type'; +import { useDisabled } from '../utils/disabled-context'; +import cn from 'clsx'; + +export interface TextInputProps { + value?: string; + onChange?: (value: string) => void; + placeholder?: string; + actionType?: ActionType; + disabled?: boolean; + type?: 'email' | 'number' | 'password' | 'tel' | 'text' | 'url'; + /** + * Markup to render inside the text input's visual frame, before the text + * input itself. + */ + startAdornment?: ReactNode; + /** + * Markup to render inside the text input's visual frame, after the text input + * itself. + */ + endAdornment?: ReactNode; + max?: string | number; + min?: string | number; +} + +/** + * A simple text field. + * + * Can be enriched with start and end adornments, which are markup that render + * inside the text input's visual frame. + */ +// eslint-disable-next-line react/display-name -- exotic component +export const TextInput = forwardRef( + ( + { + value, + onChange, + placeholder, + actionType = 'default', + disabled, + type = 'text', + startAdornment = null, + endAdornment = null, + max, + min, + }: TextInputProps, + ref, + ) => ( +
+ {startAdornment} + + onChange?.(e.target.value)} + placeholder={placeholder} + disabled={useDisabled(disabled)} + type={type} + max={max} + min={min} + ref={ref} + className={cn( + 'box-border grow appearance-none border-none bg-base-transparent py-2', + startAdornment ? 'pl-0' : 'pl-3', + endAdornment ? 'pr-0' : 'pr-3', + disabled ? 'text-text-muted' : 'text-text-primary', + small, + 'placeholder:text-text-secondary', + 'disabled:cursor-not-allowed', + 'disabled:placeholder:text-text-muted', + 'focus:outline-0', + '[&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none', + )} + /> + + {endAdornment} +
+ ), +); diff --git a/packages/ui-tailwind/src/Toast/index.stories.tsx b/packages/ui-tailwind/src/Toast/index.stories.tsx new file mode 100644 index 0000000000..8ac45aeb72 --- /dev/null +++ b/packages/ui-tailwind/src/Toast/index.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { openToast, ToastProvider, ToastType } from '.'; +import { Button } from '../Button'; +import { Tooltip, TooltipProvider } from '../Tooltip'; +import { Text } from '../Text'; + +const meta: Meta = { + component: ToastProvider, + tags: ['autodocs', '!dev'], + argTypes: {}, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + render: function Render() { + const toast = (type: ToastType) => { + openToast({ + type, + message: 'Hello, world!', + description: 'Additional text can possibly be long enough lorem ipsum dolor sit amet.', + }); + }; + + const upload = () => { + const t = openToast({ + type: 'loading', + message: 'Hello, world!', + }); + + setTimeout(() => { + t.update({ + type: 'error', + message: 'Failed!', + description: 'Unknown error', + }); + }, 2000); + }; + + const action = () => { + openToast({ + type: 'warning', + message: 'Do you confirm?', + dismissible: false, + persistent: true, + action: { + label: 'Yes!', + onClick: () => { + openToast({ + type: 'success', + message: 'Confirmed!', + dismissible: false, + }); + }, + }, + }); + }; + + return ( + +
+ + + All style types of toasts + +
+ + + + + + + +
+ + Updating toast + +
+ + + +
+ + Action toast + +
+ +
+
+
+ ); + }, +}; diff --git a/packages/ui-tailwind/src/Toast/index.tsx b/packages/ui-tailwind/src/Toast/index.tsx new file mode 100644 index 0000000000..459164dba8 --- /dev/null +++ b/packages/ui-tailwind/src/Toast/index.tsx @@ -0,0 +1,3 @@ +export { ToastProvider } from './provider'; +export { openToast } from './open'; +export type { Toast, ToastProps, ToastType } from './open'; diff --git a/packages/ui-tailwind/src/Toast/open.ts b/packages/ui-tailwind/src/Toast/open.ts new file mode 100644 index 0000000000..e36adb1806 --- /dev/null +++ b/packages/ui-tailwind/src/Toast/open.ts @@ -0,0 +1,107 @@ +import { toast, ExternalToast } from 'sonner'; +import { ReactNode } from 'react'; + +export type ToastType = 'success' | 'info' | 'warning' | 'error' | 'loading'; +type ToastFn = (message: ReactNode, options?: ExternalToast) => string | number; +type ToastId = string | number; + +const toastFnMap: Record = { + success: toast.success, + info: toast.info, + warning: toast.warning, + error: toast.error, + loading: toast.loading, +}; + +export interface ToastProps { + type: ToastType; + message: string; + description?: string; + persistent?: boolean; + dismissible?: boolean; + action?: ExternalToast['action']; +} + +export interface Toast { + update: (newProps: Partial) => void; + dismiss: VoidFunction; +} + +/** + * If `` exists in the document, opens a toast with provided type and options. + * By default, the toast is dismissible and has a duration of 4000 milliseconds. It can + * be programmatically updated to another type and content without re-opening the toast. + * + * Example: + * + * ```tsx + * import { ToastProvider, openToast } from '@penumbra-zone/ui/Toast'; + * import { ToastProvider, openToast } from '@penumbra-zone/ui/Button'; + * + * const Component = () => { + * const open = () => { + * const toast = openToast({ + * type: 'loading', + * message: 'Loading...', + * }); + * + * setTimeout(() => { + * toast.update({ + * type: 'error', + * message: 'Failed!', + * description: 'Unknown error' + * }); + * }, 2000); + * }; + * + * return ( + * <> + * + * + * + * ); + * }; + * ``` + */ +export const openToast = (props: ToastProps): Toast => { + let options = props; + let id: ToastId | undefined = undefined; + + const open = () => { + const fn = toastFnMap[options.type]; + + id = fn(options.message, { + id, + description: options.description, + closeButton: options.dismissible ?? true, + dismissible: options.dismissible ?? true, + duration: options.persistent ? Infinity : 4000, + action: options.action, + }); + }; + + const dismiss: Toast['dismiss'] = () => { + if (typeof id === 'undefined') { + return; + } + + toast.dismiss(id); + id = undefined; + }; + + const update: Toast['update'] = newProps => { + if (typeof id === 'undefined') { + return; + } + + options = { ...options, ...newProps }; + open(); + }; + + open(); + + return { + dismiss, + update, + }; +}; diff --git a/packages/ui-tailwind/src/Toast/provider.tsx b/packages/ui-tailwind/src/Toast/provider.tsx new file mode 100644 index 0000000000..0739757842 --- /dev/null +++ b/packages/ui-tailwind/src/Toast/provider.tsx @@ -0,0 +1,41 @@ +import { Toaster } from 'sonner'; + +/** + * If `` exists in the document, you can call `openToast` function to open a toast with provided type and options. + * By default, the toast is dismissible and has a duration of 4000 milliseconds. It can + * be programmatically updated to another type and content without re-opening the toast. + * + * Example: + * + * ```tsx + * import { ToastProvider, openToast } from '@penumbra-zone/ui/Toast'; + * import { ToastProvider, openToast } from '@penumbra-zone/ui/Button'; + * + * const Component = () => { + * const open = () => { + * const toast = openToast({ + * type: 'loading', + * message: 'Loading...', + * }); + * + * setTimeout(() => { + * toast.update({ + * type: 'error', + * message: 'Failed!', + * description: 'Unknown error' + * }); + * }, 2000); + * }; + * + * return ( + * <> + * + * + * + * ); + * }; + * ``` + */ +export const ToastProvider: typeof Toaster = ({ ...props }) => { + return ; +}; diff --git a/packages/ui-tailwind/src/Tooltip/index.stories.tsx b/packages/ui-tailwind/src/Tooltip/index.stories.tsx new file mode 100644 index 0000000000..937b2d1e52 --- /dev/null +++ b/packages/ui-tailwind/src/Tooltip/index.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Tooltip, TooltipProvider } from './index'; +import { Text } from '../Text'; + +const meta: Meta = { + component: Tooltip, + tags: ['autodocs', '!dev'], + argTypes: { + children: { control: false }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + render: args => ( + + + + ), + args: { + title: 'This is a heading', + message: + 'This is description information. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi.', + children: Hover over this text., + }, +}; diff --git a/packages/ui-tailwind/src/Tooltip/index.tsx b/packages/ui-tailwind/src/Tooltip/index.tsx new file mode 100644 index 0000000000..d22d7dfd1b --- /dev/null +++ b/packages/ui-tailwind/src/Tooltip/index.tsx @@ -0,0 +1,5 @@ +export { TooltipProvider } from '@radix-ui/react-tooltip'; +export type { TooltipProviderProps } from '@radix-ui/react-tooltip'; + +export { Tooltip } from './tooltip'; +export type { TooltipProps } from './tooltip'; diff --git a/packages/ui-tailwind/src/Tooltip/tooltip.tsx b/packages/ui-tailwind/src/Tooltip/tooltip.tsx new file mode 100644 index 0000000000..429ab4f2f8 --- /dev/null +++ b/packages/ui-tailwind/src/Tooltip/tooltip.tsx @@ -0,0 +1,70 @@ +import * as RadixTooltip from '@radix-ui/react-tooltip'; +import { ReactNode } from 'react'; +import cn from 'clsx'; +import { Text } from '../Text'; +import { buttonBase } from '../utils/button'; +import { small } from '../utils/typography'; + +export interface TooltipProps { + /** An optional title to show in larger text above the message. */ + title?: string; + /** + * A string message to show in the tooltip. Note that only strings are + * allowed; for interactive content, use a `` or a ``. + */ + message: string; + /** + * The trigger for the tooltip. + * + * Note that the trigger will be wrapped in an HTML button element, so only pass content that can be validly nested inside a button (i.e., don't pass another button). + */ + children: ReactNode; +} + +/** + * Use this for small informational text that should appear adjacent to a piece + * of content. + * + * ```tsx + * + * Hover me + * + * ``` + * + * ## Differences between ``, ``, and ``. + * + * These three components provide similar functionality, but are meant to be + * used in distinct ways. + * + * - ``: Use dialogs for interactive or informational content that + * should take the user's attention above everything else on the page. Dialogs + * are typically opened in response to a click from a user, but may also be + * opened and closed programmatically. + * - ``: Use popovers for interactive or informational content that + * should be visually tied to a specific element on the page, such as the + * dropdown menu underneath the menu button. Popovers are typically opened in + * response to a click from a user, but may also be opened and closed + * programmatically. + * - ``: Use tooltips for plain-text informational content that + * should be visually tied to a specific element on the page. Tooltips are + * opened in response to the user hovering over that element. + */ +export const Tooltip = ({ title, message, children }: TooltipProps) => ( + + {children} + + + {title &&
{title}
} + + {message} +
+
+
+); diff --git a/packages/ui-tailwind/src/ValueView/index.stories.tsx b/packages/ui-tailwind/src/ValueView/index.stories.tsx new file mode 100644 index 0000000000..95489668fc --- /dev/null +++ b/packages/ui-tailwind/src/ValueView/index.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { ValueViewComponent } from '.'; +import { + DELEGATION_VALUE_VIEW, + PENUMBRA_VALUE_VIEW, + UNBONDING_VALUE_VIEW, + UNKNOWN_ASSET_ID_VALUE_VIEW, + UNKNOWN_ASSET_VALUE_VIEW, +} from '../utils/bufs'; + +const meta: Meta = { + component: ValueViewComponent, + tags: ['autodocs', '!dev', 'density'], + argTypes: { + valueView: { + options: [ + 'Penumbra', + 'Delegation token', + 'Unbonding token', + 'Unknown asset', + 'Unknown asset ID', + ], + mapping: { + Penumbra: PENUMBRA_VALUE_VIEW, + 'Delegation token': DELEGATION_VALUE_VIEW, + 'Unbonding token': UNBONDING_VALUE_VIEW, + 'Unknown asset': UNKNOWN_ASSET_VALUE_VIEW, + 'Unknown asset ID': UNKNOWN_ASSET_ID_VALUE_VIEW, + }, + }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + valueView: PENUMBRA_VALUE_VIEW, + context: 'default', + priority: 'primary', + }, +}; diff --git a/packages/ui-tailwind/src/ValueView/index.tsx b/packages/ui-tailwind/src/ValueView/index.tsx new file mode 100644 index 0000000000..d2fe2e9ab3 --- /dev/null +++ b/packages/ui-tailwind/src/ValueView/index.tsx @@ -0,0 +1,95 @@ +import { ReactNode } from 'react'; +import { ValueView } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { getMetadata } from '@penumbra-zone/getters/value-view'; +import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view'; +import { ConditionalWrap } from '../ConditionalWrap'; +import { Pill, PillProps } from '../Pill'; +import { Text } from '../Text'; +import { AssetIcon } from '../AssetIcon'; +import { Density, useDensity } from '../utils/density'; +import cn from 'clsx'; + +type Context = 'default' | 'table'; + +const ValueText = ({ children, density }: { children: ReactNode; density: Density }) => { + if (density === 'sparse') { + return {children}; + } + + return {children}; +}; + +export interface ValueViewComponentProps { + valueView?: ValueView; + /** + * A `ValueViewComponent` will be rendered differently depending on which + * context it's rendered in. By default, it'll be rendered in a pill. But in a + * table context, it'll be rendered as just an icon and text. + */ + context?: SelectedContext; + /** + * Use `primary` in most cases, or `secondary` when this value view + * represents a secondary value, such as when it's an equivalent value of a + * numeraire. + */ + priority?: PillProps['priority']; +} + +/** + * `ValueViewComponent` renders a `ValueView` — its amount, icon, and symbol. + * Use this anywhere you would like to render a `ValueView`. + * + * Note that `ValueViewComponent` only has density variants when the `context` + * is `default`. For the `table` context, there is only one density. + */ +export const ValueViewComponent = ({ + valueView, + context, + priority = 'primary', +}: ValueViewComponentProps) => { + const density = useDensity(); + + if (!valueView) { + return null; + } + + const formattedAmount = getFormattedAmtFromValueView(valueView, true); + const metadata = getMetadata.optional(valueView); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- possibly empty string + const symbol = metadata?.symbol || 'Unknown'; + + return ( + ( + +
+ {children} +
+
+ )} + > + +
+ +
+ +
+
+ {formattedAmount} +
+
+ {symbol} +
+
+
+
+ ); +}; diff --git a/packages/ui-tailwind/src/WalletBalance/index.stories.tsx b/packages/ui-tailwind/src/WalletBalance/index.stories.tsx new file mode 100644 index 0000000000..c1e5c7d0ff --- /dev/null +++ b/packages/ui-tailwind/src/WalletBalance/index.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { WalletBalance } from '.'; +import { OSMO_BALANCE, PENUMBRA2_BALANCE, PENUMBRA_BALANCE } from '../utils/bufs'; + +const meta: Meta = { + component: WalletBalance, + tags: ['autodocs', '!dev'], + argTypes: { + balance: { + options: ['Penumbra balance', 'Account 2', 'Osmo balance'], + mapping: { + 'Penumbra balance': PENUMBRA_BALANCE, + 'Account 2': PENUMBRA2_BALANCE, + 'Osmo balance': OSMO_BALANCE, + }, + }, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + balance: PENUMBRA_BALANCE, + }, +}; diff --git a/packages/ui-tailwind/src/WalletBalance/index.tsx b/packages/ui-tailwind/src/WalletBalance/index.tsx new file mode 100644 index 0000000000..50bb0cc80e --- /dev/null +++ b/packages/ui-tailwind/src/WalletBalance/index.tsx @@ -0,0 +1,83 @@ +import type { MouseEventHandler } from 'react'; +import { Wallet } from 'lucide-react'; +import cn from 'clsx'; +import type { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; +import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view'; +import { + getAddressIndex, + getBalanceView, + getMetadataFromBalancesResponse, +} from '@penumbra-zone/getters/balances-response'; +import { ActionType, getOutlineColorByActionType } from '../utils/action-type'; +import { Text } from '../Text'; + +const getColorByActionType = (actionType: ActionType, disabled?: boolean): string => { + if (disabled) { + return cn('text-text-muted'); + } + if (actionType === 'destructive') { + return cn('text-destructive-light'); + } + return cn('text-text-secondary'); +}; + +export interface WalletBalanceProps { + balance?: BalancesResponse; + actionType?: ActionType; + disabled?: boolean; + onClick?: MouseEventHandler; +} + +/** + * `WalletBalance` renders a `BalancesResponse` — its account index, amount,, and symbol. + * Use this anywhere you would like to render a `BalancesResponse`. + * + * Allows clicking on the wallet icon. + */ +export const WalletBalance = ({ + balance, + actionType = 'default', + disabled, + onClick, +}: WalletBalanceProps) => { + const account = getAddressIndex.optional(balance); + const valueView = getBalanceView.optional(balance); + const metadata = getMetadataFromBalancesResponse.optional(balance); + + if (!valueView || !account || !metadata) { + return null; + } + + return ( +
+ + + + {getFormattedAmtFromValueView(valueView, true)} {metadata.symbol || 'Unknown'} + +
+ ); +}; diff --git a/packages/ui-tailwind/src/theme/fonts.css b/packages/ui-tailwind/src/theme/fonts.css new file mode 100644 index 0000000000..4ffe3e1047 --- /dev/null +++ b/packages/ui-tailwind/src/theme/fonts.css @@ -0,0 +1,120 @@ +@font-face { + font-family: 'Iosevka Term'; + font-weight: 400; + src: url('./fonts/IosevkaTerm-Regular.woff2') format('woff2'); +} +/* latin-ext */ +@font-face { + font-family: 'Poppins'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('./fonts/Poppins-Italic-LatinExt.woff2') format('woff2'); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, + U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Poppins'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('./fonts/Poppins-Italic.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, + U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, + U+FFFD; +} +/* latin-ext */ +@font-face { + font-family: 'Poppins'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: url('./fonts/Poppins-MediumItalic-LatinExt.woff2') format('woff2'); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, + U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Poppins'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: url('./fonts/Poppins-MediumItalic.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, + U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, + U+FFFD; +} +/* latin-ext */ +@font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('./fonts/Poppins-Regular-LatinExt.woff2') format('woff2'); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, + U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('./fonts/Poppins-Regular.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, + U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, + U+FFFD; +} +/* latin-ext */ +@font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('./fonts/Poppins-Medium-LatinExt.woff2') format('woff2'); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, + U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('./fonts/Poppins-Medium.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, + U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, + U+FFFD; +} +/* vietnamese */ +@font-face { + font-family: 'Work Sans'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('./fonts/WorkSans-Medium-Vietnamese.woff2') format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, + U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Work Sans'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('./fonts/WorkSans-Medium-LatinExt.woff2') format('woff2'); + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, + U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Work Sans'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('./fonts/WorkSans-Medium.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, + U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, + U+FFFD; +} diff --git a/packages/ui-tailwind/src/theme/fonts/IosevkaTerm-Regular.woff2 b/packages/ui-tailwind/src/theme/fonts/IosevkaTerm-Regular.woff2 new file mode 100644 index 0000000000..4c0d1e4810 Binary files /dev/null and b/packages/ui-tailwind/src/theme/fonts/IosevkaTerm-Regular.woff2 differ diff --git a/packages/ui-tailwind/src/theme/fonts/Poppins-Italic-LatinExt.woff2 b/packages/ui-tailwind/src/theme/fonts/Poppins-Italic-LatinExt.woff2 new file mode 100644 index 0000000000..98bf7e0a4a Binary files /dev/null and b/packages/ui-tailwind/src/theme/fonts/Poppins-Italic-LatinExt.woff2 differ diff --git a/packages/ui-tailwind/src/theme/fonts/Poppins-Italic.woff2 b/packages/ui-tailwind/src/theme/fonts/Poppins-Italic.woff2 new file mode 100644 index 0000000000..7c769e4d9b Binary files /dev/null and b/packages/ui-tailwind/src/theme/fonts/Poppins-Italic.woff2 differ diff --git a/packages/ui-tailwind/src/theme/fonts/Poppins-Medium-LatinExt.woff2 b/packages/ui-tailwind/src/theme/fonts/Poppins-Medium-LatinExt.woff2 new file mode 100644 index 0000000000..e567af4a1b Binary files /dev/null and b/packages/ui-tailwind/src/theme/fonts/Poppins-Medium-LatinExt.woff2 differ diff --git a/packages/ui-tailwind/src/theme/fonts/Poppins-Medium.woff2 b/packages/ui-tailwind/src/theme/fonts/Poppins-Medium.woff2 new file mode 100644 index 0000000000..ebe2c494e9 Binary files /dev/null and b/packages/ui-tailwind/src/theme/fonts/Poppins-Medium.woff2 differ diff --git a/packages/ui-tailwind/src/theme/fonts/Poppins-MediumItalic-LatinExt.woff2 b/packages/ui-tailwind/src/theme/fonts/Poppins-MediumItalic-LatinExt.woff2 new file mode 100644 index 0000000000..4e56f206f4 Binary files /dev/null and b/packages/ui-tailwind/src/theme/fonts/Poppins-MediumItalic-LatinExt.woff2 differ diff --git a/packages/ui-tailwind/src/theme/fonts/Poppins-MediumItalic.woff2 b/packages/ui-tailwind/src/theme/fonts/Poppins-MediumItalic.woff2 new file mode 100644 index 0000000000..3fede4b83b Binary files /dev/null and b/packages/ui-tailwind/src/theme/fonts/Poppins-MediumItalic.woff2 differ diff --git a/packages/ui-tailwind/src/theme/fonts/Poppins-Regular-LatinExt.woff2 b/packages/ui-tailwind/src/theme/fonts/Poppins-Regular-LatinExt.woff2 new file mode 100644 index 0000000000..23b9ce118e Binary files /dev/null and b/packages/ui-tailwind/src/theme/fonts/Poppins-Regular-LatinExt.woff2 differ diff --git a/packages/ui-tailwind/src/theme/fonts/Poppins-Regular.woff2 b/packages/ui-tailwind/src/theme/fonts/Poppins-Regular.woff2 new file mode 100644 index 0000000000..ed45aa4a70 Binary files /dev/null and b/packages/ui-tailwind/src/theme/fonts/Poppins-Regular.woff2 differ diff --git a/packages/ui-tailwind/src/theme/fonts/WorkSans-Medium-LatinExt.woff2 b/packages/ui-tailwind/src/theme/fonts/WorkSans-Medium-LatinExt.woff2 new file mode 100644 index 0000000000..e11f4626b6 Binary files /dev/null and b/packages/ui-tailwind/src/theme/fonts/WorkSans-Medium-LatinExt.woff2 differ diff --git a/packages/ui-tailwind/src/theme/fonts/WorkSans-Medium-Vietnamese.woff2 b/packages/ui-tailwind/src/theme/fonts/WorkSans-Medium-Vietnamese.woff2 new file mode 100644 index 0000000000..aeb59df91e Binary files /dev/null and b/packages/ui-tailwind/src/theme/fonts/WorkSans-Medium-Vietnamese.woff2 differ diff --git a/packages/ui-tailwind/src/theme/fonts/WorkSans-Medium.woff2 b/packages/ui-tailwind/src/theme/fonts/WorkSans-Medium.woff2 new file mode 100644 index 0000000000..12b62a4dd6 Binary files /dev/null and b/packages/ui-tailwind/src/theme/fonts/WorkSans-Medium.woff2 differ diff --git a/packages/ui-tailwind/src/theme/globals.css b/packages/ui-tailwind/src/theme/globals.css new file mode 100644 index 0000000000..6c497d5fbf --- /dev/null +++ b/packages/ui-tailwind/src/theme/globals.css @@ -0,0 +1,18 @@ +::-webkit-scrollbar { + width: 4px; +} + +/* Track */ +::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.25); +} + +/* Handle */ +::-webkit-scrollbar-thumb { + background: #888; +} + +/* Handle on hover */ +::-webkit-scrollbar-thumb:hover { + background: #555; +} diff --git a/packages/ui-tailwind/src/theme/index.tsx b/packages/ui-tailwind/src/theme/index.tsx new file mode 100644 index 0000000000..7d7c15227c --- /dev/null +++ b/packages/ui-tailwind/src/theme/index.tsx @@ -0,0 +1,6 @@ +/** This import of fonts.css creates the 'dist/styles.css' */ +import './fonts.css'; +import './globals.css'; + +export { tailwindConfig, withPenumbra } from './tailwind-config'; +export { theme } from './theme'; diff --git a/packages/ui-tailwind/src/theme/tailwind-config.ts b/packages/ui-tailwind/src/theme/tailwind-config.ts new file mode 100644 index 0000000000..6028b96233 --- /dev/null +++ b/packages/ui-tailwind/src/theme/tailwind-config.ts @@ -0,0 +1,67 @@ +import type { Config } from 'tailwindcss'; +import { theme } from './theme'; + +/** + * For consumers using Tailwind, this file exports a Tailwind config based on + * the Penumbra UI theme values. + */ +export const tailwindConfig = { + content: [ + './node_modules/@penumbra-zone/ui-tailwind/**/*.{js,ts,jsx,tsx,mdx,css}', + './node_modules/@penumbra-zone/ui/**/*.{js,ts,jsx,tsx,mdx,css}', + ], + theme: { + extend: { + borderRadius: theme.borderRadius, + colors: theme.color, + fontFamily: theme.font, + fontSize: theme.fontSize, + lineHeight: theme.lineHeight, + backdropBlur: theme.blur, + backgroundImage: theme.gradient, + keyframes: theme.keyframes, + animation: theme.animation, + screens: Object.keys(theme.breakpoint).reduce( + (prev, curr) => ({ + ...prev, + [curr]: theme.breakpoint[curr as keyof (typeof theme)['breakpoint']].toString() + 'px', + }), + {}, + ), + + // No need to customize spacing, since Tailwind's default is the same as + // Penumbra UI's. + }, + }, +} as const; + +const composeContent = (content: Config['content']): Config['content'] => { + if (typeof content === 'string') { + return [...tailwindConfig.content, content]; + } + + if (Array.isArray(content)) { + return [...tailwindConfig.content, ...content]; + } + + content.files.push(...tailwindConfig.content); + return content; +}; + +/** + * Wrap your Tailwind config with `withPenumbra` function to support Penumbra classes + * and styles of the `@penumbra-zone/ui` library. + */ +export const withPenumbra = (config: Config): Config => { + return { + ...config, + content: composeContent(config.content), + theme: { + ...config.theme, + extend: { + ...tailwindConfig.theme.extend, + ...config.theme?.extend, + }, + }, + }; +}; diff --git a/packages/ui-tailwind/src/theme/theme.ts b/packages/ui-tailwind/src/theme/theme.ts new file mode 100644 index 0000000000..5370c6acff --- /dev/null +++ b/packages/ui-tailwind/src/theme/theme.ts @@ -0,0 +1,295 @@ +import { hexOpacity } from '../utils/hexOpacity'; + +/** + * Used for reference in the `theme` object below. Not intended to be used + * directly by consumers, but rather as a semantic reference for building the + * theme. + */ +const PALETTE = { + green: { + 50: '#f0fdf4', + 100: '#DEFAE8', + 200: '#BFF3D1', + 300: '#8DE8AE', + 400: '#55D383', + 500: '#2DBA61', + 600: '#1F9A4C', + 700: '#1C793F', + 800: '#1C5F36', + 900: '#194E2E', + 950: '#03160B', + }, + neutral: { + 50: '#fafafa', + 100: '#f5f5f5', + 200: '#e5e5e5', + 300: '#d4d4d4', + 400: '#a3a3a3', + 500: '#737373', + 600: '#525252', + 700: '#404040', + 800: '#262626', + 900: '#171717', + 950: '#0a0a0a', + }, + orange: { + 50: '#FFF8ED', + 100: '#FDEED6', + 200: '#FBDBAD', + 300: '#F8C079', + 400: '#F49C43', + 500: '#F07E1C', + 600: '#E16615', + 700: '#BA4D14', + 800: '#933E19', + 900: '#773517', + 950: '#200B04', + }, + purple: { + 50: '#FAF7FC', + 100: '#F5F0F7', + 200: '#E9E0EE', + 300: '#D8C7E0', + 400: '#C1A6CC', + 500: '#A582B3', + 600: '#886693', + 700: '#705279', + 800: '#5F4766', + 900: '#4F3C53', + 950: '#180E1B', + }, + red: { + 50: '#fef2f2', + 100: '#FCE4E4', + 200: '#FBCDCD', + 300: '#F8A9A9', + 400: '#F17878', + 500: '#E54E4E', + 600: '#CF3333', + 700: '#AF2626', + 800: '#902424', + 900: '#772525', + 950: '#1E0606', + }, + teal: { + 50: '#f1fcfa', + 100: '#D4F3EE', + 200: '#92DFD5', + 300: '#77D1C8', + 400: '#53AEA8', + 500: '#319B96', + 600: '#257C79', + 700: '#226362', + 800: '#204F4F', + 900: '#1F4242', + 950: '#031516', + }, + yellow: { + 50: '#FDFCE9', + 100: '#FBF7C6', + 200: '#F8EB90', + 300: '#F4DA50', + 400: '#E8C127', + 500: '#DDAD15', + 600: '#C0860E', + 700: '#99610F', + 800: '#7E4D15', + 900: '#6B3F18', + 950: '#201004', + }, + base: { + black: '#000000', + white: '#ffffff', + transparent: 'transparent', + }, +}; + +/** + * Call `theme.spacing(x)`, where `x` is the number of spacing units (in the + * Penumbra theme, 1 spacing unit = 4px) that you want to interpolate into your + * CSS or JavaScript. By default, returns a string with the number of pixels + * suffixed with `px` -- e.g., `theme.spacing(4)` returns `'16px'`. Pass + * `number` as the second argument to get back a number of pixels -- e.g., + * `theme.spacing(4, 'number')` returns `16`. + */ +function spacing(spacingUnits: number, returnType?: 'string'): string; +function spacing(spacingUnits: number, returnType: 'number'): number; +function spacing(spacingUnits: number, returnType?: 'string' | 'number'): string | number { + if (returnType === 'number') { + return spacingUnits * 4; + } + return `${spacingUnits * 4}px`; +} + +export const theme = { + blur: { + none: '0px', + xs: '4px', + sm: '8px', + md: '16px', + lg: '32px', + xl: '64px', + }, + borderRadius: { + none: '0px', + xs: '4px', + sm: '8px', + md: '12px', + lg: '16px', + xl: '20px', + '2xl': '24px', + full: '9999px', + }, + breakpoint: { + mobile: 0, + tablet: 600, + desktop: 900, + lg: 1200, + xl: 1600, + }, + color: { + neutral: { + main: PALETTE.neutral['700'], + light: PALETTE.neutral['400'], + dark: PALETTE.neutral['900'], + contrast: PALETTE.neutral['50'], + }, + primary: { + main: PALETTE.orange['700'], + light: PALETTE.orange['400'], + dark: PALETTE.orange['950'], + contrast: PALETTE.orange['50'], + }, + secondary: { + main: PALETTE.teal['700'], + light: PALETTE.teal['400'], + dark: PALETTE.teal['950'], + contrast: PALETTE.teal['50'], + }, + unshield: { + main: PALETTE.purple['700'], + light: PALETTE.purple['400'], + dark: PALETTE.purple['950'], + contrast: PALETTE.purple['50'], + }, + destructive: { + main: PALETTE.red['700'], + light: PALETTE.red['400'], + dark: PALETTE.red['950'], + contrast: PALETTE.red['50'], + }, + caution: { + main: PALETTE.yellow['700'], + light: PALETTE.yellow['400'], + dark: PALETTE.yellow['950'], + contrast: PALETTE.yellow['50'], + }, + success: { + main: PALETTE.green['700'], + light: PALETTE.green['400'], + dark: PALETTE.green['950'], + contrast: PALETTE.green['50'], + }, + base: { + black: PALETTE.base.black, + white: PALETTE.base.white, + transparent: PALETTE.base.transparent, + }, + text: { + primary: PALETTE.neutral['50'], + secondary: PALETTE.neutral['400'], + muted: PALETTE.neutral['700'], + special: PALETTE.orange['400'], + }, + action: { + hoverOverlay: PALETTE.teal['400'] + hexOpacity(0.15), + activeOverlay: PALETTE.neutral['950'] + hexOpacity(0.15), + disabledOverlay: PALETTE.neutral['950'] + hexOpacity(0.8), + primaryFocusOutline: PALETTE.orange['400'], + secondaryFocusOutline: PALETTE.teal['400'], + unshieldFocusOutline: PALETTE.purple['400'], + neutralFocusOutline: PALETTE.neutral['400'], + destructiveFocusOutline: PALETTE.red['400'], + }, + other: { + tonalStroke: PALETTE.neutral['50'] + hexOpacity(0.15), + tonalFill5: PALETTE.neutral['50'] + hexOpacity(0.05), + tonalFill10: PALETTE.neutral['50'] + hexOpacity(0.1), + solidStroke: PALETTE.neutral['700'], + dialogBackground: PALETTE.teal['700'] + hexOpacity(0.1), + overlay: PALETTE.base.black + hexOpacity(0.5), + }, + }, + gradient: { + card: 'linear-gradient(136deg, rgba(250, 250, 250, 0.1) 6.32%, rgba(250, 250, 250, 0.01) 75.55%)', + tabNeutral: 'radial-gradient(at 50% 100%, rgba(163, 163, 163, 0.35) 0%, transparent 50%)', + tabAccent: 'radial-gradient(at 50% 100%, rgba(244, 156, 67, 0.35) 0%, transparent 50%)', + tabUnshield: 'radial-gradient(at 50% 100%, rgba(193, 166, 204, 0.35) 0%, transparent 50%)', + dialogSuccess: `radial-gradient(100% 100% at 0% 0%, rgba(83, 174, 168, 0.20) 0%, rgba(83, 174, 168, 0.02) 100%)`, + dialogCaution: `radial-gradient(100% 100% at 0% 0%, rgba(153, 97, 15, 0.20) 0%, rgba(153, 97, 15, 0.02) 100%)`, + dialogError: `radial-gradient(100% 100% at 0% 0%, rgba(175, 38, 38, 0.20) 0%, rgba(175, 38, 38, 0.02) 100%)`, + buttonHover: + 'linear-gradient(0deg, rgba(83, 174, 168, 0.15) 0%, rgba(83, 174, 168, 0.15) 100%)', + buttonDisabled: 'linear-gradient(0deg, rgba(10, 10, 10, 0.8) 0%, rgba(10, 10, 10, 0.8) 100%)', + progressLoading: + 'linear-gradient(90deg,rgba(255, 255, 255, 0) 0%,#fff 50%,rgba(255, 255, 255, 0) 100%)', + }, + font: { + default: 'Poppins', + mono: 'Iosevka Term, monospace', + heading: 'Work Sans', + }, + fontSize: { + text9xl: '8rem', + text8xl: '6rem', + text7xl: '4.5rem', + text6xl: '3.75rem', + text5xl: '3rem', + text4xl: '2.25rem', + text3xl: '1.875rem', + text2xl: '1.5rem', + textXl: '1.25rem', + textLg: '1.125rem', + textBase: '1rem', + textSm: '0.875rem', + textXs: '0.75rem', + }, + lineHeight: { + text9xl: '8.25rem', + text8xl: '6.25rem', + text7xl: '5rem', + text6xl: '4.25rem', + text5xl: '3.5rem', + text4xl: '2.75rem', + text3xl: '2.5rem', + text2xl: '2.25rem', + textXl: '2rem', + textLg: '1.75rem', + textBase: '1.5rem', + textSm: '1.25rem', + textXs: '1rem', + }, + spacing, + zIndex: { + disabledOverlay: 10, + }, + keyframes: { + scale: { + '0%': { opacity: '0', transform: 'scale(0)' }, + '100%': { opacity: '1', transform: 'scale(1)' }, + }, + progress: { + '0%': { left: '-20%' }, + '100%': { left: '100%' }, + }, + }, + animation: { + scale: 'scale 0.15s ease-out', + progress: 'progress 1s linear infinite', + }, +} as const; + +type Theme = typeof theme; +export type Color = keyof Theme['color']; +export type ColorVariant = keyof Theme['color']['neutral']; +export type TextColorVariant = keyof Theme['color']['text']; diff --git a/packages/ui-tailwind/src/utils/action-type.ts b/packages/ui-tailwind/src/utils/action-type.ts new file mode 100644 index 0000000000..34540f71ea --- /dev/null +++ b/packages/ui-tailwind/src/utils/action-type.ts @@ -0,0 +1,98 @@ +import cn from 'clsx'; + +export type ActionType = 'default' | 'accent' | 'unshield' | 'destructive'; + +export const getColorByActionType = (actionType: ActionType): string => { + if (actionType === 'destructive') { + return cn('text-destructive-light'); + } + return cn('text-text-primary'); +}; + +const AFTER_OUTLINE_COLOR_MAP: Record = { + default: cn('focus-within:after:outline-action-neutralFocusOutline'), + accent: cn('focus-within:after:outline-action-primaryFocusOutline'), + unshield: cn('focus-within:after:outline-action-unshieldFocusOutline'), + destructive: cn('focus-within:after:outline-action-destructiveFocusOutline'), +}; + +const OUTLINE_COLOR_MAP: Record = { + default: cn('outline-neutral-main'), + accent: cn('outline-primary-main'), + unshield: cn('outline-unshield-main'), + destructive: cn('outline-destructive-main'), +}; + +const BEFORE_OUTLINE_COLOR_MAP: Record = { + default: cn('focus:before:outline-action-neutralFocusOutline'), + accent: cn('focus:before:outline-action-primaryFocusOutline'), + unshield: cn('focus:before:outline-action-unshieldFocusOutline'), + destructive: cn('focus:before:outline-action-destructiveFocusOutline'), +}; + +const FOCUS_OUTLINE_COLOR_MAP: Record = { + default: cn('focus:outline-action-neutralFocusOutline'), + accent: cn('focus:outline-action-primaryFocusOutline'), + unshield: cn('focus:outline-action-unshieldFocusOutline'), + destructive: cn('focus:outline-action-destructiveFocusOutline'), +}; + +const FOCUS_WITHIN_OUTLINE_COLOR_MAP: Record = { + default: cn('focus-within:outline-action-neutralFocusOutline'), + accent: cn('focus-within:outline-action-primaryFocusOutline'), + unshield: cn('focus-within:outline-action-unshieldFocusOutline'), + destructive: cn('focus-within:outline-action-destructiveFocusOutline'), +}; + +const ARIA_CHECKED_OUTLINE_COLOR_MAP: Record = { + default: cn('aria-checked:outline-action-neutralFocusOutline'), + accent: cn('aria-checked:outline-action-primaryFocusOutline'), + unshield: cn('aria-checked:outline-action-unshieldFocusOutline'), + destructive: cn('aria-checked:outline-action-destructiveFocusOutline'), +}; + +const BORDER_COLOR_MAP: Record = { + default: cn('border-neutral-main'), + accent: cn('border-primary-main'), + unshield: cn('border-unshield-main'), + destructive: cn('border-destructive-main'), +}; + +const BACKGROUND_COLOR_MAP: Record = { + default: cn('bg-neutral-main'), + accent: cn('bg-primary-main'), + unshield: cn('bg-unshield-main'), + destructive: cn('bg-destructive-main'), +}; + +export const getAfterOutlineColorByActionType = (actionType: ActionType): string => { + return AFTER_OUTLINE_COLOR_MAP[actionType]; +}; + +export const getBeforeOutlineColorByActionType = (actionType: ActionType): string => { + return BEFORE_OUTLINE_COLOR_MAP[actionType]; +}; + +export const getOutlineColorByActionType = (actionType: ActionType): string => { + return OUTLINE_COLOR_MAP[actionType]; +}; + +export const getFocusOutlineColorByActionType = (actionType: ActionType): string => { + return FOCUS_OUTLINE_COLOR_MAP[actionType]; +}; + +export const getFocusWithinOutlineColorByActionType = (actionType: ActionType): string => { + return FOCUS_WITHIN_OUTLINE_COLOR_MAP[actionType]; +}; + +export const getAriaCheckedOutlineColorByActionType = (actionType: ActionType): string => { + return ARIA_CHECKED_OUTLINE_COLOR_MAP[actionType]; +}; + +export const getBorderColorByActionType = (actionType: ActionType): string => { + return BORDER_COLOR_MAP[actionType]; +}; + +export const getBackgroundColorByActionType = (actionType: ActionType): string => { + return BACKGROUND_COLOR_MAP[actionType]; +}; diff --git a/packages/ui-tailwind/src/utils/bufs/address-view.ts b/packages/ui-tailwind/src/utils/bufs/address-view.ts new file mode 100644 index 0000000000..500ce31f81 --- /dev/null +++ b/packages/ui-tailwind/src/utils/bufs/address-view.ts @@ -0,0 +1,54 @@ +import { AddressView } from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys_pb'; +import { addressFromBech32m } from '@penumbra-zone/bech32m/penumbra'; + +export const ADDRESS_VIEW_DECODED = new AddressView({ + addressView: { + case: 'decoded', + value: { + address: { inner: new Uint8Array(80) }, + index: { + account: 0, + randomizer: new Uint8Array([0, 0, 0]), + }, + }, + }, +}); + +export const ADDRESS2_VIEW_DECODED = new AddressView({ + addressView: { + case: 'decoded', + value: { + address: { inner: new Uint8Array(80) }, + index: { + account: 2, + randomizer: new Uint8Array([0, 0, 0]), + }, + }, + }, +}); + +export const ADDRESS_VIEW_DECODED_ONE_TIME = new AddressView({ + addressView: { + case: 'decoded', + value: { + address: { inner: new Uint8Array(80) }, + index: { + account: 0, + // A one-time address is defined by a randomizer with at least one + // non-zero byte. + randomizer: new Uint8Array([1, 2, 3]), + }, + }, + }, +}); + +export const ADDRESS_VIEW_OPAQUE = new AddressView({ + addressView: { + case: 'opaque', + value: { + address: addressFromBech32m( + 'penumbra1e8k5cyds484dxvapeamwveh5khqv4jsvyvaf5wwxaaccgfghm229qw03pcar3ryy8smptevstycch0qk3uu0rgkvtjpxy3cu3rjd0agawqtlz6erev28a6sg69u7cxy0t02nd4', + ), + }, + }, +}); diff --git a/packages/ui-tailwind/src/utils/bufs/balances-responses.ts b/packages/ui-tailwind/src/utils/bufs/balances-responses.ts new file mode 100644 index 0000000000..d28ed43357 --- /dev/null +++ b/packages/ui-tailwind/src/utils/bufs/balances-responses.ts @@ -0,0 +1,18 @@ +import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; +import { OSMO_VALUE_VIEW, PENUMBRA_VALUE_VIEW } from './value-view.ts'; +import { ADDRESS2_VIEW_DECODED, ADDRESS_VIEW_DECODED } from './address-view.ts'; + +export const PENUMBRA_BALANCE = new BalancesResponse({ + balanceView: PENUMBRA_VALUE_VIEW, + accountAddress: ADDRESS_VIEW_DECODED, +}); + +export const PENUMBRA2_BALANCE = new BalancesResponse({ + balanceView: PENUMBRA_VALUE_VIEW, + accountAddress: ADDRESS2_VIEW_DECODED, +}); + +export const OSMO_BALANCE = new BalancesResponse({ + balanceView: OSMO_VALUE_VIEW, + accountAddress: ADDRESS_VIEW_DECODED, +}); diff --git a/packages/ui-tailwind/src/utils/bufs/index.ts b/packages/ui-tailwind/src/utils/bufs/index.ts new file mode 100644 index 0000000000..19f47a03ee --- /dev/null +++ b/packages/ui-tailwind/src/utils/bufs/index.ts @@ -0,0 +1,9 @@ +/** + * The `bufs` directory is meant to be used within Storybook or Vitest + * environments only, and should not be used in the resulting library code. + */ + +export * from './metadata'; +export * from './value-view'; +export * from './address-view'; +export * from './balances-responses'; diff --git a/packages/ui-tailwind/src/utils/bufs/metadata.ts b/packages/ui-tailwind/src/utils/bufs/metadata.ts new file mode 100644 index 0000000000..4a1d954bea --- /dev/null +++ b/packages/ui-tailwind/src/utils/bufs/metadata.ts @@ -0,0 +1,78 @@ +import { bech32mIdentityKey } from '@penumbra-zone/bech32m/penumbravalid'; +import { AssetId, Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; + +const u8 = (length: number) => Uint8Array.from({ length }, () => Math.floor(Math.random() * 256)); +const validatorIk = { ik: u8(32) }; +const validatorIkString = bech32mIdentityKey(validatorIk); +const delString = 'delegation_' + validatorIkString; +const udelString = 'udelegation_' + validatorIkString; +const delAsset = { inner: u8(32) }; +const unbondString = 'unbonding_start_at_123_' + validatorIkString; +const uunbondString = 'uunbonding_start_at_123_' + validatorIkString; +const unbondAsset = { inner: u8(32) }; + +export const DELEGATION_TOKEN_METADATA = new Metadata({ + display: delString, + base: udelString, + denomUnits: [{ denom: udelString }, { denom: delString, exponent: 6 }], + name: 'Delegation token', + penumbraAssetId: delAsset, + symbol: `delUM(${validatorIkString})`, +}); + +export const UNBONDING_TOKEN_METADATA = new Metadata({ + display: unbondString, + base: uunbondString, + denomUnits: [{ denom: uunbondString }, { denom: unbondString, exponent: 6 }], + name: 'Unbonding token', + penumbraAssetId: unbondAsset, + symbol: `unbondUMat123(${validatorIkString})`, +}); + +export const PENUMBRA_METADATA = new Metadata({ + denomUnits: [ + { + denom: 'penumbra', + exponent: 6, + }, + { + denom: 'mpenumbra', + exponent: 3, + }, + { + denom: 'upenumbra', + }, + ], + base: 'upenumbra', + name: 'Penumbra', + display: 'penumbra', + symbol: 'UM', + penumbraAssetId: new AssetId({ inner: u8(32) }), + images: [ + { + svg: 'https://raw.githubusercontent.com/prax-wallet/registry/main/images/um.svg', + }, + ], +}); + +export const OSMO_METADATA = new Metadata({ + symbol: 'OSMO', + name: 'Osmosis', + penumbraAssetId: new AssetId({ inner: u8(32) }), + base: 'uosmo', + display: 'osmo', + denomUnits: [{ denom: 'uosmo' }, { denom: 'osmo', exponent: 6 }], +}); + +export const PIZZA_METADATA = new Metadata({ + symbol: 'PIZZA', + name: 'Pizza', + penumbraAssetId: new AssetId({ inner: u8(32) }), + base: 'upizza', + display: 'pizza', + denomUnits: [{ denom: 'upizza' }, { denom: 'pizza', exponent: 6 }], +}); + +export const UNKNOWN_TOKEN_METADATA = new Metadata({ + penumbraAssetId: { inner: new Uint8Array([]) }, +}); diff --git a/packages/ui-tailwind/src/utils/bufs/value-view.ts b/packages/ui-tailwind/src/utils/bufs/value-view.ts new file mode 100644 index 0000000000..3ff1bfa55a --- /dev/null +++ b/packages/ui-tailwind/src/utils/bufs/value-view.ts @@ -0,0 +1,68 @@ +import { ValueView } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { + DELEGATION_TOKEN_METADATA, + OSMO_METADATA, + PENUMBRA_METADATA, + UNBONDING_TOKEN_METADATA, +} from './metadata.ts'; + +export const PENUMBRA_VALUE_VIEW = new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + amount: { hi: 0n, lo: 123_456_789_000n }, + metadata: PENUMBRA_METADATA, + }, + }, +}); + +export const OSMO_VALUE_VIEW = new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + amount: { hi: 0n, lo: 987_000_000n }, + metadata: OSMO_METADATA, + }, + }, +}); + +export const DELEGATION_VALUE_VIEW = new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + amount: { hi: 0n, lo: 123_000_000n }, + metadata: DELEGATION_TOKEN_METADATA, + }, + }, +}); + +export const UNBONDING_VALUE_VIEW = new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + amount: { hi: 0n, lo: 123_000_000n }, + metadata: UNBONDING_TOKEN_METADATA, + }, + }, +}); + +export const UNKNOWN_ASSET_VALUE_VIEW = new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + amount: { hi: 0n, lo: 123_000_000n }, + metadata: { + penumbraAssetId: { inner: new Uint8Array([]) }, + }, + }, + }, +}); + +export const UNKNOWN_ASSET_ID_VALUE_VIEW = new ValueView({ + valueView: { + case: 'unknownAssetId', + value: { + amount: { hi: 0n, lo: 123_000_000n }, + }, + }, +}); diff --git a/packages/ui-tailwind/src/utils/button.ts b/packages/ui-tailwind/src/utils/button.ts new file mode 100644 index 0000000000..3ea2ff425d --- /dev/null +++ b/packages/ui-tailwind/src/utils/button.ts @@ -0,0 +1,76 @@ +import cn from 'clsx'; +import type { Density } from './density'; +import { + getAfterOutlineColorByActionType, + getBeforeOutlineColorByActionType, + ActionType, + getBackgroundColorByActionType, +} from './action-type'; + +export type Priority = 'primary' | 'secondary'; + +interface ButtonStyleAttributes { + density: Density; + iconOnly?: boolean | 'adornment'; + actionType: ActionType; +} + +/** Shared styles to use for any `