chore(ui): improve mobile responsiveness (#1292)
Some checks failed
Build / build (v26.3.0) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
Deploy Storybook / deploy (push) Has been cancelled

This commit is contained in:
Parth 2026-07-05 19:24:37 +05:30 committed by GitHub
parent c7cb02ae86
commit d66f5e9c82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 549 additions and 271 deletions

View file

@ -11,7 +11,7 @@ export function LogsOverlay({ open, onOpenChange }: LogsOverlayProps) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={() => onOpenChange(false)}>
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-screen max-w-screen! flex-col rounded-none border-none">
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-dvh max-h-dvh! max-w-screen! flex-col overflow-hidden rounded-none border-none">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<PageTitle title={t('logs.title')} />

View file

@ -67,7 +67,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
{t('global.loading')}
</div>
) : (
<div className="flex items-center text-4xl tracking-wider">
<div className="flex items-center text-3xl tracking-wider sm:text-4xl">
<Balance
onClick={() => toggleDisplayMode()}
valueString={String(walletBalanceSummary.calculatedTotalBalanceInSats)}
@ -76,7 +76,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
</div>
)}
</div>
<div className="mt-2 flex w-full justify-center gap-4" data-tour-id="wallet-actions">
<div className="mt-2 flex w-full justify-center gap-2 sm:gap-4" data-tour-id="wallet-actions">
<Button size="xxl" className="flex-1" variant="default" onClick={() => void navigate(routes.receive)}>
<DownloadIcon />
{t('current_wallet.button_deposit')}

View file

