diff --git a/.changeset/real-tigers-act.md b/.changeset/real-tigers-act.md new file mode 100644 index 000000000..08e125037 --- /dev/null +++ b/.changeset/real-tigers-act.md @@ -0,0 +1,5 @@ +--- +'@penumbra-zone/ui': minor +--- + +Add `Dialog.RadioGroup` and `Dialog.RadioItem` components diff --git a/packages/ui/src/AssetSelector/Custom.tsx b/packages/ui/src/AssetSelector/Custom.tsx index 89630c463..77da9b165 100644 --- a/packages/ui/src/AssetSelector/Custom.tsx +++ b/packages/ui/src/AssetSelector/Custom.tsx @@ -1,6 +1,5 @@ import { ReactNode, useId, useState } from 'react'; import { styled } from 'styled-components'; -import { RadioGroup } from '@radix-ui/react-radio-group'; import { Dialog } from '../Dialog'; import { IsAnimatingProvider } from '../IsAnimatingProvider'; import { getHash } from './shared/helpers.ts'; @@ -125,13 +124,13 @@ export const AssetSelectorCustom = ({ ) } > - + {typeof children === 'function' ? children({ onClose, getKeyHash: getHash }) : children} - + )} diff --git a/packages/ui/src/AssetSelector/ListItem.tsx b/packages/ui/src/AssetSelector/ListItem.tsx deleted file mode 100644 index 2103a7e40..000000000 --- a/packages/ui/src/AssetSelector/ListItem.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { RadioGroupItem } from '@radix-ui/react-radio-group'; -import { styled } from 'styled-components'; -import { motion } from 'framer-motion'; -import { AssetIcon } from '../AssetIcon'; -import { Text } from '../Text'; -import { getHash, isBalancesResponse } from './shared/helpers.ts'; -import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view'; -import { - getAddressIndex, - getBalanceView, - getMetadataFromBalancesResponse, -} from '@penumbra-zone/getters/balances-response'; -import { ActionType, getOutlineColorByActionType } from '../utils/ActionType.ts'; -import { asTransientProps } from '../utils/asTransientProps.ts'; -import { KeyboardEventHandler, MouseEventHandler } from 'react'; -import { useAssetsSelector } from './shared/Context.tsx'; -import { AssetSelectorValue } from './shared/types.ts'; -import { media } from '../utils/media.ts'; - -const Root = styled(motion.button)<{ - $isSelected: boolean; - $actionType: ActionType; - $disabled?: boolean; -}>` - border-radius: ${props => props.theme.borderRadius.sm}; - background-color: ${props => props.theme.color.other.tonalFill5}; - padding: ${props => props.theme.spacing(3)}; - - display: flex; - justify-content: space-between; - align-items: center; - text-align: left; - transition: - background 0.15s, - outline 0.15s; - - &:hover { - background: linear-gradient( - 0deg, - ${props => props.theme.color.action.hoverOverlay} 0%, - ${props => props.theme.color.action.hoverOverlay} 100% - ), - ${props => props.theme.color.other.tonalFill5}; - } - - &:focus { - background: linear-gradient( - 0deg, - ${props => props.theme.color.action.hoverOverlay} 0%, - ${props => props.theme.color.action.hoverOverlay} 100% - ), - ${props => props.theme.color.other.tonalFill5}; - outline: 2px solid ${props => getOutlineColorByActionType(props.theme, props.$actionType)}; - } - - &[aria-checked='true'] { - outline: 2px solid ${props => getOutlineColorByActionType(props.theme, props.$actionType)}; - } - - &:disabled { - background: linear-gradient( - 0deg, - ${props => props.theme.color.action.disabledOverlay} 0%, - ${props => props.theme.color.action.disabledOverlay} 100% - ), - ${props => props.theme.color.other.tonalFill5}; - } -`; - -const AssetInfo = styled.div` - display: flex; - gap: ${props => props.theme.spacing(2)}; - align-items: center; -`; - -const AssetTitle = styled.div` - display: flex; - align-items: center; - white-space: nowrap; - gap: ${props => props.theme.spacing(1)}; -`; - -const AssetTitleText = styled(Text)` - display: inline-block; - max-width: 100px; - - ${media.tablet` - max-width: 300px; - `} - - ${media.lg` - max-width: 400px; - `} -`; - -const Balance = styled.div` - display: flex; - flex-direction: column; - align-items: flex-end; -`; - -export interface ListItemProps { - /** - * 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 ListItem = ({ value, disabled, actionType = 'default' }: ListItemProps) => { - const { onClose, onChange, value: selectedValue } = useAssetsSelector(); - - const hash = getHash(value); - const isSelected = !!selectedValue && getHash(value) === getHash(selectedValue); - - const metadata = isBalancesResponse(value) - ? getMetadataFromBalancesResponse.optional(value) - : value; - - const balance = isBalancesResponse(value) - ? { - addressIndexAccount: getAddressIndex.optional(value)?.account, - valueView: getBalanceView.optional(value), - } - : undefined; - - const onEnter: KeyboardEventHandler = event => { - if (event.key === 'Enter') { - onClose(); - } - }; - - const onMouseDown: MouseEventHandler = () => { - // close only after the value is selected by onClick - setTimeout(() => { - onClose(); - }, 0); - }; - - // click is triggered by radix-ui on focus, click, arrow selection, etc. – basically always - const onClick = () => { - onChange?.(value); - }; - - return ( - - - - -
- - {balance?.valueView && ( - - {getFormattedAmtFromValueView(balance.valueView, true)}{' '} - - )} - - {metadata?.symbol ?? 'Unknown'} - - - {metadata?.name && ( - color.text.secondary} as='div'> - {metadata.name} - - )} -
-
- - {balance?.addressIndexAccount !== undefined && ( - - color.text.secondary}> - #{balance.addressIndexAccount} - - color.text.secondary}> - Account - - - )} -
-
- ); -}; diff --git a/packages/ui/src/AssetSelector/SelectItem.tsx b/packages/ui/src/AssetSelector/SelectItem.tsx new file mode 100644 index 000000000..b7dcba099 --- /dev/null +++ b/packages/ui/src/AssetSelector/SelectItem.tsx @@ -0,0 +1,103 @@ +import { styled } from 'styled-components'; +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/ActionType'; +import { AssetSelectorValue } from './shared/types'; +import { media } from '../utils/media'; +import { getHash, isBalancesResponse } from './shared/helpers'; +import { RadioItem } from '../Dialog/RadioItem'; +import { useAssetsSelector } from './shared/Context'; + +const AssetTitleText = styled(Text)` + display: inline-block; + max-width: 100px; + + ${media.tablet` + max-width: 300px; + `} + + ${media.lg` + max-width: 400px; + `} +`; + +const Balance = styled.div` + display: flex; + flex-direction: column; + align-items: flex-end; +`; + +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 && ( + + color.text.secondary}> + #{balance.addressIndexAccount} + + color.text.secondary}> + Account + + + ) + } + /> + ); +}; diff --git a/packages/ui/src/AssetSelector/index.tsx b/packages/ui/src/AssetSelector/index.tsx index 7823cd23f..6ee71f2de 100644 --- a/packages/ui/src/AssetSelector/index.tsx +++ b/packages/ui/src/AssetSelector/index.tsx @@ -7,7 +7,7 @@ import { isBalancesResponse, isMetadata } from './shared/helpers.ts'; import { filterMetadataOrBalancesResponseByText } from './shared/filterMetadataOrBalancesResponseByText.ts'; import { AssetSelectorBaseProps, AssetSelectorValue } from './shared/types.ts'; import { AssetSelectorCustom, AssetSelectorCustomProps } from './Custom.tsx'; -import { ListItem, ListItemProps } from './ListItem.tsx'; +import { Item, AssetSelectorItemProps } from './SelectItem.tsx'; import { Text } from '../Text'; import { filterAssets, groupAndSortBalances } from './shared/groupAndSort.ts'; @@ -35,7 +35,6 @@ export interface AssetSelectorProps extends AssetSelectorBaseProps { */ 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 @@ -85,7 +84,7 @@ export interface AssetSelectorProps extends AssetSelectorBaseProps { * > * {({ getKeyHash }) => * filteredOptions.map(option => ( - * + * * )) * } * @@ -134,7 +133,7 @@ export const AssetSelector = ({ {filteredBalances.map(([account, balances]) => ( {balances.map(balance => ( - + ))} ))} @@ -146,7 +145,7 @@ export const AssetSelector = ({ )} {filteredAssets.map(asset => ( - + ))} )} @@ -155,8 +154,8 @@ export const AssetSelector = ({ }; AssetSelector.Custom = AssetSelectorCustom; -AssetSelector.ListItem = ListItem; +AssetSelector.Item = Item; export { isBalancesResponse, isMetadata, groupAndSortBalances, filterAssets }; -export type { AssetSelectorValue, AssetSelectorCustomProps, ListItemProps }; +export type { AssetSelectorValue, AssetSelectorCustomProps, AssetSelectorItemProps }; diff --git a/packages/ui/src/Dialog/Content.tsx b/packages/ui/src/Dialog/Content.tsx new file mode 100644 index 000000000..77851100f --- /dev/null +++ b/packages/ui/src/Dialog/Content.tsx @@ -0,0 +1,157 @@ +import { MotionProp } from '../utils/MotionProp.ts'; +import { ReactNode, useContext } from 'react'; +import { ButtonGroup, ButtonGroupProps } from '../ButtonGroup'; +import { DialogContext } from './Context.tsx'; +import { EmptyContent } from './EmptyContent.tsx'; +import { Display } from '../Display'; +import { Grid } from '../Grid'; +import { Title as RadixDialogTitle, Close as RadixDialogClose } from '@radix-ui/react-dialog'; +import { Text } from '../Text'; +import { Density } from '../Density'; +import { Button } from '../Button'; +import { X } from 'lucide-react'; +import { styled } from 'styled-components'; +import { motion } from 'framer-motion'; + +const FullHeightWrapper = styled.div` + height: 100%; + min-height: 100svh; + max-height: 100lvh; + position: relative; + + display: flex; + align-items: center; +`; + +const DialogContentCard = styled(motion.div)` + position: relative; + width: 100%; + max-height: 75%; + box-sizing: border-box; + + background: ${props => props.theme.color.other.dialogBackground}; + border: 1px solid ${props => props.theme.color.other.tonalStroke}; + border-radius: ${props => props.theme.borderRadius.xl}; + backdrop-filter: blur(${props => props.theme.blur.xl}); + + display: flex; + flex-direction: column; + + /** + * We add 'pointer-events: auto' here so that clicks _inside_ the content card + * work, even though the _outside_ clicks pass through to the underlying + * ''. + */ + pointer-events: auto; +`; + +const DialogChildrenWrap = styled.div` + overflow-y: auto; + + display: flex; + flex-direction: column; + gap: ${props => props.theme.spacing(6)}; + + padding-bottom: ${props => props.theme.spacing(8)}; + padding-left: ${props => props.theme.spacing(6)}; + padding-right: ${props => props.theme.spacing(6)}; +`; + +const DialogHeader = styled.header` + position: sticky; + top: 0; + + display: flex; + flex-direction: column; + gap: ${props => props.theme.spacing(4)}; + color: ${props => props.theme.color.text.primary}; + + padding-top: ${props => props.theme.spacing(8)}; + padding-bottom: ${props => props.theme.spacing(6)}; + padding-left: ${props => props.theme.spacing(6)}; + padding-right: ${props => props.theme.spacing(6)}; +`; + +/** + * 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. + */ +const DialogClose = styled.div` + position: absolute; + top: ${props => props.theme.spacing(8)}; + right: ${props => props.theme.spacing(6)}; +`; + +export interface DialogContentProps + extends MotionProp { + children?: ReactNode; + /** Renders the element after the dialog title. These elements will be sticky to the top of the dialog */ + headerChildren?: ReactNode; + title: string; + /** + * If you want to render CTA buttons in the dialog footer, use + * `buttonGroupProps`. The dialog will then render a `` using + * these props. + */ + buttonGroupProps?: IconOnlyButtonGroupProps extends boolean + ? ButtonGroupProps + : undefined; + /** @deprecated this prop will be removed in the future */ + zIndex?: number; +} + +export const Content = ({ + children, + headerChildren, + title, + buttonGroupProps, + motion, + zIndex, +}: DialogContentProps) => { + const { showCloseButton } = useContext(DialogContext); + + return ( + + + + + + + + + + + + {title} + + + {headerChildren} + + + + {children} + + {buttonGroupProps && } + + + {showCloseButton && ( + + + + + + + + )} + + + + + + + + + ); +}; diff --git a/packages/ui/src/Dialog/Context.tsx b/packages/ui/src/Dialog/Context.tsx new file mode 100644 index 000000000..2a897d0cf --- /dev/null +++ b/packages/ui/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/src/Dialog/EmptyContent.tsx b/packages/ui/src/Dialog/EmptyContent.tsx new file mode 100644 index 000000000..87ac9ab2c --- /dev/null +++ b/packages/ui/src/Dialog/EmptyContent.tsx @@ -0,0 +1,51 @@ +import { ReactNode } from 'react'; +import { + Overlay as RadixDialogOverlay, + Portal as RadixDialogPortal, + Content as RadixDialogContent, +} from '@radix-ui/react-dialog'; +import { styled } from 'styled-components'; + +const Overlay = styled(RadixDialogOverlay)` + backdrop-filter: blur(${props => props.theme.blur.xs}); + background-color: ${props => props.theme.color.other.overlay}; + position: fixed; + inset: 0; + z-index: auto; +`; + +/** + * We make a full-screen wrapper around the dialog's content so that we can + * correctly position it using the same ``/`` as the + * underlying page uses. Note that we use a `styled.div` here, rather than + * `styled(RadixDialog.Content)`, because Radix adds an inline `pointer-events: + * auto` style to that element. We need to make sure there _aren't_ pointer + * events on the dialog content, because of the aforementioned full-screen + * wrapper that appears over the ``. We want to make sure that clicks + * on the full-screen wrapper pass through to the underlying ``, so + * that the dialog closes when the user clicks there. + */ +const DialogContent = styled.div<{ $zIndex?: number }>` + position: fixed; + inset: 0; + pointer-events: none; + ${props => props.$zIndex && `z-index: ${props.$zIndex};`} +`; + +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/src/Dialog/RadioItem.tsx b/packages/ui/src/Dialog/RadioItem.tsx new file mode 100644 index 000000000..ace45ce46 --- /dev/null +++ b/packages/ui/src/Dialog/RadioItem.tsx @@ -0,0 +1,154 @@ +import { KeyboardEventHandler, MouseEventHandler, ReactNode, useMemo } from 'react'; +import { RadioGroupItem } from '@radix-ui/react-radio-group'; +import { styled } from 'styled-components'; +import { motion } from 'framer-motion'; +import { Text } from '../Text'; +import { ActionType, getOutlineColorByActionType } from '../utils/ActionType'; +import { asTransientProps } from '../utils/asTransientProps'; + +const Root = styled(motion.button)<{ + $actionType: ActionType; + $disabled?: boolean; +}>` + border-radius: ${props => props.theme.borderRadius.sm}; + background-color: ${props => props.theme.color.other.tonalFill5}; + padding: ${props => props.theme.spacing(3)}; + + display: flex; + justify-content: space-between; + align-items: center; + text-align: left; + transition: + background 0.15s, + outline 0.15s; + + &:hover { + background: linear-gradient( + 0deg, + ${props => props.theme.color.action.hoverOverlay} 0%, + ${props => props.theme.color.action.hoverOverlay} 100% + ), + ${props => props.theme.color.other.tonalFill5}; + } + + &:focus { + background: linear-gradient( + 0deg, + ${props => props.theme.color.action.hoverOverlay} 0%, + ${props => props.theme.color.action.hoverOverlay} 100% + ), + ${props => props.theme.color.other.tonalFill5}; + outline: 2px solid ${props => getOutlineColorByActionType(props.theme, props.$actionType)}; + } + + &[aria-checked='true'] { + outline: 2px solid ${props => getOutlineColorByActionType(props.theme, props.$actionType)}; + } + + &:disabled { + background: linear-gradient( + 0deg, + ${props => props.theme.color.action.disabledOverlay} 0%, + ${props => props.theme.color.action.disabledOverlay} 100% + ), + ${props => props.theme.color.other.tonalFill5}; + } +`; + +const Info = styled.div` + display: flex; + gap: ${props => props.theme.spacing(2)}; + align-items: center; +`; + +const Title = styled.div` + display: flex; + align-items: center; + white-space: nowrap; + gap: ${props => props.theme.spacing(1)}; +`; + +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 onEnter: KeyboardEventHandler = event => { + if (event.key === 'Enter') { + onClose?.(); + } + }; + + const onMouseDown: MouseEventHandler = () => { + // close only after the value is selected by onClick + setTimeout(() => { + onClose?.(); + }, 0); + }; + + // click is triggered by radix-ui on focus, click, arrow selection, etc. – basically always + const onClick = () => { + onSelect?.(); + }; + + const descriptionText = useMemo(() => { + if (!description) { + return null; + } + + if (typeof description === 'string') { + return ( + color.text.secondary} as='div'> + {description} + + ); + } + + return description; + }, [description]); + + return ( + + + + {startAdornment} +
+ {title} + {descriptionText} +
+
+ + {endAdornment} +
+
+ ); +}; diff --git a/packages/ui/src/Dialog/RadioItemGroup.tsx b/packages/ui/src/Dialog/RadioItemGroup.tsx new file mode 100644 index 000000000..4e116b231 --- /dev/null +++ b/packages/ui/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/src/Dialog/Trigger.tsx b/packages/ui/src/Dialog/Trigger.tsx new file mode 100644 index 000000000..fa578c3f4 --- /dev/null +++ b/packages/ui/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/src/Dialog/index.stories.tsx b/packages/ui/src/Dialog/index.stories.tsx index f51cfad8b..b67cc659f 100644 --- a/packages/ui/src/Dialog/index.stories.tsx +++ b/packages/ui/src/Dialog/index.stories.tsx @@ -4,13 +4,22 @@ import { Dialog } from '.'; import { Button } from '../Button'; import { ComponentType } from 'react'; import { Text } from '../Text'; +import { AssetIcon } from '../AssetIcon'; import { styled } from 'styled-components'; import { Ban, Handshake, ThumbsUp } from 'lucide-react'; +import { OSMO_METADATA, PENUMBRA_METADATA, PIZZA_METADATA } from '../utils/bufs'; const WhiteTextWrapper = styled.div` color: ${props => props.theme.color.text.primary}; `; +const Column = styled.div` + display: flex; + flex-direction: column; + gap: ${props => props.theme.spacing(1)}; + padding-top: ${props => props.theme.spacing(1)}; +`; + const meta: Meta = { component: Dialog, tags: ['autodocs', '!dev'], @@ -70,3 +79,40 @@ export const Basic: Story = { ); }, }; + +export const WithRadioItems: Story = { + render: function Render() { + return ( + + + + + + + + + } + /> + } + /> + } + /> + + + + + ); + }, +}; diff --git a/packages/ui/src/Dialog/index.tsx b/packages/ui/src/Dialog/index.tsx index 7b0960aec..7b4fc0def 100644 --- a/packages/ui/src/Dialog/index.tsx +++ b/packages/ui/src/Dialog/index.tsx @@ -1,110 +1,11 @@ -import { createContext, ReactNode, useContext } from 'react'; -import { styled } from 'styled-components'; +import { ReactNode } from 'react'; import * as RadixDialog from '@radix-ui/react-dialog'; -import { Text } from '../Text'; -import { X } from 'lucide-react'; -import { ButtonGroup, ButtonGroupProps } from '../ButtonGroup'; -import { Button } from '../Button'; -import { Density } from '../Density'; -import { Display } from '../Display'; -import { Grid } from '../Grid'; -import { MotionProp } from '../utils/MotionProp'; -import { motion } from 'framer-motion'; - -const Overlay = styled(RadixDialog.Overlay)` - backdrop-filter: blur(${props => props.theme.blur.xs}); - background-color: ${props => props.theme.color.other.overlay}; - position: fixed; - inset: 0; - z-index: auto; -`; - -const FullHeightWrapper = styled.div` - height: 100%; - min-height: 100svh; - max-height: 100lvh; - position: relative; - - display: flex; - align-items: center; -`; - -/** - * We make a full-screen wrapper around the dialog's content so that we can - * correctly position it using the same ``/`` as the - * underlying page uses. Note that we use a `styled.div` here, rather than - * `styled(RadixDialog.Content)`, because Radix adds an inline `pointer-events: - * auto` style to that element. We need to make sure there _aren't_ pointer - * events on the dialog content, because of the aforementioned full-screen - * wrapper that appears over the ``. We want to make sure that clicks - * on the full-screen wrapper pass through to the underlying ``, so - * that the dialog closes when the user clicks there. - */ -const DialogContent = styled.div<{ $zIndex?: number }>` - position: fixed; - inset: 0; - pointer-events: none; - ${props => props.$zIndex && `z-index: ${props.$zIndex};`} -`; - -const DialogContentCard = styled(motion.div)` - position: relative; - width: 100%; - max-height: 75%; - box-sizing: border-box; - - background: ${props => props.theme.color.other.dialogBackground}; - border: 1px solid ${props => props.theme.color.other.tonalStroke}; - border-radius: ${props => props.theme.borderRadius.xl}; - backdrop-filter: blur(${props => props.theme.blur.xl}); - - display: flex; - flex-direction: column; - - /** - * We add 'pointer-events: auto' here so that clicks _inside_ the content card - * work, even though the _outside_ clicks pass through to the underlying - * ''. - */ - pointer-events: auto; -`; - -const DialogChildrenWrap = styled.div` - overflow-y: auto; - - display: flex; - flex-direction: column; - gap: ${props => props.theme.spacing(6)}; - - padding-bottom: ${props => props.theme.spacing(8)}; - padding-left: ${props => props.theme.spacing(6)}; - padding-right: ${props => props.theme.spacing(6)}; -`; - -const DialogHeader = styled.header` - position: sticky; - top: 0; - - display: flex; - flex-direction: column; - gap: ${props => props.theme.spacing(4)}; - color: ${props => props.theme.color.text.primary}; - - padding-top: ${props => props.theme.spacing(8)}; - padding-bottom: ${props => props.theme.spacing(6)}; - padding-left: ${props => props.theme.spacing(6)}; - padding-right: ${props => props.theme.spacing(6)}; -`; - -/** - * 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. - */ -const DialogClose = styled.div` - position: absolute; - top: ${props => props.theme.spacing(8)}; - right: ${props => props.theme.spacing(6)}; -`; +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 { /** @@ -235,119 +136,16 @@ export const Dialog = ({ children, onClose, isOpen }: DialogProps) => { ); }; -export interface DialogEmptyContentProps { - children?: ReactNode; - /** @deprecated this prop will be removed in the future */ - zIndex?: number; -} - -const EmptyContent = ({ children, zIndex }: DialogEmptyContentProps) => { - return ( - - - - - {children} - - - ); -}; Dialog.EmptyContent = EmptyContent; - -/** Internal use only. */ -const DialogContext = createContext<{ showCloseButton: boolean }>({ - showCloseButton: true, -}); - -export interface DialogContentProps - extends MotionProp { - children?: ReactNode; - /** Renders the element after the dialog title. These elements will be sticky to the top of the dialog */ - headerChildren?: ReactNode; - title: string; - /** - * If you want to render CTA buttons in the dialog footer, use - * `buttonGroupProps`. The dialog will then render a `` using - * these props. - */ - buttonGroupProps?: IconOnlyButtonGroupProps extends boolean - ? ButtonGroupProps - : undefined; - /** @deprecated this prop will be removed in the future */ - zIndex?: number; -} - -const Content = ({ - children, - headerChildren, - title, - buttonGroupProps, - motion, - zIndex, -}: DialogContentProps) => { - const { showCloseButton } = useContext(DialogContext); - - return ( - - - - - - - - - - - - {title} - - - {headerChildren} - - - - {children} - - {buttonGroupProps && } - - - {showCloseButton && ( - - - - - - - - )} - - - - - - - - - ); -}; Dialog.Content = Content; - -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; -} - -const Trigger = ({ children, asChild }: DialogTriggerProps) => ( - {children} -); Dialog.Trigger = Trigger; +Dialog.RadioGroup = RadioGroup; +Dialog.RadioItem = RadioItem; + +export type { + DialogTriggerProps, + DialogEmptyContentProps, + DialogContentProps, + DialogRadioGroupProps, + DialogRadioItemProps, +};