@ -118,15 +118,22 @@ export const CreateStepConfirm = ({
<div className="space-y-4">
<div className="space-y-2">
<Field data-invalid={errors.revealSensitiveInfo !== undefined} orientation="horizontal">
<Field
data-invalid={errors.revealSensitiveInfo !== undefined}
orientation="horizontal"
className="items-start"
>
<Switch
className="mt-0.5 shrink-0"
id="switch-reveal-seed"
checked={revealSensitiveInfo}
onCheckedChange={(checked) =>
setValue('revealSensitiveInfo', checked, { shouldValidate: true, shouldTouch: true })
}
/>
<FieldLabel htmlFor="switch-reveal-seed">{t('create_wallet.confirmation_toggle_reveal_info')}</FieldLabel>
<FieldLabel htmlFor="switch-reveal-seed" className="min-w-0 flex-1">
{t('create_wallet.confirmation_toggle_reveal_info')}
</FieldLabel>
</Field>
{errors.revealSensitiveInfo?.message && (
<div className="text-destructive text-xs">{errors.revealSensitiveInfo.message}</div>
@ -134,15 +141,16 @@ export const CreateStepConfirm = ({
</div>
<div className="space-y-2">
<Field data-invalid={errors.backupConfirmed !== undefined} orientation="horizontal">
<Field data-invalid={errors.backupConfirmed !== undefined} orientation="horizontal" className="items-start">
<Switch
className="mt-0.5 shrink-0"
id="switch-confirm-backup"
required={true}
disabled={!touchedFields.revealSensitiveInfo}
checked={backupConfirmed}
onCheckedChange={(checked) => setValue('backupConfirmed', checked, { shouldValidate: true })}
/>
<FieldLabel htmlFor="switch-confirm-backup">
<FieldLabel htmlFor="switch-confirm-backup" className="min-w-0 flex-1">
{t('create_wallet.confirmation_toggle_info_written_down')}
</FieldLabel>
</Field>

View file

@ -103,7 +103,7 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
'border-brand-success/50 bg-brand-success/10': allSelected && isCorrect,
})}
>
<div className="grid grid-cols-3 gap-1.5 select-none">
<div className="grid grid-cols-2 gap-1.5 select-none sm:grid-cols-3">
{!isCorrect &&
mnemonicPhrase.map((_, index) => {
const word = selectedWords[index]
@ -112,10 +112,13 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
return (
<div
key={index}
className={cn('flex items-center gap-0.5 rounded-md px-0.5 py-1.5 font-mono text-xs transition-all', {
'bg-primary/10 text-primary border-primary/20 border': isFilled,
'border-muted bg-muted/30 border border-dashed': !isFilled,
})}
className={cn(
'flex min-w-0 items-center gap-0.5 rounded-md px-0.5 py-1.5 font-mono text-xs transition-all',
{
'bg-primary/10 text-primary border-primary/20 border': isFilled,
'border-muted bg-muted/30 border border-dashed': !isFilled,
},
)}
>
<span
className={cn('min-w-8 text-right tabular-nums', {
@ -126,7 +129,7 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
{index + 1}.
</span>
{isFilled ? (
<span>{isHidden ? <MaskedText masked /> : word}</span>
<span className="min-w-0">{isHidden ? <MaskedText masked /> : word}</span>
) : (
<span className="text-muted-foreground/30">···</span>
)}
@ -143,7 +146,7 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
</div>
{!allSelected && (
<div className="grid grid-cols-3 gap-2">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{shuffledWords.map((word, index) => {
const isPicked = pickedIndicesSet.has(index)
const isWrong = wrongButtonIndex === index
@ -154,7 +157,7 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
size="lg"
variant={isWrong ? 'destructive' : isPicked ? 'outline' : 'secondary'}
disabled={isPicked || wrongButtonIndex !== undefined}
className={cn('font-mono text-sm transition-all', {
className={cn('min-w-0 px-2 font-mono text-sm transition-all', {
'pointer-events-none opacity-25': isPicked,
'animate-shake': isWrong,
})}
@ -167,24 +170,21 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
</div>
)}
<div className="flex gap-2">
<div className="flex flex-col gap-2">
<Button
type="button"
variant="outline"
className={cn('flex-1', {
hidden: isCorrect,
})}
className="w-full"
size="xxl"
onClick={onBack}
disabled={isCorrect || verifyMutation.isPending}
disabled={!isCorrect || verifyMutation.isPending}
onClick={() => verifyMutation.mutate({ mustBeCorrect: true })}
>
<ChevronLeftIcon />
{t('create_wallet.back_button')}
{verifyMutation.isPending && <Spinner className="motion-reduce:hidden" />}
{t('create_wallet.confirmation_button_fund_wallet')}
</Button>
{skipWalletBackupVerification && !isCorrect && (
<Button
type="button"
className="flex-1"
className="w-full"
variant="secondary"
size="xxl"
disabled={verifyMutation.isPending}
@ -195,13 +195,15 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
)}
<Button
type="button"
className="flex-1"
size="xxl"
disabled={!isCorrect || verifyMutation.isPending}
onClick={() => verifyMutation.mutate({ mustBeCorrect: true })}
variant="ghost"
className={cn({
hidden: isCorrect,
})}
onClick={onBack}
disabled={isCorrect || verifyMutation.isPending}
>
{verifyMutation.isPending && <Spinner className="motion-reduce:hidden" />}
{t('create_wallet.confirmation_button_fund_wallet')}
<ChevronLeftIcon />
{t('create_wallet.back_button')}
</Button>
</div>
</div>

View file

@ -41,7 +41,7 @@ const CreateWalletCard = ({ icon: Icon, title, children }: PropsWithChildren<{ i
<div className="bg-primary/10 mb-4 flex h-12 w-12 items-center justify-center rounded-full">
<Icon className="text-primary" />
</div>
<CardTitle className="text-xl font-bold">{title}</CardTitle>
<CardTitle className="text-center text-xl font-bold break-words">{title}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">{children}</CardContent>
</Card>

View file

@ -43,8 +43,8 @@ export default function DevSetupPage() {
<h5 className="text-xl font-bold">Jam Instances</h5>
<div className="flex flex-col gap-2">
<div>
<div className="flex items-center gap-2">
<WalletIcon className="h-4 w-4" />
<div className="flex flex-wrap items-center gap-2">
<WalletIcon className="h-4 w-4 shrink-0" />
<span className="font-mono">jm_regtest_joinmarket</span>
<Badge>primary · ng native</Badge>
</div>
@ -57,9 +57,14 @@ export default function DevSetupPage() {
</div>
<div>
<div className="flex items-center gap-2">
<WalletIcon className="h-4 w-4" />
<a href={LINK_JM_REGTEST_JOINMARKET2} target="_blank" rel="noopener noreferrer" className="underline">
<div className="flex flex-wrap items-center gap-2">
<WalletIcon className="h-4 w-4 shrink-0" />
<a
href={LINK_JM_REGTEST_JOINMARKET2}
target="_blank"
rel="noopener noreferrer"
className="min-w-0 break-all underline"
>
jm_regtest_joinmarket2 ({LINK_JM_REGTEST_JOINMARKET2})
</a>
<Badge>secondary · ng standalone</Badge>
@ -72,8 +77,8 @@ export default function DevSetupPage() {
</div>
<div>
<div className="flex items-center gap-2">
<WalletIcon className="h-4 w-4" />
<div className="flex flex-wrap items-center gap-2">
<WalletIcon className="h-4 w-4 shrink-0" />
<span className="font-mono">jm_regtest_joinmarket3</span>
<Badge>tertiary · clientserver native</Badge>
</div>
@ -85,9 +90,14 @@ export default function DevSetupPage() {
</div>
<div>
<div className="flex items-center gap-2">
<WalletIcon className="h-4 w-4" />
<a href={LINK_JM_REGTEST_JOINMARKET4} target="_blank" rel="noopener noreferrer" className="underline">
<div className="flex flex-wrap items-center gap-2">
<WalletIcon className="h-4 w-4 shrink-0" />
<a
href={LINK_JM_REGTEST_JOINMARKET4}
target="_blank"
rel="noopener noreferrer"
className="min-w-0 break-all underline"
>
jm_regtest_joinmarket4 ({LINK_JM_REGTEST_JOINMARKET4})
</a>
<Badge>quaternary · clientserver standalone</Badge>
@ -115,10 +125,15 @@ export default function DevSetupPage() {
<h5 className="text-xl font-bold">Block Explorer</h5>
<div>
{' '}
<div className="flex items-center gap-2">
<BlocksIcon className="h-4 w-4" />
<div className="flex flex-wrap items-center gap-2">
<BlocksIcon className="h-4 w-4 shrink-0" />
<a href={LINK_JM_REGTEST_EXPLORER} target="_blank" rel="noopener noreferrer" className="underline">
<a
href={LINK_JM_REGTEST_EXPLORER}
target="_blank"
rel="noopener noreferrer"
className="min-w-0 break-all underline"
>
jm_regtest_explorer ({LINK_JM_REGTEST_EXPLORER})
</a>
</div>
@ -133,16 +148,23 @@ export default function DevSetupPage() {
</div>
</div>
<div>
<div className="flex items-center gap-2">
<TerminalIcon className="h-4 w-4" />
<div className="flex flex-wrap items-center gap-2">
<TerminalIcon className="h-4 w-4 shrink-0" />
<a href={LINK_JM_REGTEST_RPC_TERMINAL} target="_blank" rel="noopener noreferrer" className="underline">
<a
href={LINK_JM_REGTEST_RPC_TERMINAL}
target="_blank"
rel="noopener noreferrer"
className="min-w-0 break-all underline"
>
Regtest RPC Terminal ({LINK_JM_REGTEST_RPC_TERMINAL})
</a>
</div>
<div className="my-2 ms-5">
Mine a block, e.g.:
<pre>generatetoaddress 1 bcrt1qrnz0thqslhxu86th069r9j6y7ldkgs2tzgf5wx</pre>
<pre className="max-w-full overflow-x-auto">
generatetoaddress 1 bcrt1qrnz0thqslhxu86th069r9j6y7ldkgs2tzgf5wx
</pre>
</div>
</div>
</div>

View file

@ -99,7 +99,7 @@ export function CreateFidelityBondDialog({
return (
<Dialog open={open} onOpenChange={handleOpenChange} {...dialogProps}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogContent className="max-h-[90dvh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl">{t('earn.fidelity_bond.create_fidelity_bond.title')}</DialogTitle>
<DialogDescription>{t('earn.fidelity_bond.subtitle')}</DialogDescription>

View file

@ -88,7 +88,7 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="lockdate-month" className="text-sm font-medium">
{t('earn.fidelity_bond.select_date.form_label_month')}
@ -186,8 +186,8 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
)}
onClick={() => setSelectedJarIndex(jar.jarIndex)}
>
<CardContent className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<CardContent className="flex flex-col items-start justify-between gap-3 p-4 sm:flex-row sm:items-center">
<div className="flex min-w-0 items-center gap-4">
<div
className="h-10 w-10 rounded-full shadow-sm"
style={{ backgroundColor: jar.color, opacity: 0.8 }}
@ -201,7 +201,7 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
</p>
</div>
</div>
<div className="text-right">
<div className="shrink-0 text-left sm:text-right">
<p className="font-mono font-semibold">
{formatSats(jar.balanceSummary.calculatedAvailableBalanceInSats)}
</p>
@ -324,9 +324,11 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
{selectedUtxos.length > 0 && (
<div className="border-t pt-4">
<div className="bg-muted/50 flex items-center justify-between rounded-lg p-3">
<span className="font-medium">{t('earn.fidelity_bond.select_utxos.label_total_selected')}</span>
<span className="font-mono text-lg font-bold">{formatSats(totalAmount)}</span>
<div className="bg-muted/50 flex flex-wrap items-center justify-between gap-2 rounded-lg p-3">
<span className="min-w-0 font-medium break-words">
{t('earn.fidelity_bond.select_utxos.label_total_selected')}
</span>
<span className="shrink-0 font-mono text-lg font-bold">{formatSats(totalAmount)}</span>
</div>
</div>
)}
@ -403,7 +405,7 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-muted-foreground mb-1 text-xs">
{t('earn.fidelity_bond.review_inputs.label_lock_date')}

View file

@ -90,8 +90,8 @@ const OfferTypeInput = (props: React.ComponentProps<typeof RadioGroup>) => {
const id = useId()
return (
<RadioGroup className="flex items-center justify-center" {...props}>
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full max-w-50 cursor-pointer flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px]">
<RadioGroup className="flex flex-col items-stretch justify-center gap-2 sm:flex-row sm:items-center" {...props}>
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full cursor-pointer flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px] sm:max-w-50">
<RadioGroupItem
value={OFFERTYPE_ABS}
id={`${id}-sw0absoffer`}
@ -104,7 +104,7 @@ const OfferTypeInput = (props: React.ComponentProps<typeof RadioGroup>) => {
</Label>
</div>
</div>
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full max-w-50 flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px]">
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px] sm:max-w-50">
<RadioGroupItem
value={OFFERTYPE_REL}
id={`${id}-sw0reloffer`}

View file

@ -358,7 +358,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
return (
<FidelityBondCard value={it} key={it.utxo}>
{actionsEnabled && (
<div className="flex w-full flex-row gap-2">
<div className="flex w-full flex-col gap-2 sm:flex-row">
<Button variant="secondary" className="flex-1" onClick={() => setMoveToJarUtxo(it)}>
<UnlockIcon />
{t('earn.fidelity_bond.existing.button_spend')}

View file

@ -42,7 +42,7 @@ export function FidelityBondCard({ value, children }: PropsWithChildren<Fidelity
t('earn.fidelity_bond.existing.title_active')
)}
</CardTitle>
<CardDescription className="text-xs">
<CardDescription className="min-w-0 text-xs break-all">
<Tooltip>
<TooltipTrigger asChild>
<code>{value.path}</code>
@ -54,29 +54,29 @@ export function FidelityBondCard({ value, children }: PropsWithChildren<Fidelity
</Tooltip>
</CardDescription>
<CardAction>
<div className="flex items-center gap-2">
<CoinsIcon />
<div>
<div className="flex min-w-0 items-center gap-2">
<CoinsIcon className="shrink-0" />
<div className="min-w-0">
<Balance valueString={String(value.value)} />
</div>
</div>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex items-center gap-4">
<ClockIcon />
<div className="flex flex-col">
<div className="flex items-start gap-4">
<ClockIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-muted-foreground text-sm font-semibold">
{t(`earn.fidelity_bond.existing.${isExpired ? 'label_expired_on' : 'label_locked_until'}`)}
</span>
<span className="text-md font-mono">
<span className="text-md block font-mono break-words">
{value.locktime} ({humanReadableLockDuration})
</span>
</div>
</div>
<div className="flex items-center gap-4">
<CopyIcon />
<div className="flex flex-col">
<div className="flex items-start gap-4">
<CopyIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-muted-foreground text-sm font-semibold">
{t('earn.fidelity_bond.existing.label_address')}
</span>

View file

@ -204,7 +204,7 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto">
<DialogContent className="max-h-[90dvh] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl">{t('earn.fidelity_bond.move.title')}</DialogTitle>
<DialogDescription>{t('earn.fidelity_bond.subtitle')}</DialogDescription>
@ -248,8 +248,8 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
)}
onClick={() => setSelectedJarIndex(jar.jarIndex)}
>
<CardContent className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<CardContent className="flex flex-col items-start justify-between gap-3 p-4 sm:flex-row sm:items-center">
<div className="flex min-w-0 items-center gap-4">
<div
className="h-10 w-10 rounded-full shadow-sm"
style={{ backgroundColor: jar.color, opacity: 0.8 }}
@ -274,7 +274,7 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
{step === 'confirm' && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-muted-foreground mb-1 text-xs">
{t('earn.fidelity_bond.review_inputs.label_jar')}

View file

@ -15,7 +15,11 @@ type Offer = NonNullable<SessionResponse['offer_list']>[number]
const OfferTypeBadge = ({ value }: { value: Offer }) => {
const { t } = useTranslation()
const text = renderOfferText(value, t)
return <Badge variant={text ? 'default' : 'outline'}>{text}</Badge>
return (
<Badge className="max-w-full truncate" variant={text ? 'default' : 'outline'}>
{text}
</Badge>
)
}
const renderOfferText = (value: Offer, t: TFunction<'translation', undefined>) => {
@ -52,19 +56,19 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi
</CardAction>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="flex items-center gap-4">
<FingerprintIcon />
<div className="flex flex-col">
<div className="flex min-w-0 items-start gap-4">
<FingerprintIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<Label className="font-semibold">{t('earn.current.text_offer_id')}</Label>
<span className="text-md font-mono select-all">
<span className="text-md block font-mono break-all select-all">
{nickname}:{value?.oid}
</span>
</div>
</div>
<div className="flex items-center gap-4">
<HandCoinsIcon />
<div className="flex flex-col">
<div className="flex min-w-0 items-start gap-4">
<HandCoinsIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<Label className="font-semibold">{t('earn.current.text_cjfee')}</Label>
<span className="text-sm">
{isRelativeOffer(value?.ordertype || '') ? (
@ -75,18 +79,18 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi
</span>
</div>
</div>
<div className="flex items-center gap-4">
<Minimize2Icon />
<div className="flex flex-col">
<div className="flex min-w-0 items-start gap-4">
<Minimize2Icon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<Label className="font-semibold">{t('earn.current.text_minsize')}</Label>
<span className="text-sm">
<Balance valueString={String(value?.minsize || '0')} />
</span>
</div>
</div>
<div className="flex items-center gap-4">
<Maximize2Icon />
<div className="flex flex-col">
<div className="flex min-w-0 items-start gap-4">
<Maximize2Icon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<Label className="font-semibold">{t('earn.current.text_maxsize')}</Label>
<span className="text-sm">
<Balance valueString={String(value?.maxsize || '0')} />

View file

@ -224,7 +224,7 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto">
<DialogContent className="max-h-[90dvh] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl">{t('earn.fidelity_bond.renew.title')}</DialogTitle>
<DialogDescription>{t('earn.fidelity_bond.subtitle')}</DialogDescription>
@ -256,7 +256,7 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
<p className="font-mono text-lg font-bold">{formatSats(utxo.value)}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="renew-lockdate-month" className="text-sm font-medium">
{t('earn.fidelity_bond.select_date.form_label_month')}
@ -321,7 +321,7 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
{step === 'confirm' && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-muted-foreground text-xs">
{t('earn.fidelity_bond.review_inputs.label_lock_date')}

View file

@ -180,7 +180,7 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
const earned24Hours = useMemo(() => sumEarned(allEntries, new Date(now - MILLISECONDS_IN_A_DAY)), [allEntries, now])
return (
<div className={cn('mx-auto flex-1 space-y-3', className)}>
<div className={cn('mx-auto min-w-0 flex-1 space-y-3', className)}>
{isLoading ? (
<div className="flex flex-1 items-center justify-center">
<Spinner />
@ -188,7 +188,7 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
) : (
<div className="flex min-h-0 flex-1 flex-col gap-4">
{/* Stats row */}
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-4">
{(
[
{ label: t('earn.report.stats.earned_total'), value: earnedTotal },
@ -212,8 +212,8 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
<EarnReportChart entries={allEntries} />
{/* Toolbar: search + refresh */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[12rem] flex-1">
<SearchIcon className="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
placeholder={t('earn.report.placeholder_search')}

View file

@ -15,7 +15,7 @@ export const EarnReportOverlay = ({ open, onOpenChange, ...dialogProps }: EarnRe
return (
<Dialog open={open} onOpenChange={onOpenChange} {...dialogProps}>
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-screen max-w-screen! flex-col rounded-none border-none">
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-dvh max-h-dvh! max-w-screen! flex-col overflow-hidden rounded-none border-none">
<DialogHeader>
<DialogTitle>
<PageTitle title={t('earn.report.title')} />

View file

@ -32,15 +32,15 @@ function ErrorView({ title, subtitle, reason, stacktrace }: ErrorViewProps) {
<div className="my-4">
<h6 className="font-semibold">{t('error_page.heading_reason')}</h6>
<Alert variant="destructive" className="overflow-scroll">
<AlertDescription className="flex items-center justify-between">{reason}</AlertDescription>
<Alert variant="destructive" className="overflow-x-auto">
<AlertDescription className="min-w-0 break-words">{reason}</AlertDescription>
</Alert>
</div>
{stacktrace && (
<div className="my-4">
<h6 className="font-semibold">{t('error_page.heading_stacktrace')}</h6>
<pre className="overflow-scroll rounded-lg border p-2">
<pre className="max-w-full overflow-x-auto rounded-lg border p-2">
<code>{stacktrace}</code>
</pre>
</div>

View file

@ -150,15 +150,22 @@ export const ImportStepConfirm = ({
</div>
<div className="space-y-2">
<Field data-invalid={errors.revealSensitiveInfo !== undefined} orientation="horizontal">
<Field
data-invalid={errors.revealSensitiveInfo !== undefined}
orientation="horizontal"
className="items-start"
>
<Switch
className="mt-0.5 shrink-0"
id="switch-reveal-seed"
checked={revealSensitiveInfo}
onCheckedChange={(checked) =>
setValue('revealSensitiveInfo', checked, { shouldValidate: true, shouldTouch: true })
}
/>
<FieldLabel htmlFor="switch-reveal-seed">{t('create_wallet.confirmation_toggle_reveal_info')}</FieldLabel>
<FieldLabel htmlFor="switch-reveal-seed" className="min-w-0 flex-1">
{t('create_wallet.confirmation_toggle_reveal_info')}
</FieldLabel>
</Field>
{errors.revealSensitiveInfo?.message && (
<div className="text-destructive text-xs">{errors.revealSensitiveInfo.message}</div>

View file

@ -50,8 +50,8 @@ const ImportWalletCard = ({
<div className="bg-primary/10 mb-4 flex h-12 w-12 items-center justify-center rounded-full">
<Icon className="text-primary" />
</div>
<CardTitle className="text-xl font-bold">{title}</CardTitle>
{description !== undefined && <CardDescription>{description}</CardDescription>}
<CardTitle className="text-center text-xl font-bold break-words">{title}</CardTitle>
{description !== undefined && <CardDescription className="text-center">{description}</CardDescription>}
</CardHeader>
<CardContent className="space-y-6">{children}</CardContent>

View file

@ -40,21 +40,39 @@ export function AppFooter({
<footer>
<div className="flex flex-col items-center justify-between gap-8 px-4 py-12 sm:flex-row sm:gap-2 sm:p-4">
<div
className="flex flex-col justify-start gap-2 sm:flex-row sm:items-center sm:justify-center"
className="flex w-full flex-col justify-start gap-2 sm:w-auto sm:flex-row sm:items-center sm:justify-center"
data-tour-id="footer-tools"
>
<Button variant="outline" size="sm" onClick={onClickCheatsheet} title={t('footer.cheatsheet')}>
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={onClickCheatsheet}
title={t('footer.cheatsheet')}
>
<FileQuestionMarkIcon />
<span className="">{t('footer.cheatsheet')}</span>
<span>{t('footer.cheatsheet')}</span>
</Button>
<Button variant="outline" size="sm" onClick={onClickOrderbook} title={t('footer.orderbook')}>
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={onClickOrderbook}
title={t('footer.orderbook')}
>
<BookOpenIcon />
<span className="">{t('footer.orderbook')}</span>
<span>{t('footer.orderbook')}</span>
</Button>
{onClickLogs && (
<Button variant="outline" size="sm" onClick={onClickLogs} title={t('footer.logs')}>
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={onClickLogs}
title={t('footer.logs')}
>
<ScrollTextIcon />
<span className="">{t('footer.logs')}</span>
<span>{t('footer.logs')}</span>
</Button>
)}
</div>
@ -84,7 +102,7 @@ export function AppFooter({
</a>
</div>
</div>
<div className="order-last flex-1 text-xs sm:order-first">
<div className="order-last min-w-0 flex-1 text-center text-xs break-words sm:order-first sm:text-left">
<Trans i18nKey="footer.warning">
This is pre-alpha software.
<Button

View file

@ -38,8 +38,8 @@ const WalletPreview = ({
const walletNameTitle = shortenStringMiddle(walletName ?? '...', 12)
return (
<div className="flex flex-1 items-center">
<Link to={routes.home} className="flex items-center gap-2" data-tour-id="wallet-preview">
<div className="flex min-w-0 flex-1 items-center">
<Link to={routes.home} className="flex min-w-0 items-center gap-2" data-tour-id="wallet-preview">
<div className="flex h-8 w-8 items-center">
{isLoading || isReloading ? (
<Spinner className="text-muted-foreground size-6 motion-reduce:hidden" strokeWidth={3} />
@ -47,7 +47,7 @@ const WalletPreview = ({
<WalletIcon strokeWidth={1} />
)}
</div>
<div className="flex flex-col gap-0.25 leading-none">
<div className="flex min-w-0 flex-col gap-0.25 leading-none">
<div className="flex items-center gap-2">
<div
className="font-semibold tracking-tight"
@ -57,7 +57,7 @@ const WalletPreview = ({
</div>
{isDevMode() && <DevBadge />}
</div>
<div className="flex min-h-5 min-w-[150px] items-center">
<div className="flex min-h-5 min-w-0 items-center sm:min-w-[150px]">
{rescanInfo?.rescanning === true ? (
<div className="cursor-wait motion-safe:animate-pulse">
{rescanInfo.progress !== undefined

View file

@ -2,7 +2,7 @@ import type { PropsWithChildren } from 'react'
export const AuthPageShell = ({ children }: PropsWithChildren) => {
return (
<div className="from-background to-muted flex min-h-screen items-center justify-center bg-gradient-to-br p-4">
<div className="from-background to-muted flex min-h-dvh min-h-screen items-start justify-center bg-gradient-to-br p-4 sm:items-center">
{children}
</div>
)

View file

@ -72,7 +72,7 @@ export function LayoutInner({ onLogout, onLockWallet, children }: LayoutInnerPro
}, [])
return (
<div className="bg-brand-shell text-brand-shell-foreground flex min-h-screen flex-1 flex-col transition-colors duration-300">
<div className="bg-brand-shell text-brand-shell-foreground flex min-h-screen min-w-0 flex-1 flex-col transition-colors duration-300">
<AppNavbar
theme={resolvedTheme}
isLoading={isLoading}
@ -87,7 +87,7 @@ export function LayoutInner({ onLogout, onLockWallet, children }: LayoutInnerPro
sessionInfo={jmSession}
sidebarInfo={sidebarContext}
/>
<main className="flex-1">{children}</main>
<main className="min-w-0 flex-1">{children}</main>
<AppFooter
websocketInfo={websocket}
blockHeight={jmSession?.block_height}

View file

@ -181,7 +181,10 @@ export const PostLoginOnboardingTour = ({ enabled = true }: PostLoginOnboardingT
/>
)}
<Card className={cn('absolute max-w-[calc(100vw-2rem)] shadow-2xl')} style={tooltipStyles}>
<Card
className={cn('absolute max-h-[calc(100dvh-2rem)] max-w-[calc(100vw-2rem)] overflow-y-auto shadow-2xl')}
style={tooltipStyles}
>
<CardHeader className="space-y-1">
<CardTitle className="text-lg">{currentStep.title}</CardTitle>
<CardDescription>
@ -191,11 +194,11 @@ export const PostLoginOnboardingTour = ({ enabled = true }: PostLoginOnboardingT
<CardContent>
<p className="text-sm leading-relaxed">{currentStep.description}</p>
</CardContent>
<CardFooter className="flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={closeTour}>
<CardFooter className="flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-between">
<Button variant="ghost" size="sm" className="w-full sm:w-auto" onClick={closeTour}>
Skip tour
</Button>
<div className="flex items-center gap-2">
<div className="grid grid-cols-2 gap-2 sm:flex sm:items-center">
<Button
variant="outline"
size="sm"

View file

@ -146,8 +146,8 @@ export function LogViewer({ fileName, value, refresh }: LogViewerProps) {
<CardHeader className="flex flex-col justify-center gap-2 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="font-mono break-all select-all">{fileName}</CardTitle>
{/* Search + action buttons */}
<div className="flex items-center justify-end gap-2">
<div className="relative max-w-[360px] min-w-[220px] flex-1">
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto sm:flex-nowrap">
<div className="relative min-w-0 flex-1 sm:max-w-[360px] sm:min-w-[220px]">
<SearchIcon className="text-muted-foreground absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2" />
<Input
value={searchValue}
@ -177,7 +177,7 @@ export function LogViewer({ fileName, value, refresh }: LogViewerProps) {
title={t('global.download')}
>
<DownloadIcon className="group/download" />
{t('global.download')}
<span className="hidden sm:inline">{t('global.download')}</span>
</Button>
<Button
variant="outline"
@ -186,7 +186,7 @@ export function LogViewer({ fileName, value, refresh }: LogViewerProps) {
title={t('global.refresh')}
>
<RefreshCwIcon className={cn({ 'animate-spin': isLoadingRefresh })} />
{t('global.refresh')}
<span className="hidden sm:inline">{t('global.refresh')}</span>
</Button>
</div>
</CardHeader>

View file

@ -88,13 +88,13 @@ export const LoginCard = ({
</Button>
)}
</div>
<CardTitle className="text-2xl font-bold">{t('login.title')}</CardTitle>
<CardTitle className="text-center text-2xl font-bold break-words">{t('login.title')}</CardTitle>
{listWalletsLoading ? (
<>
<Skeleton className="h-4 w-full" />
</>
) : wallets && wallets.length > 0 ? (
<CardDescription>{t('login.subtitle')}</CardDescription>
<CardDescription className="text-center">{t('login.subtitle')}</CardDescription>
) : undefined}
{enableOnboardingDialog ? (
<Button variant="link" size="sm" className="h-auto px-0" onClick={() => setShowOnboarding(true)}>

View file

@ -116,10 +116,10 @@ export const LoginFormComponent = ({
</SelectTrigger>
<SelectContent>
{wallets?.map((wallet) => (
<SelectItem key={wallet} value={wallet} className="text-base">
{shortenStringMiddle(walletDisplayName(wallet), 32)}
<SelectItem key={wallet} value={wallet} className="min-w-0 text-base">
<span className="min-w-0 flex-1 truncate">{shortenStringMiddle(walletDisplayName(wallet), 32)}</span>
{activeWallet === wallet ? (
<span className="text-muted-foreground/50 inline-flex items-center gap-1.5 py-1 text-xs">
<span className="text-muted-foreground/50 inline-flex shrink-0 items-center gap-1.5 py-1 text-xs">
<ActivityIndicator active={makerRunning || coinjoinInProgress} />
{t('wallets.wallet_preview.wallet_active')}
</span>

View file

@ -113,7 +113,9 @@ export const OnboardingDialog = ({ open, onOpenChange }: OnboardingDialogProps)
</div>
<div className="space-y-2 rounded-lg border p-4">
<Badge variant="destructive">{t('onboarding.splashscreen_warning_title')}</Badge>
<Badge className="max-w-full whitespace-normal" variant="destructive">
{t('onboarding.splashscreen_warning_title')}
</Badge>
<p className="text-muted-foreground text-sm leading-relaxed">
<Trans
i18nKey="onboarding.splashscreen_warning_text"

View file

@ -241,7 +241,7 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps)
if (error) {
return (
<div className={cn('mx-auto space-y-3', className)}>
<div className={cn('w-full min-w-0 space-y-3', className)}>
<div className="flex flex-col items-start gap-2">
<Alert variant="destructive">
<AlertCircleIcon />
@ -262,9 +262,9 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps)
}
return (
<div className={cn('mx-auto flex-1 space-y-3', className)}>
<div className="flex flex-col items-start justify-center gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className={cn('flex min-h-0 w-full min-w-0 flex-1 flex-col space-y-3', className)}>
<div className="flex w-full flex-col items-start justify-center gap-2 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<p className="text-muted-foreground text-sm">
{searchInputRaw === ''
? t('orderbook.text_orderbook_summary', summary)
@ -272,36 +272,36 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps)
</p>
</div>
<div className="flex items-center space-x-2">
<div className="flex w-full min-w-0 flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center">
{showDemoButton && (
<Button
className="text-xs"
className="w-full min-w-0 justify-center overflow-hidden text-xs sm:w-auto"
variant="outline"
size="sm"
onClick={__dev_generateDemoReportEntryButton}
disabled={isFetching}
>
<PlusIcon className="size-3" />
Add Demo Entry
<DevBadge />
<span className="truncate">Add Demo Entry</span>
<DevBadge className="shrink-0" />
</Button>
)}
<ButtonGroup>
<ButtonGroup className="w-full min-w-0 sm:w-auto">
<Button
variant="outline"
className="rounded-r-none"
className="min-w-0 flex-1 justify-center rounded-r-none sm:flex-none"
size="sm"
onClick={() => void handleReload()}
disabled={isFetching}
>
<RefreshCwIcon className={cn({ 'motion-safe:animate-spin': isFetching })} />
{t('orderbook.button_reload_title')}
<span className="truncate">{t('orderbook.button_reload_title')}</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={isFetching}>
<Button className="shrink-0" variant="outline" size="sm" disabled={isFetching}>
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
@ -319,7 +319,7 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps)
placeholder={t('orderbook.placeholder_search')}
value={searchInputRaw}
onChange={(event) => setSearchInputRaw(event.target.value)}
className="w-64"
className="w-full min-w-0 sm:w-64"
disabled={isFetching}
/>
</div>
@ -328,14 +328,15 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps)
{/* Controls */}
{nickname && (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-4">
<div className="flex items-start gap-4">
<Switch
className="mt-0.5 shrink-0"
id="highlight-my-offers"
checked={isHighlightMyOffers}
onCheckedChange={(checked) => setHighlightMyOffers(checked)}
disabled={isFetching}
/>
<Label htmlFor="highlight-my-offers" className="flex flex-col items-start gap-0">
<Label htmlFor="highlight-my-offers" className="min-w-0 flex-1 flex-col items-start gap-0">
<div className="font-medium">{t('orderbook.label_highlight_own_orders')}</div>
{myOffers.length === 0 ? (
<div className="text-muted-foreground text-sm">{t('orderbook.text_highlight_own_orders_subtitle')}</div>
@ -344,8 +345,9 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps)
</div>
{myOffers.length > 0 && (
<div className="flex items-center gap-4">
<div className="flex items-start gap-4">
<Switch
className="mt-0.5 shrink-0"
id="pin-my-offers"
checked={isPinMyOffers}
onCheckedChange={(checked) => {
@ -356,7 +358,7 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps)
}}
disabled={isFetching}
/>
<Label htmlFor="pin-my-offers" className="flex flex-col items-start gap-0">
<Label htmlFor="pin-my-offers" className="min-w-0 flex-1 flex-col items-start gap-0">
<div className="font-medium">{t('orderbook.label_pin_to_top_own_orders')}</div>
<div className="text-muted-foreground text-sm">
{t('orderbook.text_pin_to_top_own_orders_subtitle')}
@ -368,12 +370,12 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps)
)}
{marketSummary && (
<Card>
<Card className="overflow-hidden">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">{t('orderbook.market_summary_title')}</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<Tabs defaultValue="summary">
<Tabs defaultValue="summary" className="min-w-0">
<TabsList>
<TabsTrigger value="summary" className="cursor-pointer">
{t('orderbook.tab_summary')}
@ -383,10 +385,10 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps)
</TabsTrigger>
</TabsList>
<TabsContent value="summary">
<TabsContent value="summary" className="min-w-0 overflow-hidden">
<div className="text-muted-foreground grid gap-1 text-sm sm:grid-cols-2">
{marketSummary.medianAbsFee !== null && (
<p className="flex items-center gap-1">
<p className="flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5">
<span>{t('orderbook.market_summary_median_abs_fee_label')}:</span>
<Balance
valueString={String(Math.round(marketSummary.medianAbsFee))}
@ -395,23 +397,23 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps)
</p>
)}
{marketSummary.medianRelFee !== null && (
<p>
<p className="break-words">
{t('orderbook.market_summary_median_rel_fee', {
value: factorToPercentage(marketSummary.medianRelFee).toFixed(4),
})}
</p>
)}
<p className="flex items-center gap-1">
<p className="flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5">
<span>{t('orderbook.market_summary_total_liquidity_label')}:</span>
<Balance valueString={String(marketSummary.totalLiquidity)} enableVisibilityToggle={false} />
</p>
<p className="flex items-center gap-1">
<p className="flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5">
<span>{t('orderbook.market_summary_offer_min_size_label')}:</span>
<Balance valueString={String(marketSummary.minOfferSize)} enableVisibilityToggle={false} />
<span></span>
<Balance valueString={String(marketSummary.maxOfferSize)} enableVisibilityToggle={false} />
</p>
<p>
<p className="break-words">
{t('orderbook.market_summary_bonded_makers', {
bonded: marketSummary.bondedMakers,
unbonded: marketSummary.unbondedMakers,

View file

@ -14,15 +14,15 @@ export function OrderbookOverlay({ open, onOpenChange, ...dialogProps }: Orderbo
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={() => onOpenChange(false)} {...dialogProps}>
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-screen max-w-screen! flex-col rounded-none border-none">
<DialogHeader className="px-2">
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-dvh max-h-dvh! w-screen max-w-none! flex-col gap-0! overflow-hidden rounded-none border-none p-0!">
<DialogHeader className="px-4 py-3 sm:px-6">
<DialogTitle className="flex items-center gap-2">
<PageTitle title={t('orderbook.title')} />
</DialogTitle>
</DialogHeader>
<div className="flex min-h-0 flex-1 overflow-hidden">
<OrderbookContent enabled={open} className="flex h-full flex-col p-2 pt-0" />
<div className="flex min-h-0 w-full flex-1 overflow-hidden px-2 pb-2 sm:px-4 sm:pb-4">
<OrderbookContent enabled={open} className="flex min-h-0 w-full flex-1 flex-col" />
</div>
</DialogContent>
</Dialog>

View file

@ -138,6 +138,8 @@ describe('OrderbookTable', () => {
render(<OrderbookTable tableEntries={[relativeEntry, absoluteEntry]} selectedEntries={[]} pinnedEntries={[]} />)
for (const heading of [
'orderbook.table.heading_counterparty',
'orderbook.table.heading_type',
'orderbook.table.heading_fee',
'orderbook.table.heading_minimum_size',
'orderbook.table.heading_maximum_size',
@ -147,6 +149,7 @@ describe('OrderbookTable', () => {
const header = screen.queryByText(heading)
if (header) {
await user.click(header)
await user.click(header)
}
}
@ -154,6 +157,52 @@ describe('OrderbookTable', () => {
expect(screen.getByText('orderbook.table.heading_counterparty')).toBeInTheDocument()
})
it('keeps the normal scrollable table on mobile-sized layouts', async () => {
const user = userEvent.setup()
const firstEntry = makeEntry({
bondValue: {
amount: 0,
displayValue: '7',
value: 7,
},
counterparty: 'same-maker',
fee: {
displayValue: '100',
value: 100,
},
orderId: '2',
})
const secondEntry = makeEntry({
counterparty: 'same-maker',
fee: {
displayValue: '900',
value: 900,
},
orderId: '1',
})
render(<OrderbookTable tableEntries={[firstEntry, secondEntry]} selectedEntries={[]} pinnedEntries={[]} />)
expect(screen.getByRole('table')).toHaveClass('min-w-[42rem]')
expect(screen.getByText('orderbook.table.heading_minimum_size')).toBeInTheDocument()
expect(screen.getByText('orderbook.table.heading_maximum_size')).toBeInTheDocument()
expect(screen.getByText('orderbook.table.heading_bond_value')).toBeInTheDocument()
expect(screen.queryByText('orderbook.table.heading_miner_fee_contribution')).not.toBeInTheDocument()
await user.click(screen.getByText('orderbook.table.heading_counterparty'))
await user.click(screen.getByText('orderbook.table.heading_fee'))
await user.click(screen.getByText('orderbook.table.heading_fee'))
})
it('handles showing all rows when the filtered table is empty', async () => {
const user = userEvent.setup()
render(<OrderbookTable tableEntries={[]} selectedEntries={[]} pinnedEntries={[]} />)
await user.click(screen.getByText('show-all'))
await waitFor(() => expect(screen.getByText('pagination:1/0:-1:0')).toBeInTheDocument())
})
it('filters rows and handles empty pinned row models', () => {
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined)

View file

@ -214,9 +214,7 @@ export const OrderbookTable = ({
],
[t],
)
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
minerFeeContribution: false,
})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({ minerFeeContribution: false })
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [rowPinning, setRowPinning] = useState<RowPinningState>({
top: [],
@ -290,9 +288,9 @@ export const OrderbookTable = ({
}, [table, onChange])
return (
<div className="flex flex-1 flex-col gap-2 overflow-hidden rounded-lg border shadow-lg">
<div className="flex-1 overflow-auto">
<Table>
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-hidden rounded-lg border shadow-lg">
<div className="min-h-0 flex-1 overflow-auto">
<Table className="min-w-[42rem] text-sm">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>

View file

@ -97,7 +97,7 @@ export const ReceiveForm = ({ className, defaultValues, onSubmit, jars, disabled
<div className="space-y-2">
<Field className="space-y-4" data-invalid={errors.source !== undefined}>
<FieldLabel>{t('receive.label_source_jar')}</FieldLabel>
<div className="grid grid-cols-5 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
{jars.map((jar, index) => (
<SelectableJar
key={index}

View file

@ -119,7 +119,7 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
<Card>
<CardContent className="flex w-full flex-col items-center justify-center gap-2">
{getAddressMutation.isPending ? (
<Skeleton style={{ height: QRCODE_WIDTH, width: QRCODE_WIDTH }} />
<Skeleton className="aspect-square w-full max-w-80" />
) : getAddressMutation.data?.address ? (
<BitcoinAddressQrCode
className="animate-in fade-in duration-1000"
@ -128,10 +128,7 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
width={QRCODE_WIDTH}
/>
) : getAddressMutation.isIdle ? (
<div
className={cn('flex items-center justify-center border')}
style={{ height: QRCODE_WIDTH, width: QRCODE_WIDTH }}
>
<div className={cn('flex aspect-square w-full max-w-80 items-center justify-center border')}>
<Button
variant="outline"
size="lg"
@ -148,8 +145,9 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
</div>
) : (
<div
className={cn('text-destructive flex items-center justify-center border text-sm')}
style={{ height: QRCODE_WIDTH, width: QRCODE_WIDTH }}
className={cn(
'text-destructive flex aspect-square w-full max-w-80 items-center justify-center border text-sm',
)}
>
{t('receive.error_loading_address_failed')}
</div>
@ -157,7 +155,7 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
{getAddressMutation.isPending ? (
<div className="flex flex-col items-center space-y-2">
<Skeleton className="mt-0.5 h-5 w-[24rem]" />
<Skeleton className="mt-0.5 h-5 w-full max-w-96" />
<Skeleton className="h-6 w-[84px]" />
</div>
) : (
@ -181,7 +179,7 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
</div>
)}
<div className="mt-4 flex items-center gap-2">
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
<Button
variant="outline"
size="sm"

View file

@ -63,7 +63,7 @@ export default function JarSelectorDialog({
</DialogHeader>
<div className="py-4">
<div className="grid grid-cols-5 gap-4">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
{jars.map((jar, index) => (
<SelectableJar
key={index}
@ -81,11 +81,16 @@ export default function JarSelectorDialog({
</div>
</div>
<DialogFooter className="sm:justify-center">
<Button className="flex-1" variant="outline" onClick={handleClose}>
<DialogFooter className="gap-3 sm:justify-center">
<Button className="min-h-12 flex-1 text-base" variant="outline" size="xxl" onClick={handleClose}>
{t('modal.confirm_button_reject')}
</Button>
<Button className="flex-1" onClick={() => void confirm()} disabled={!selectedJar || isConfirming}>
<Button
className="min-h-12 flex-1 text-base"
size="xxl"
onClick={() => void confirm()}
disabled={!selectedJar || isConfirming}
>
{isConfirming ? (
<>
<Spinner className="motion-reduce:hidden" />

View file

@ -90,7 +90,7 @@ export default function PaymentConfirmDialog({
<Dialog open={open} onOpenChange={handleClose} {...dialogProps}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center justify-center gap-2 font-semibold">
<DialogTitle className="flex flex-wrap items-center justify-center gap-2 text-center font-semibold">
{t('send.confirm_send_modal.title')}
</DialogTitle>
<DialogDescription className="text-center">
@ -106,7 +106,7 @@ export default function PaymentConfirmDialog({
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 space-y-1 space-x-4 md:grid-cols-5">
<div className="grid grid-cols-1 gap-x-4 gap-y-1 md:grid-cols-5">
<div className="col-span-1 font-semibold md:text-right">{t('send.confirm_send_modal.label_source_jar')}</div>
<div className="col-span-4">
{meta.sourceJar.name} <span className="text-muted-foreground text-xs">#{meta.sourceJar.jarIndex}</span>
@ -132,7 +132,7 @@ export default function PaymentConfirmDialog({
</div>
<div className="col-span-1 font-semibold md:text-right">{t('send.confirm_send_modal.label_amount')}</div>
<div className="col-span-4 flex items-center gap-1">
<div className="col-span-4 flex flex-wrap items-center gap-1">
{values.amount?.isSweep ? (
<>
<Trans i18nKey="send.confirm_send_modal.text_sweep_balance">
@ -165,7 +165,7 @@ export default function PaymentConfirmDialog({
{t('send.confirm_send_modal.label_estimated_max_collaborator_fee')}
</div>
<div className="col-span-4 flex items-center gap-1">
<div className="col-span-4 flex flex-wrap items-center gap-1">
&le;
<Balance valueString={String(estimatedMaxCollaboratorFee.maxFee)} showBalance={true} />
<span className="text-muted-foreground text-xs">
@ -211,11 +211,16 @@ export default function PaymentConfirmDialog({
</Card>
)}
<DialogFooter className="sm:justify-center">
<Button className="flex-1" variant="outline" onClick={handleClose}>
<DialogFooter className="gap-3 sm:justify-center">
<Button className="min-h-12 flex-1 text-base" variant="outline" size="xxl" onClick={handleClose}>
{t('modal.confirm_button_reject')}
</Button>
<Button className="flex-1" onClick={() => void confirm()} disabled={isConfirming}>
<Button
className="min-h-12 flex-1 text-base"
size="xxl"
onClick={() => void confirm()}
disabled={isConfirming}
>
{isConfirming ? (
<>
<Spinner className="motion-reduce:hidden" />

View file

@ -172,6 +172,9 @@ describe('SendForm', () => {
fireEvent.click(document.querySelector('#btn-sweep-trigger')!)
expect(document.querySelector('#btn-sweep-clear-trigger')).toBeInTheDocument()
expect(
document.querySelector('#send-amount-sweep-from-jar')?.closest('[data-slot="button-group"]'),
).toBeInTheDocument()
// reselect the same jar while sweep is active -> hits the sweep-reset branch
fireEvent.click(screen.getByRole('button', { name: 'Jar 0' }))
@ -194,6 +197,9 @@ describe('SendForm', () => {
fireEvent.click(document.querySelector('#show-address-from-jar-selector-trigger')!)
fireEvent.click(await screen.findByTestId('jar-confirm'))
await waitFor(() => expect(screen.getByTestId('address')).toBeInTheDocument())
expect(
document.querySelector('#send-destination-address-from-jar')?.closest('[data-slot="button-group"]'),
).toBeInTheDocument()
})
it('shows an error when the jar selector address lookup fails', async () => {

View file

@ -55,6 +55,9 @@ type AddressFromJarSelectorDialog = Omit<ComponentProps<typeof JarSelectorDialog
onConfirm: (jar: JarIndex, address: AddressInfo) => void
}
const responsiveSelectionButtonGroupClass =
'w-full flex-col gap-2 sm:flex-row sm:items-stretch [&>*]:rounded-md [&>*]:border sm:[&>*:not(:first-child)]:rounded-l-none sm:[&>*:not(:first-child)]:border-l-0 sm:[&>*:not(:last-child)]:rounded-r-none'
const AddressFromJarSelectorDialog = ({
walletFileName,
onError,
@ -299,11 +302,11 @@ export function SendForm({
<form onSubmit={(event) => void doOnSubmit(event)} className={cn('flex flex-col gap-4', className)} noValidate>
<div className="space-y-2">
<Field className="space-y-4" data-invalid={errors.source !== undefined}>
<div className="flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<FieldLabel>{t('send.label_source_jar')}</FieldLabel>
{sourceJarLabelButton && <>{sourceJarLabelButton}</>}
</div>
<div className="grid grid-cols-5 gap-4">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
{jars.map((jar, index) => (
<SelectableJar
key={index}
@ -351,7 +354,7 @@ export function SendForm({
)}
</FieldLabel>
<ButtonGroup
className={cn({
className={cn('w-full', {
hidden: destinationJar !== undefined,
})}
>
@ -372,6 +375,7 @@ export function SendForm({
type="button"
variant="outline"
size="lg"
className="shrink-0"
disabled={disabled}
onClick={() => setShowQrScannerDialog(true)}
>
@ -383,6 +387,7 @@ export function SendForm({
type="button"
variant="outline"
size="lg"
className="shrink-0"
disabled={disabled}
onClick={() => setShowAddressFromJarSelectorDialog(true)}
>
@ -391,7 +396,7 @@ export function SendForm({
</Button>
</ButtonGroup>
<ButtonGroup
className={cn({
className={cn(responsiveSelectionButtonGroupClass, {
hidden: destinationJar === undefined,
})}
>
@ -401,12 +406,12 @@ export function SendForm({
id="send-destination-address-from-jar"
className={cn(
inputVariants(),
'flex items-center justify-between gap-2',
'flex min-h-8 min-w-0 flex-col items-start justify-center gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-2',
'bg-input/50 dark:bg-input/80 h-auto',
)}
>
<Address value={values.destination.address} copyable={true} />
<Badge className="text-sm" variant="default">
<Address value={values.destination.address} copyable={true} className="text-xs sm:text-sm" />
<Badge className="shrink-0 text-sm" variant="default">
{destinationJar.name} <span className="text-xs">#{destinationJar.jarIndex}</span>
</Badge>
</div>
@ -416,7 +421,7 @@ export function SendForm({
type="button"
variant="outline"
size="lg"
className="h-auto"
className="h-auto w-full sm:w-auto"
disabled={disabled}
onClick={() => {
setValue('destination.address', '', { shouldValidate: true })
@ -447,7 +452,7 @@ export function SendForm({
<FieldLabel htmlFor="send-amount">{t('send.label_amount_input')}</FieldLabel>
<ButtonGroup
className={cn('relative', {
className={cn('relative w-full', {
hidden: isSweep === true,
})}
>
@ -471,6 +476,7 @@ export function SendForm({
type="button"
variant="outline"
size="lg"
className="shrink-0"
disabled={
disabled ||
sourceJar === undefined ||
@ -489,7 +495,7 @@ export function SendForm({
</ButtonGroup>
<ButtonGroup
className={cn({
className={cn(responsiveSelectionButtonGroupClass, {
hidden: isSweep !== true,
})}
>
@ -497,7 +503,7 @@ export function SendForm({
id="send-amount-sweep-from-jar"
className={cn(
inputVariants(),
'flex items-center justify-between gap-2',
'flex min-h-8 min-w-0 flex-col items-start justify-center gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-2',
'bg-input/50 dark:bg-input/80 h-auto',
)}
aria-disabled
@ -506,7 +512,7 @@ export function SendForm({
<Balance valueString={values.amount.sweepAmount.toFixed(0)} />
)}
<Badge className="text-sm" variant="default">
<Badge className="shrink-0 text-sm" variant="default">
{sourceJar?.name} <span className="text-xs">#{sourceJar?.jarIndex}</span>
</Badge>
</div>
@ -516,7 +522,7 @@ export function SendForm({
type="button"
variant="outline"
size="lg"
className="h-auto"
className="h-auto w-full sm:w-auto"
disabled={disabled}
onClick={() => {
setValue('amount.isSweep', false, { shouldValidate: true })

View file

@ -503,7 +503,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
<AlertTitle>{t('send.text_coinjoin_already_running')}</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
{currentPaymentAttempt && (
<pre>
<pre className="max-w-full overflow-x-auto">
{JSON.stringify(
{
sourceJar: currentPaymentAttempt.data.source.fromJar,

View file

@ -24,8 +24,8 @@ const TxFeeUnitInput = (props: React.ComponentProps<typeof RadioGroup>) => {
const id = useId()
return (
<RadioGroup className="flex items-center justify-center" {...props}>
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full max-w-50 cursor-pointer flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px]">
<RadioGroup className="flex flex-col items-stretch justify-center gap-2 sm:flex-row sm:items-center" {...props}>
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full cursor-pointer flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px] sm:max-w-50">
<RadioGroupItem
value={TX_FEE_UNITS.BLOCKS}
id={`${id}-blocks`}
@ -38,7 +38,7 @@ const TxFeeUnitInput = (props: React.ComponentProps<typeof RadioGroup>) => {
</Label>
</div>
</div>
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full max-w-50 flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px]">
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px] sm:max-w-50">
<RadioGroupItem
value={TX_FEE_UNITS.SATS_PER_VBYTE}
id={`${id}-satsperkvb`}

View file

@ -50,7 +50,7 @@ export const UtxoSelectionDialog = ({
placeholder={t('jar_details.utxo_list.placeholder_search')}
disabled={isSubmitting}
/>
<div className="max-h-[55vh] overflow-hidden">
<div className="max-h-[55dvh] min-h-0 overflow-hidden">
<JarUtxosTable
globalFilter={filter}
tableEntries={tableEntries}

View file

@ -143,10 +143,7 @@ const AccountXpubItem = ({ xpub, accountNameAndLabel }: AccountXpubItemProps) =>
{showQrCode ? (
<BitcoinXpubQrCode xpub={xpub.xpub} width={QRCODE_WIDTH} className="animate-in fade-in duration-1000" />
) : (
<div
className={cn('flex items-center justify-center border')}
style={{ height: QRCODE_WIDTH, width: QRCODE_WIDTH }}
>
<div className={cn('flex aspect-square w-full max-w-80 items-center justify-center border')}>
<Button variant="outline" size="lg" onClick={() => setShowQrCode(true)}>
<HatGlassesIcon />
{
@ -175,15 +172,15 @@ export const AccountXpubsAccordion = ({ values }: AccountXpubsAccordionProps) =>
})
return (
<AccordionItem key={index} value={String(account.accountIndex)}>
<AccordionTrigger className="group/xpub-accordion-trigger px-4 no-underline!">
<span className="flex-1 flex-col group-hover/xpub-accordion-trigger:underline">
<AccordionTrigger className="group/xpub-accordion-trigger flex-wrap px-4 no-underline!">
<span className="min-w-0 flex-1 flex-col break-words group-hover/xpub-accordion-trigger:underline">
{account.accountName}
</span>
<span className="text-muted-foreground font-mono">{accountLabel}</span>
<span className="text-muted-foreground shrink-0 font-mono">{accountLabel}</span>
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-1 p-4 pt-2">
<Label className="text-muted-foreground text-sm">
<div className="flex flex-1 items-end gap-2">
<div className="flex min-w-0 flex-1 flex-wrap items-end gap-2">
{/* TODO: i18n */}Extended Public Key
<span className="text-muted-foreground/70 font-mono text-xs">{account.path}</span>
</div>

View file

@ -16,9 +16,9 @@ export const LanguageSelector = () => {
}
return (
<div className="flex items-center justify-between py-2">
<div className="flex items-center gap-2">
<div className="bg-muted/50 flex h-7 w-7 items-center justify-center rounded-lg border">
<div className="flex flex-col items-stretch justify-between gap-2 py-2 sm:flex-row sm:items-center">
<div className="flex min-w-0 items-center gap-2">
<div className="bg-muted/50 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border">
<GlobeIcon className="text-muted-foreground h-3 w-3" />
</div>
<div>
@ -27,7 +27,7 @@ export const LanguageSelector = () => {
</div>
<Select value={i18n.resolvedLanguage} onValueChange={(value) => void handleLanguageChange(value)}>
<SelectTrigger className="h-7 w-38 text-xs" aria-label="Select language">
<SelectTrigger className="h-7 w-full text-xs sm:w-38" aria-label="Select language">
<SelectValue placeholder={getCurrentLanguageDescription()} />
</SelectTrigger>
<SelectContent>

View file

@ -156,8 +156,13 @@ export const RescanChainPage = ({ walletFileName }: RescanChainProps) => {
return (
<div className="mx-auto max-w-4xl space-y-3 p-4">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => void navigate(routes.settings)} title={t('global.back')}>
<div className="flex min-w-0 items-start gap-2 sm:items-center sm:gap-4">
<Button
variant="ghost"
className="shrink-0"
onClick={() => void navigate(routes.settings)}
title={t('global.back')}
>
<ArrowLeftIcon />
<span className="sr-only">{t('global.back')}</span>
</Button>
@ -173,9 +178,9 @@ export const RescanChainPage = ({ walletFileName }: RescanChainProps) => {
/>
{rescanInfo.rescanning && (
<div className="bg-muted/50 mt-4 animate-pulse rounded-lg p-3 duration-100">
<div className="flex items-center gap-2">
<RefreshCwIcon className="h-4 w-4 animate-spin motion-reduce:hidden" />
<span className="text-sm">
<div className="flex min-w-0 items-start gap-2">
<RefreshCwIcon className="mt-0.5 h-4 w-4 shrink-0 animate-spin motion-reduce:hidden" />
<span className="min-w-0 text-sm break-words">
{rescanInfo?.progress === undefined
? t('app.alert_rescan_in_progress')
: t('app.alert_rescan_in_progress_with_progress', {

View file

@ -23,22 +23,22 @@ export const SettingItem = ({
}: SettingsItemProps) => {
const content = (
<div
className={cx('flex items-center justify-between py-2', {
className={cx('flex min-w-0 items-center justify-between gap-2 py-2', {
'hover:bg-muted/50 -mx-2 cursor-pointer rounded-md px-2': !disabled,
'cursor-not-allowed opacity-60': disabled,
})}
onClick={!disabled && action ? () => void action() : undefined}
>
<div className="flex items-center gap-2">
<div className="bg-muted/50 flex h-7 w-7 items-center justify-center rounded-lg border">
<div className="flex min-w-0 items-center gap-2">
<div className="bg-muted/50 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border">
{Icon && <Icon className="text-muted-foreground h-4 w-4 align-middle" />}
{renderIcon?.({ className: 'text-muted-foreground h-4 w-4 align-middle' })}
</div>
<div>
<p className="text-sm font-medium">{title}</p>
<div className="min-w-0">
<p className="text-sm font-medium break-words">{title}</p>
</div>
</div>
{children}
{children !== undefined && <div className="shrink-0">{children}</div>}
</div>
)

View file

@ -189,7 +189,7 @@ export const FeeConfigDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange} {...dialogProps}>
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto">
<DialogContent className="max-h-[90dvh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('settings.fees.title')}</DialogTitle>
<DialogDescription>

View file

@ -386,12 +386,12 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
<Card>
<CardContent className="space-y-5">
<div className="bg-muted/50 flex items-center justify-between rounded-lg border px-4 py-3">
<div>
<div className="bg-muted/50 flex flex-col items-start justify-between gap-2 rounded-lg border px-4 py-3 sm:flex-row sm:items-center">
<div className="min-w-0">
<div className="font-medium">{t('scheduler.complete_wallet_title')}</div>
<div className="text-muted-foreground text-sm">{t('scheduler.complete_wallet_subtitle')}</div>
</div>
<div className="font-semibold">
<div className="shrink-0 font-semibold">
<Balance valueString={String(walletInfo.walletBalanceSummary.calculatedAvailableBalanceInSats)} />
</div>
</div>

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState, type ComponentProps } from 'react'
import { Html5Qrcode } from 'html5-qrcode'
import { Html5Qrcode, type Html5QrcodeCameraScanConfig } from 'html5-qrcode'
import { ClipboardPasteIcon, ImageUpIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@ -16,7 +16,13 @@ import { parseBip21Uri, type Bip21ParseResult } from '@/lib/bip21'
import type { WithRequiredProperty } from '@/types/global'
const QR_READER_ELEMENT_ID = 'qr-scanner-reader'
const SCAN_CONFIG = { fps: 10, qrbox: { width: 250, height: 250 } }
const SCAN_CONFIG = {
fps: 10,
qrbox: (viewfinderWidth, viewfinderHeight) => {
const edgeLength = Math.max(1, Math.floor(Math.min(250, viewfinderWidth * 0.8, viewfinderHeight * 0.8)))
return { width: edgeLength, height: edgeLength }
},
} satisfies Html5QrcodeCameraScanConfig
type QrScannerDialogProps = WithRequiredProperty<
Omit<ComponentProps<typeof Dialog>, 'children'>,
@ -178,7 +184,7 @@ export default function QrScannerDialog({ open, onOpenChange, onScan, ...dialogP
}}
/>
<DialogFooter className="sm:justify-center">
<DialogFooter className="sm:justify-center [&>[data-slot=button]]:min-h-10">
<Button className="flex-1" variant="outline" onClick={() => void handlePaste()}>
<ClipboardPasteIcon />
{t('send.qr_paste_button')}

View file

@ -25,7 +25,7 @@ function AccordionTrigger({ className, children, ...props }: React.ComponentProp
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
'focus-visible:border-ring focus-visible:ring-ring/50 flex min-w-0 flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
className,
)}
{...props}

View file

@ -31,7 +31,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
<div
data-slot="alert-title"
className={cn(
'[&_a]:hover:text-foreground font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3',
'[&_a]:hover:text-foreground min-w-0 font-medium break-words group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3',
className,
)}
{...props}
@ -44,7 +44,7 @@ function AlertDescription({ className, ...props }: React.ComponentProps<'div'>)
<div
data-slot="alert-description"
className={cn(
'text-muted-foreground [&_a]:hover:text-foreground text-sm text-balance md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4',
'text-muted-foreground [&_a]:hover:text-foreground min-w-0 text-sm text-balance break-words md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4',
className,
)}
{...props}

View file

@ -4,7 +4,7 @@ import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
"flex w-fit max-w-full items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:min-w-0 [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {

View file

@ -5,7 +5,10 @@ function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm', className)}
className={cn(
'bg-card text-card-foreground flex min-w-0 flex-col gap-4 rounded-xl border py-4 shadow-sm sm:gap-6 sm:py-6',
className,
)}
{...props}
/>
)
@ -16,7 +19,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-0 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
'@container/card-header grid min-w-0 auto-rows-min grid-rows-[auto_auto] items-start gap-0 px-4 has-data-[slot=card-action]:grid-cols-1 sm:px-6 sm:has-data-[slot=card-action]:grid-cols-[minmax(0,1fr)_auto] [.border-b]:pb-4 sm:[.border-b]:pb-6',
className,
)}
{...props}
@ -36,18 +39,27 @@ function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
className={cn(
'col-start-1 row-start-3 mt-3 max-w-full min-w-0 self-start justify-self-start sm:col-start-2 sm:row-span-2 sm:row-start-1 sm:mt-0 sm:justify-self-end',
className,
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />
return <div data-slot="card-content" className={cn('min-w-0 px-4 sm:px-6', className)} {...props} />
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-footer" className={cn('flex items-center px-6 [.border-t]:pt-6', className)} {...props} />
return (
<div
data-slot="card-footer"
className={cn('flex min-w-0 items-center px-4 sm:px-6 [.border-t]:pt-4 sm:[.border-t]:pt-6', className)}
{...props}
/>
)
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }

View file

@ -61,7 +61,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-2xl',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border p-4 shadow-lg duration-200 sm:max-w-2xl sm:p-6',
className,
)}
onPointerDownOutside={(event) => {

View file

@ -17,7 +17,7 @@ type PlainAddressProps = {
const PlainAddress = ({ value, className, chunked }: PlainAddressProps) => {
const chunks = chunked ? value.match(/.{1,4}/g) : [value]
return (
<span className={cn(styles.bitcoinAddress, chunked === true ? styles.chunked : undefined, className)}>
<span className={cn('min-w-0', styles.bitcoinAddress, chunked === true ? styles.chunked : undefined, className)}>
{chunks?.map((it, index) => (
<span key={index}>{it}</span>
))}
@ -30,7 +30,7 @@ type CopyableAddressProps = PlainAddressProps
const CopyableAddress = (props: CopyableAddressProps) => {
const { t } = useTranslation()
return (
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
<PlainAddress {...props} />
<CopyButton
value={props.value}

View file

@ -52,13 +52,13 @@ const QrCodeComponent = ({
return (
<div
className={cn('group/qrcode relative flex items-center justify-center', className)}
style={{ height: width, width: width }}
className={cn('group/qrcode relative flex aspect-square w-full items-center justify-center', className)}
style={{ maxWidth: width }}
>
{imageDataUrl && (
<>
<img
className={cn('transition-all duration-500', {
className={cn('h-full w-full transition-all duration-500', {
'group-hover/qrcode:blur-[2px]': children !== undefined,
})}
src={imageDataUrl}

View file

@ -63,7 +63,7 @@ export const Cheatsheet = ({ open, onOpenChange }: CheatsheetProps) => {
isClosing ? 'animate-slide-down' : 'animate-slide-up'
}`}
>
<div className="flex-shrink-0 p-6">
<div className="flex-shrink-0 p-4 sm:p-6">
<div className="flex items-center justify-between gap-2">
<PageTitle title={t('cheatsheet.title')} />
<Button onClick={handleClose} variant="ghost" size="icon" title={t('global.close')}>
@ -104,7 +104,7 @@ export const Cheatsheet = ({ open, onOpenChange }: CheatsheetProps) => {
</p>
</div>
<div className="flex-1 overflow-y-auto px-6 pb-10">
<div className="flex-1 overflow-y-auto px-4 pb-8 sm:px-6 sm:pb-10">
<div className="flex flex-col gap-4 sm:gap-6">
<ListItem
number={1}

View file

@ -16,7 +16,7 @@ const PageTitle = ({ title, subtitle, variant = 'default', center = false }: Pag
'text-destructive': variant === 'error',
})}
>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">{title}</h1>
<h1 className="flex flex-wrap items-center gap-2 text-2xl font-semibold tracking-tight">{title}</h1>
{subtitle && <p className="text-muted-foreground mb-2 text-sm">{subtitle}</p>}
</div>
)

View file

@ -10,13 +10,16 @@ type SeedPhraseGridProps = ComponentProps<typeof MaskedText> & {
export const SeedPhraseGrid = ({ value, className, masked, maskedText }: SeedPhraseGridProps) => {
return (
<div className={cn('grid grid-cols-2 gap-2 font-mono', className, 'select-none')}>
<div className={cn('grid grid-cols-2 gap-2 font-mono text-sm sm:text-base', className, 'select-none')}>
{value.map((word, index) => (
<div key={index} className="bg-background flex min-w-32 items-center gap-2 rounded-lg border p-2">
<span className="text-muted-foreground/60 inline-block min-w-8 ps-1 text-right tabular-nums">
<div
key={index}
className="bg-background flex min-w-0 items-center gap-1.5 overflow-hidden rounded-lg border p-2"
>
<span className="text-muted-foreground/60 inline-block min-w-7 shrink-0 ps-1 text-right tabular-nums">
{index + 1}.
</span>
<MaskedText masked={masked} maskedText={maskedText}>
<MaskedText className="min-w-0 truncate" masked={masked} maskedText={maskedText}>
{word}
</MaskedText>
</div>

View file

@ -66,7 +66,7 @@ function PaginationFirst({ className, ...props }: React.ComponentProps<typeof Pa
aria-label={t('global.table.pagination.page_selector.label_first')}
title={t('global.table.pagination.page_selector.label_first')}
size="default"
className={cn('gap-1 px-2.5 md:pr-2.5', className)}
className={cn('hidden gap-1 px-2.5 sm:inline-flex md:pr-2.5', className)}
{...props}
>
<ChevronsLeftIcon />
@ -82,7 +82,7 @@ function PaginationLast({ className, ...props }: React.ComponentProps<typeof Pag
aria-label={t('global.table.pagination.page_selector.label_last')}
title={t('global.table.pagination.page_selector.label_last')}
size="default"
className={cn('gap-1 px-2.5 md:pr-2.5', className)}
className={cn('hidden gap-1 px-2.5 sm:inline-flex md:pr-2.5', className)}
{...props}
>
<span className="hidden md:block">{t('global.table.pagination.page_selector.label_last')}</span>

View file

@ -0,0 +1,115 @@
import { render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ButtonGroup } from './button-group'
import { Card, CardAction, CardContent, CardFooter, CardHeader, CardTitle } from './card'
import { Dialog, DialogContent, DialogTitle } from './dialog'
import { BitcoinXpubQrCode } from './jam/BitcoinQrCode'
import { SeedPhraseGrid } from './jam/SeedPhraseGrid'
import { PaginationFirst, PaginationLast } from './pagination'
import { Tabs, TabsList, TabsTrigger } from './tabs'
vi.mock('qrcode', () => ({
default: {
toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,test'),
},
}))
describe('responsive UI contracts', () => {
it('allows QR codes to shrink below their generated image size', async () => {
render(<BitcoinXpubQrCode xpub="xpub-test" width={320} />)
await waitFor(() => expect(screen.getByRole('img')).toBeInTheDocument())
const container = screen.getByRole('img').parentElement
expect(container).toHaveClass('aspect-square', 'w-full')
expect(container).toHaveStyle({ maxWidth: '320px' })
expect(screen.getByRole('img')).toHaveClass('h-full', 'w-full')
})
it('keeps dialogs inside the dynamic viewport', () => {
render(
<Dialog open>
<DialogContent>
<DialogTitle>Responsive dialog</DialogTitle>
</DialogContent>
</Dialog>,
)
expect(screen.getByRole('dialog')).toHaveClass('max-h-[calc(100dvh-2rem)]', 'overflow-y-auto', 'p-4', 'sm:p-6')
})
it('stacks card actions and uses compact spacing on phone widths', () => {
render(
<Card data-testid="card">
<CardHeader data-testid="card-header">
<CardTitle>Wallet</CardTitle>
<CardAction data-testid="card-action">Balance</CardAction>
</CardHeader>
<CardContent data-testid="card-content">Content</CardContent>
<CardFooter data-testid="card-footer">Actions</CardFooter>
</Card>,
)
expect(screen.getByTestId('card')).toHaveClass('min-w-0', 'gap-4', 'py-4', 'sm:gap-6', 'sm:py-6')
expect(screen.getByTestId('card-header')).toHaveClass(
'px-4',
'has-data-[slot=card-action]:grid-cols-1',
'sm:has-data-[slot=card-action]:grid-cols-[minmax(0,1fr)_auto]',
)
expect(screen.getByTestId('card-action')).toHaveClass('row-start-3', 'sm:row-start-1')
expect(screen.getByTestId('card-content')).toHaveClass('min-w-0', 'px-4', 'sm:px-6')
expect(screen.getByTestId('card-footer')).toHaveClass('min-w-0', 'px-4', 'sm:px-6')
})
it('makes tab lists horizontally scrollable', () => {
render(
<Tabs defaultValue="one">
<TabsList data-testid="tabs-list">
<TabsTrigger value="one">One</TabsTrigger>
<TabsTrigger value="two">Two</TabsTrigger>
</TabsList>
</Tabs>,
)
expect(screen.getByTestId('tabs-list')).toHaveClass('max-w-full', 'overflow-x-auto', 'overflow-y-hidden')
})
it('lets button groups and seed entries shrink within narrow containers', () => {
render(
<>
<ButtonGroup data-testid="button-group">
<input aria-label="Amount" />
<button type="button">Submit</button>
</ButtonGroup>
<SeedPhraseGrid value={['abandon', 'ability']} masked={false} />
</>,
)
expect(screen.getByTestId('button-group')).toHaveClass('max-w-full', '[&>input]:min-w-0')
expect(screen.getByLabelText('Amount')).toBeInTheDocument()
const seedWords = ['abandon', 'ability']
seedWords.forEach((word) => {
const wordElement = screen.getByText(word)
expect(wordElement).toHaveClass('min-w-0', 'truncate')
expect(wordElement.closest('div')).toHaveClass('min-w-0', 'overflow-hidden')
})
})
it('hides first and last pagination controls on phone widths', () => {
render(
<>
<PaginationFirst onClick={() => undefined} />
<PaginationLast onClick={() => undefined} />
</>,
)
expect(screen.getByTitle('global.table.pagination.page_selector.label_first')).toHaveClass(
'hidden',
'sm:inline-flex',
)
expect(screen.getByTitle('global.table.pagination.page_selector.label_last')).toHaveClass(
'hidden',
'sm:inline-flex',
)
})
})

View file

@ -30,7 +30,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit max-w-full min-w-0 items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:min-w-0 *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}

View file

@ -13,7 +13,7 @@ function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimi
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
'bg-muted text-muted-foreground inline-flex h-9 w-fit max-w-full items-center justify-start overflow-x-auto overflow-y-hidden rounded-lg p-[3px]',
className,
)}
{...props}

View file

@ -120,7 +120,10 @@ export const PasswordVerificationForm = ({
{errors.password?.message && <div className="text-destructive text-xs">{errors.password.message}</div>}
</div>
<Field orientation="horizontal" className="justify-end">
<Field
orientation="horizontal"
className="flex-col items-stretch justify-end sm:flex-row sm:items-center [&>*]:w-full sm:[&>*]:w-auto"
>
{onCancel !== undefined && (
<Button type="button" variant="outline" onClick={() => void onCancel()}>
{t('global.cancel')}

View file

@ -94,9 +94,9 @@ export const AccountDetailsTabContent = ({ value }: AccountDetailsTabContentProp
return (
<AccordionItem key={index} value={String(index)}>
<AccordionTrigger className="group/account-branch-accordion-trigger items-center px-4 no-underline!">
<div className="flex flex-col gap-0.25">
<div className="flex min-w-0 flex-col gap-0.25">
<span className="group-hover/account-branch-accordion-trigger:underline">{typeTitle}</span>
<span className="text-muted-foreground font-mono select-all">{branch.derivation}</span>
<span className="text-muted-foreground font-mono break-all select-all">{branch.derivation}</span>
</div>
</AccordionTrigger>
<AccordionContent className="p-4 pt-2">

View file

@ -205,7 +205,7 @@ export const UtxosContent = ({ enabled, walletFileName, addressSummary, jar }: U
</div>
</div>
<div className="flex flex-col justify-between gap-2 sm:flex-row sm:items-center">
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
disabled={!operationsEnabled || walletInfoIsFetching}
@ -239,7 +239,7 @@ export const UtxosContent = ({ enabled, walletFileName, addressSummary, jar }: U
placeholder={t('jar_details.utxo_list.placeholder_search')}
value={searchFilter}
onChange={(event_) => setSearchFilter(event_.target.value)}
className="max-w-xs"
className="w-full sm:max-w-xs"
/>
</div>
<JarUtxosTable

View file

@ -24,7 +24,7 @@ export function WalletJarsDetailsOverlay({
return (
<Dialog open={open} onOpenChange={() => onOpenChange(false)} {...dialogProps}>
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-screen max-w-screen! flex-col rounded-none border-none">
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-dvh max-h-dvh! max-w-screen! flex-col overflow-hidden rounded-none border-none">
<DialogHeader className="px-2">
<DialogTitle className="sr-only flex items-center gap-2">
<PageTitle title={/* todo: i18n */ t('Wallet Jars Details')} />